Skip to content

Erupt SSO Single Sign-On ​

erupt-sso lets erupt delegate login to an external identity provider: OIDC services such as Keycloak, Authing and Okta, as well as platforms that only offer OAuth 2.0 such as GitHub, Gitee and Feishu. Providers are configured as rows in the admin UI under System Management → SSO Provider — pick a provider preset, paste the client credentials, and that is it: no code, no config file, no restart; the login page shows the button as soon as the row is saved.

Minimum version: 2.3.0

The module runs the OAuth 2.0 authorization code flow with PKCE enforced. The code is exchanged on the server, the resulting access token is spent on exactly one user-info call, the id_token is never parsed, and no JWT library is pulled in.

Setup ​

xml
<dependency>
  <groupId>xyz.erupt</groupId>
  <artifactId>erupt-sso</artifactId>
  <version>${erupt.version}</version>
</dependency>

erupt-spring-boot-starter-all already includes this module. It depends on erupt-upms and erupt-data-jpa; auto configuration adds an SSO Provider menu under System Management (plus a hidden SSO Binding menu, see below).

The module announces itself to the frontend through registerProp("erupt-sso"); the login page then fetches the provider list and renders the buttons. A system without the module keeps its login page unchanged and gains no second way in.

How it works ​

Design points worth knowing:

  • The browser carries no trusted state: state is only a key into a short-lived server record (provider + PKCE verifier) that is burnt on first use; a state issued for one provider cannot be replayed against another.
  • The token never travels in a URL: after the callback the browser lands on the login page with nothing but a one-time ticket valid for 60 seconds, which the frontend trades for a session token via POST /erupt-api/sso/exchange. The token never appears in the address bar, browser history or a Referer header.
  • The return address is never taken from the request: the callback always redirects to erupt-app.login-page-path (or <host>/#/passport/login when unset), so there is no open redirect.
  • Client authentication at the token endpoint defaults to client_secret_post; HTTP Basic is used only when the OIDC discovery document says client_secret_basic is the only supported method.
  • Same pipeline as password login: account status, expiry and IP whitelist checks, and the LoginProxy post-login hooks, apply to SSO logins too.

Four exchange flows ​

The diagram shows the plain OAuth 2.0 case. What happens once the browser comes back with the code is decided by the row's Provider Type; there are four flows (SsoProviderType.Flow):

FlowProvider typesExchange
OAUTH2Every OIDC preset, GitHub, Gitee, Feishu, Zoom, and CustomAs in the diagram: a form-encoded POST to the Token URL answered in JSON, then a bearer GET on the User Info URL
DINGTALKDingTalkThe token request is a JSON POST, and the user token travels in DingTalk's own request header when contact/users/me is read
WECOMWeComThe corp access token (CorpID + Secret) resolves the code into a member userid, the profile is then read from the contact book; the sensitive fields (phone, email, avatar) unlocked by the snsapi_privateinfo user ticket are merged into the claims. A visitor outside the corp fails with "The account is not a member of the enterprise"
WECHATWeChatToken and user info are both GETs with query parameters; the openid from the token response is passed on to the user-info call

Rows created before 2.3.0 have an empty type column and behave as OAUTH2, unchanged.

Configuring a provider ​

SSO provider list

System Management → SSO Provider; every row is one provider. The usual order of filling it in: pick the matching preset in Provider Type, which fills the endpoints, scopes and claim fields; paste the Client ID / Client Secret from the provider's console; and for a self-hosted service replace the <host>, <realm>, <agentid> style placeholders left in the URLs, then save. Choosing Custom leaves every field to you and runs plain OAuth 2.0. The form has four groups:

Basic ​

FieldDescriptionDefault
Provider Type18 presets (Keycloak, Authing, Casdoor, Okta, Auth0, Microsoft Entra ID, Google, GitLab, Atlassian, Slack, Zoom, GitHub, Gitee, Feishu, DingTalk, WeCom, WeChat) plus Custom, see Provider Presets. Picking a preset fills in the icon, the issuer or the three URLs, the scopes, the account / name / email / phone / avatar claims and the Open ID Claim; the Code (the preset name in lower case, e.g. keycloak) and Name are only suggested into blank fields, never overwritten; the hints under Client ID / Client Secret switch to the provider console's own wording (Entra's "Application (client) ID", Feishu's "App ID"). Any <placeholder> a preset leaves in a URL must be replaced, otherwise the save is refused. The type also selects the login exchange, see Four exchange flowsCustom
Code2–32 letters, digits, - or _, unique. Part of the callback URL, so it cannot be edited after creation — changing it would silently break the callback registered at the provider—
NameLabel of the button on the login page; i18n keys are translated—
IconFont Awesome icon picker, shown on the button—
StatusEnabled / Disabled; a disabled provider is neither listed on the login page nor accepted on callbackEnabled
SortOrder of the buttons on the login page; the list supports drag sorting—

Endpoint ​

FieldDescriptionDefault
IssuerOIDC issuer. When the three URLs below are empty they are discovered from <issuer>/.well-known/openid-configuration; the result is cached per issuer and evicted when the row changes or is deleted—
Authorize URLauthorization endpointdiscovered from Issuer
Token URLtoken endpointdiscovered from Issuer
User Info URLuserinfo endpointdiscovered from Issuer
Redirect URIWhere the provider sends the browser back; must match what is registered at the provider verbatim. When empty it is derived from the incoming request as http(s)://<this host>/erupt-api/sso/callback/<code>; fill it in only behind a reverse proxy or when the public domain differs from the backend'sempty, derived

Validation on save: either all three URLs are filled in, or an Issuer is given and it serves a discovery document. An issuer without one (GitHub, Gitee and Feishu all fall in this group) is refused on save with "The issuer publishes no OpenID discovery document, fill in the Authorize, Token and User Info URLs" rather than failing at the first login. The URLs may also be filled in partially; whatever is filled overrides the discovered value.

Client ​

FieldDescriptionDefault
Client IDIssued when registering the application at the provider—
Client SecretWrite only: not shown in the table, masked in the form, and the stored value is kept when the mask is submitted unchanged—
Messaging KeyShown only when the Provider Type is WeCom, DingTalk or Slack; write only as well. WeCom and DingTalk take the app's AgentId, Slack the Bot User OAuth Token (xoxb-...). Login never reads it; it is used only when an erupt-notice provider-backed channel sends a message, so a row used for login alone may leave it empty—
ScopesTag input joined with spaces; presets are the OIDC standard scopes openid profile email phone address groups, and provider-specific ones (such as GitHub's read:user) are simply typed inopenid profile email

User Mapping ​

The user info the provider returns is a JSON object; this group decides which fields are read:

FieldDescriptionDefault
Account ClaimClaim matched against an erupt user's account on first login; when empty the Email Claim is used insteadpreferred_username
Name ClaimMapped to the user's namename
Email ClaimMapped to the user's emailemail
Phone ClaimMapped to the user's phone; usually phone_number for OIDC, mobile for Feishu—
Avatar ClaimURL of the picture; usually picture for OIDC, avatar_url for Feishu—
Open ID ClaimThe claim other modules (such as notifications) use to reach this user at the provider: Feishu open_id, DingTalk unionId, WeCom userid, Slack https://slack.com/user_id. Its value is stored in the binding's Open ID column and refreshed on every login; when empty the binding carries no Open ID and push channels cannot reach the userfilled by the preset
Sync ProfileEvery login: name, email, phone and avatar are overwritten from the provider each time; Fill empty only: only blank fields in erupt are filled, so values an administrator set by hand surviveEvery login
Auto CreateCreate: an unknown identity creates a user from the provider's profile; Reject: login fails with "No matching account in this system, please contact an administrator" plus the claim name and value, so the administrator knows what to createCreate
Default RolesRole set given to a user the first time this provider creates it—
Grant Roles On LoginWhen on, a bound user is topped up with any missing default role on every login; roles are only ever added, never revoked, so grants by an administrator or another provider stayOff
RemarkFree text—

The subject (unique identifier) needs no configuration: the module tries sub, id, openid, open_id, unionid, union_id, userId, user_id in turn and takes the first non-empty value.

Account binding and auto creation ​

How one SSO login lands on an erupt user:

  1. Look up the binding: search the binding table by "provider + subject"; a hit is the user. A binding keys on the subject only and never falls back to email or account name afterwards — those get renamed and reused, the subject does not.
  2. First login matches by account claim: with no binding, the value of the Account Claim (or the Email Claim when that is empty) is compared with the account column of the erupt user table. A match creates the binding; this happens exactly once in a binding's life.
  3. Auto create: still no match and Auto Create is on — a user is created with the account claim as account, the name claim as name (falling back to the account), enabled, non-admin, holding the Default Roles. The user has no usable password (a random, irreversible hash), can only sign in through the provider, and is never nagged to change an initial password. With Auto Create off the login is rejected.
  4. Profile sync and role top-up: name, email, phone, avatar and roles are updated according to Sync Profile and Grant Roles On Login.
  5. Usability check: a disabled, expired or out-of-whitelist account is rejected even though the provider let it through, exactly as for password login.

Connecting existing accounts

Make the provider's account claim equal the account name in erupt: the binding is created the first time the user presses the SSO button, and renaming the account afterwards no longer matters.

Login page behaviour ​

SSO buttons on the login page

While rendering, the login page calls GET /erupt-api/sso/providers for the enabled providers (code, name, icon — no secrets):

  • Up to 3 providers share one row as buttons with icon and name
  • More than 3 collapse into a row of round icon buttons with the name as tooltip
  • A click navigates to GET /erupt-api/sso/authorize/<code>; after authentication the browser returns to the login page, which exchanges the ticket and enters the system automatically
  • When the provider refuses or something fails midway, the login page shows the error (ssoError parameter) and the user can retry

The buttons adapt to all five login layouts (theme.loginLayout) and the workspace skin without extra settings.

Provider Presets ​

Each of the 18 presets in Provider Type fills in the endpoints (an issuer for OIDC services, the three URLs otherwise), the scopes, the claim mapping and the Open ID Claim; what is left for you is the client credentials from the provider's console and the <placeholder> parts in a self-hosted service's URLs. The values follow each provider's documentation as of the release — when a login fails, check the row against the provider's current docs first.

TypeProtocolPlaceholders to replaceAccount ClaimOpen ID ClaimNotes
KeycloakOIDC discovery<host>, <realm>preferred_username—Issuer https://<host>/realms/<realm>
AuthingOIDC discovery<app>preferred_username—Issuer https://<app>.authing.cn/oidc; scopes include phone
CasdoorOIDC discovery<host>preferred_username—Name Claim displayName, Phone Claim phone
OktaOIDC discovery<org>preferred_username—Issuer points at the oauth2/default authorization server
Auth0OIDC discovery<tenant>nickname——
Microsoft Entra IDOIDC discovery<tenant-id>email—Credential hints "Application (client) ID" / "Client secret value"
GoogleOIDC discoverynoneemail——
GitLabOIDC discoverynone (change the issuer for a self-hosted instance)preferred_username—Credential hints "Application ID" / "Secret"
AtlassianOIDC discoverynoneemailsub—
SlackOIDC discoverynoneemailhttps://slack.com/user_idSlack namespaces its claims by URL; sending messages needs the Bot User OAuth Token in Messaging Key
ZoomOAuth2noneemailidThe token endpoint takes the client credentials as HTTP Basic; scope user:read:user
GitHubOAuth2nonelogin—Scopes read:user user:email; a private email comes back as null and only affects profile sync
GiteeOAuth2nonelogin—Scopes user_info emails
FeishuOAuth2noneuser_idopen_idEnveloped user-info response, opened by the module; four contact:user.*:readonly scopes; Lark (international) works by changing the domain of the three URLs
DingTalkDingTalknonemobileunionIdCredential hints "AppKey" / "AppSecret"; for messaging the unionId is resolved to the corp userid once and cached, Messaging Key = AgentId
WeComWeCom<agentid> (in the Authorize URL)useriduseridClient ID = CorpID, Client Secret = the self-built app's Secret; Messaging Key = the same AgentId
WeChatWeChatnoneopenidopenidWebsite QR login, scope snsapi_login
CustomOAuth2—by handby handLeaves the form alone, plain OAuth 2.0

Keycloak walkthrough ​

  1. Choose Keycloak as the Provider Type; the issuer arrives as https://<host>/realms/<realm> — replace both placeholders, e.g. https://sso.example.com/realms/erupt. The three URLs come from the discovery document and stay empty.
  2. Create a confidential client in Keycloak and paste its Client ID and Client Secret into the Client group.
  3. In that client set Valid redirect URIs to https://<erupt host>/erupt-api/sso/callback/keycloak (keycloak being the Code the preset suggested).

Authing, Casdoor, Okta, Auth0, Entra ID and the other OIDC presets work the same way: replace the placeholder in the issuer, paste the credentials, register the callback.

Feishu, WeCom and DingTalk ​

Besides pasting the credentials, these three need two things done in their own consoles:

  • Register the callback: add https://<erupt host>/erupt-api/sso/callback/<code> to the app's redirect URLs / trusted domains.
  • Enable the scopes: grant the app the permissions matching the scopes the preset filled in — Feishu's four contact:user.*:readonly scopes, WeCom's snsapi_privateinfo (members' sensitive fields), DingTalk's openid login scope.

A few more notes: WeCom needs the <agentid> in the Authorize URL replaced with the AgentId of the self-built app (put the same value in Messaging Key if the row will also push notifications); Feishu's user-info endpoint answers with an envelope, { "code": 0, "msg": "success", "data": { ... } }, which the module opens — data becomes the claims and a non-zero code fails with "The identity provider could not be reached (code: msg)", WeCom and DingTalk envelopes being handled the same way; Lark, the international edition, only needs the domain of the three URLs changed to Lark's.

Managing SSO bindings ​

The SSO Binding menu records "which erupt user signs in with which subject of which provider", plus the profile snapshot the provider returned at login. Bindings are created by signing in, and an administrator only needs them to investigate or unbind, so the menu is hidden by default (MenuStatus.HIDE) — switch it to visible in Menu Management when needed. The menu is not the only way in: on the SSO Provider list, the "SSO Binding" drill-down on a provider row shows only the bindings of that provider.

ColumnDescription
ProviderThe owning provider
UserThe bound erupt user
SubjectThe provider-side unique identifier, picked by the login flow itself (sub / id / open_id…), not configurable
Open IDThe messaging identifier mapped through the provider's Open ID Claim, refreshed on every login; push channels address the user by it
Last LoginWhen the user last signed in through this provider
ClaimsThe raw user info the provider returned at the last login (JSON, envelope removed), rewritten in full on every login. A snapshot, not a source of truth

The menu allows viewing and deleting only. After a binding is deleted, the user's next login through that provider goes through "match by account claim → auto create" again.

Reading bindings from other modules ​

EruptSsoBindService (a Spring bean) exposes what a binding holds to other modules. Every method reads the stored binding only and never calls the provider; a user who has never signed in through a provider has no binding there and gets an empty result:

MethodReturns
find(userId, providerCode)Optional<EruptSsoBind>, the user's binding with the provider (by code)
findAll(userId)List<EruptSsoBind>, the bindings of every provider the user has signed in through
openId(userId, providerCode)Optional<String>, the binding's Open ID
claim(userId, providerCode, name)Optional<String>, any top-level field of the user-info snapshot, such as Feishu's tenant_key or WeCom's department, without a column for it; a non-primitive value comes back as its JSON text
claims(userId, providerCode)Optional<JsonObject>, the whole snapshot
java
@Resource
private EruptSsoBindService eruptSsoBindService;

public void reachOnFeishu(Long userId) {
    // The user's open_id at Feishu; present only if they signed in through the provider whose code is "feishu"
    eruptSsoBindService.openId(userId, "feishu").ifPresent(openId -> {
        // call the Feishu API with the open_id ...
    });
}

A module that needs to call the provider as the application rather than as the user can inject SsoProviderApi and call appAccessToken(EruptSso): it fetches an app-level token with the row's client credentials (Feishu tenant_access_token, DingTalk accessToken, WeCom access_token), caches it per row until shortly before expiry and drops it when the row changes; only the Feishu, DingTalk and WeCom types support it, any other type throws "This provider type has no application level API access". This is how the erupt-notice push channels send their messages.

Endpoints ​

All four endpoints are reachable without a session — they serve users who are not signed in yet, and each carries its own proof: a state, a ticket, or nothing worth protecting:

EndpointDescription
GET /erupt-api/sso/providersEnabled providers (code, name, icon) for the login page buttons
GET /erupt-api/sso/authorize/{provider}Generates state and PKCE, then 302 to the provider's authorize URL
GET /erupt-api/sso/callback/{provider}The provider's callback; on success 302 to the login page with ssoTicket, on failure with ssoError
POST /erupt-api/sso/exchangeBody { "ssoTicket": "..." }; returns the same LoginModel (with token) as password login. The ticket is single-use and valid for 60 seconds; expired tickets get "Sign-in ticket expired, please sign in again"

Common messages and what they mean:

MessageSituation
Sign-in request is invalid or expired, please start againThe state is missing, expired (10 minutes), already used, or belongs to another provider
Provider not found or disabledWrong code or a disabled row
The issuer publishes no OpenID discovery document…On save, the three URLs could not be discovered from the Issuer
Could not obtain an access tokenThe token endpoint returned no access_token, usually a client id / secret / redirect URI mismatch
The identity provider could not be reached (…)The token or user-info endpoint returned non-2xx, non-JSON, or an envelope with non-zero code; the brackets carry the provider's own code and message
The provider returned no user identifierNo usable subject field in the user info; the brackets list the field names that were returned
The provider returned no account claimBoth the Account Claim and the Email Claim are empty
No matching account in this system, please contact an administratorAuto Create is off and no account matched; the brackets show the claim name and value
Replace the <placeholder> in the URL before saving (…)On save a URL still contains a preset placeholder such as <host>, <realm> or <agentid>; the brackets show that URL
The account is not a member of the enterpriseWeCom: the account that scanned the code is not in the corp's contact book
This provider type has no application level API accessSsoProviderApi.appAccessToken was called on a row whose type is not Feishu / DingTalk / WeCom

Database tables ​

TableDescription
e_upms_ssoProviders, extends MetaModelUpdateVo; columns mirror the form: type VARCHAR(32) (the Provider Type enum name; empty on rows created before 2.3.0, treated as Custom), code (unique), name, icon, status, sort, issuer, authorize_url, token_url, user_info_url, redirect_uri (all VARCHAR(512)), client_id, client_secret VARCHAR(512), messaging_key VARCHAR(255), scopes VARCHAR(255), account_claim / name_claim / email_claim / phone_claim / avatar_claim / open_id_claim VARCHAR(64), sync_profile, auto_create, grant_roles_on_login, remark
e_upms_sso_roleDefault roles of a provider, sso_id + role_id
e_upms_sso_bindBindings, extends MetaModelUpdateVo: sso_id, erupt_user_id, subject VARCHAR(255) NOT NULL, open_id VARCHAR(255), last_login_time DATETIME(6), claims VARCHAR(10485760) (AnnotationConst.CONFIG_LENGTH, mapped to LONGTEXT on MySQL); unique constraint (sso_id, subject)

Projects with JPA schema generation enabled get the tables on upgrade; projects that maintain their schema by hand should create them from the table above.

Relation to the Spring Security OAuth2 approach

Before 2.3.0 the documented approach was to add spring-boot-starter-oauth2-client, configure the provider in application.yml and write your own callback controller that exchanges the identity for an erupt token — see Login & Authentication → Advanced: Spring Security OAuth2 Client. It still works and suits deep customisation of the authorization flow; for ordinary integrations erupt-sso is recommended: configure it in the admin UI, with no code and no restart.

Contributors

The avatar of contributor named as YuePeng YuePeng
The avatar of contributor named as Claude Fable 5.1 Claude Fable 5.1

Changelog

Released under the Apache-2.0 License.