Open with AI
Identity brokering
Identity brokering lets users sign in to Issuerd realms with an external identity provider (IdP) — a corporate OIDC provider or a social login such as Google, GitHub, or Microsoft — instead of (or in addition to) a local password. Issuerd acts as a broker: your applications only ever talk OIDC to Issuerd, and Issuerd handles the upstream federation, account creation, and account linking. This document covers configuring external providers, the login and account-linking flows, claim mappers, and operational caveats. It is aimed at system operators and realm administrators. For LDAP/Kerberos user federation (a different feature — Issuerd validates credentials against a directory directly), see user-federation.md.
Contents
Section titled “Contents”- How brokering works
- Adding an external OIDC provider
- Social provider presets
- First broker login
- Account linking
- Identity provider mappers
- Sending users straight to a provider
- Logout semantics
- Troubleshooting
How brokering works
Section titled “How brokering works”A brokered login is an OIDC authorization-code flow nested inside another one: the application runs a code flow against Issuerd, and Issuerd runs its own code flow (as a confidential client) against the external IdP.
- The application sends the browser to Issuerd’s authorization endpoint,
/realms/{realm}/protocol/openid-connect/auth. Issuerd pauses a browser flow and either renders the login page — which shows a “Continue with {display name}” button for every enabled broker provider (fed by the unauthenticatedGET /realms/{realm}/login/contextendpoint) — or, when the request carries akc_idp_hint, redirects straight to the provider (see Sending users straight to a provider). - The button (or hint redirect) lands on
GET /realms/{realm}/broker/{alias}/login?flow={id}. Issuerd verifies the paused flow and its correlation cookie, stores a single-use broker-state entry (nonce, PKCE verifier, flow id; 10-minute TTL) in the distributed cache, and 303-redirects the browser to the external IdP’s authorization endpoint withresponse_type=code, the configuredclientId, the broker callback URL asredirect_uri, the configured scopes,state,nonce, and an S256 PKCE challenge (enabled by default). - The user authenticates at the IdP, which redirects back to the broker callback:
GET /realms/{realm}/broker/{alias}/endpoint?code=...&state=.... APOSTvariant of the same path accepts IdPs that deliver the code viaresponse_mode=form_post. Thestatekey is single-use and consumed on arrival. - Issuerd exchanges the code server-to-server at the IdP’s token endpoint (client authentication per
clientAuthMethod, PKCE verifier attached). Broker HTTP calls have a 10-second timeout so a hung IdP cannot stall logins. - The identity is verified:
- If the IdP returned an
id_token, it is validated against the IdP’s JWKS (signature,exp/nbfwith 60 s leeway,audmust contain the configuredclientId,issmust equal the configured/discovered issuer when one is set,noncemust match). Only asymmetric signatures (RSA/EC/EdDSA) are accepted —HS*id_tokens are rejected. The JWKS is cached per (realm, alias) for one hour; an unknownkidtriggers one immediate refetch-and-retry. If the id_token carries no profile claims (email,preferred_username,nameall absent — legal per OIDC Core §5.4) and a userinfo endpoint is available, Issuerd fetches userinfo and merges it; the signed id_token wins conflicts, and asubmismatch rejects the login. - If there is no
id_token(GitHub-style OAuth providers), Issuerd falls back to the userinfo endpoint with the access token.
- If the IdP returned an
- The verified identity is normalized (subject from
sub, oridfor GitHub; username frompreferred_username/login; plusemail,email_verified,given_name,family_name,name) and looked up in the realm’s link table:- Known link → the login completes immediately (a disabled local account is rejected with 403).
- Unknown identity → the first-broker-login decision (see First broker login): silent create/link, or a review/link page.
- Completion issues a normal Issuerd session — the standard consent gate applies, required actions are enforced (with one exception: the “no password ⇒ UPDATE_PASSWORD” auto-rule never fires on broker logins), and the browser is redirected to the application with an Issuerd authorization code. The session’s auth method is
identity_provider, and the LOGIN event carriesmethod=identity_providerin its details.
Endpoint resolution and caching: when the provider is configured with an issuer, Issuerd fetches {issuer}/.well-known/openid-configuration and caches it per (realm, alias) for one hour in the distributed cache. In a clustered deployment this cache is Redis, so broker state and metadata are shared across nodes and the callback may land on any node.
Adding an external OIDC provider
Section titled “Adding an external OIDC provider”An identity provider is a per-realm IdentityProviderConfig record: alias, provider_id (oidc for a generic provider; google/github/microsoft for the social presets), enabled, and a free-form string map config. All broker behavior is driven by the config keys below — spellings follow Keycloak’s OIDC IdP config where Keycloak defines one, and all values are strings (quote booleans in JSON/YAML: "true").
| Key | Required | Default | Meaning |
|---|---|---|---|
clientId |
yes | — | Client id registered for Issuerd at the external IdP. |
clientSecret |
yes | — | Client secret for the token-endpoint exchange. |
issuer |
with discovery | — | OIDC issuer URL. The discovery document is fetched from {issuer}/.well-known/openid-configuration. |
authorizationUrl |
without discovery | — | Explicit authorization endpoint (overrides discovery). |
tokenUrl |
without discovery | — | Explicit token endpoint (overrides discovery). |
userInfoUrl |
no | discovered | Explicit userinfo endpoint; also the fallback identity source when the IdP issues no id_token. |
jwksUrl |
no | discovered | Explicit JWKS endpoint for id_token validation. |
useDiscovery |
no | "true" when issuer is set |
Set "false" to use only the explicit *Url keys. |
defaultScope |
no | openid profile email |
Space-separated scopes requested at the IdP. |
clientAuthMethod |
no | client_secret_basic |
client_secret_basic or client_secret_post. |
pkceEnabled |
no | "true" |
Send an S256 PKCE challenge to the IdP and hold the verifier for the exchange. |
trustEmail |
no | "false" |
Trust the IdP’s email claim: skip the review page and auto-link on verified-email conflicts (see First broker login). |
syncMode |
no | import |
import = run mappers when a user is created/linked; force = re-run mappers on every brokered login. |
storeTokens |
no | "false" |
Persist the external refresh token on the link row (see note below). |
displayName |
no | the alias | Label of the login-page button (“Continue with …”). |
mappers |
no | [] |
JSON array of mapper objects — see Identity provider mappers. |
Explicit *Url keys always win over discovered values. The iss check on incoming id_tokens uses the configured issuer, falling back to the discovered issuer — configure an issuer even alongside explicit URLs when the IdP signs tokens with a stable issuer.
Note:
storeTokenspersists the IdP’s refresh token on the link record (stored as plain text in the database, never exposed through any API response). Nothing in Issuerd currently consumes the stored token — there is no upstream refresh path — so enabling it only widens what a database leak exposes.
Provider ids ldap, kerberos, and saml are not broker providers — LDAP/Kerberos are user-federation providers (user-federation.md) and SAML brokering is not implemented. Such a provider never appears on the login page and its broker endpoints answer “this identity provider is not available for sign-in”.
Admin console
Section titled “Admin console”In the admin console (/admin/console, see administration.md) select the realm and open Identity Providers. The create dialog offers the provider presets (see below) and pre-fills their config; everything remains editable. The edit page manages the config map and the provider’s mappers, and can run a connection test.
Admin API
Section titled “Admin API”CRUD lives under /admin/realms/{realm}/identity-provider/instances (reads require the view-realm or manage-realm role; writes require manage-realm):
# Obtain an admin token first — see administration.md (password grant on the# master realm's admin-cli client).TOKEN=...
curl -s -X POST "http://localhost:8080/admin/realms/myrealm/identity-provider/instances" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "alias": "corporate-idp", "display_name": "Corporate SSO", "provider_id": "oidc", "enabled": true, "config": { "clientId": "issuerd-broker", "clientSecret": "s3cr3t", "issuer": "https://idp.example.com", "defaultScope": "openid profile email", "trustEmail": "true" } }'The representation fields are alias, display_name (a shorthand that maps to the displayName config key — an explicit config.displayName entry wins), provider_id, enabled (defaults to true), and config. Further endpoints:
GET|PUT|DELETE /admin/realms/{realm}/identity-provider/instances/{alias}GET|POST .../instances/{alias}/mappers,PUT|DELETE .../instances/{alias}/mappers/{name}POST .../instances/{alias}/test-connection— validates the config and, with discovery enabled, actually fetches and parses the discovery document; returns{"status": "ok"|"error", "problems": [...]}.GET /admin/enums/identity-provider-presets— the preset list below (also embedded inGET /admin/serverinfo).
Provision YAML
Section titled “Provision YAML”The provision file’s identity_providers section registers providers at boot/provision time (see provisioning.md for apply semantics and ${VAR} environment interpolation):
identity_providers: - realm: myrealm alias: corporate-idp provider_id: oidc enabled: true config: clientId: issuerd-broker clientSecret: ${CORP_IDP_CLIENT_SECRET} issuer: https://idp.example.com defaultScope: openid profile email trustEmail: "true" syncMode: importSocial provider presets
Section titled “Social provider presets”Presets are pure configuration templates (listed by identity_provider_presets() in crates/issuerd-core/src/broker.rs, served at /admin/enums/identity-provider-presets). Choosing a preset sets the provider_id and pre-fills the config map; nothing is hardcoded elsewhere. What you must still do at every provider: register an OAuth/OIDC client (app) for Issuerd and paste its client id and secret into clientId/clientSecret. The redirect URI to register at the provider is the broker callback of your realm and alias:
{issuer_url}/realms/{realm-name}/broker/{alias}/endpointFor example https://sso.example.com/realms/myrealm/broker/google/endpoint for alias google in realm myrealm (realm name, never the UUID). issuer_url is the public base URL from issuerd.toml — see configuration.md.
Preset (provider_id) |
Pre-filled config | Registration notes |
|---|---|---|
google |
issuer=https://accounts.google.com, defaultScope="openid profile email", trustEmail=true |
Create an OAuth 2.0 client (Web application) in Google Cloud Console and add the broker endpoint URL as an authorized redirect URI. |
microsoft |
issuer=https://login.microsoftonline.com/common/v2.0, defaultScope="openid profile email", trustEmail=true |
Register an app in Microsoft Entra ID. The preset uses the multi-tenant /common endpoint; replace the issuer with https://login.microsoftonline.com/{tenant}/v2.0 to pin a single tenant. |
github |
authorizationUrl=https://github.com/login/oauth/authorize, tokenUrl=https://github.com/login/oauth/access_token, userInfoUrl=https://api.github.com/user, defaultScope="read:user user:email", useDiscovery=false |
Register an OAuth App under GitHub developer settings; the “Authorization callback URL” is the broker endpoint URL. |
Provider-specific behavior worth knowing:
- GitHub issues no OIDC
id_tokenand has no discovery — hence the explicit URLs anduseDiscovery=false. The identity comes from the userinfo call: the subject is the numeric GitHub userid, the username is the GitHublogin. GitHub’s/userresponse only containsemailwhen the user has a public email address; users with private emails arrive with no email at all (the review-profile page then shows an empty email field and no auto-linking by email can occur). - Google and Microsoft presets set
trustEmail=truebecause both assert verified addresses. Do not copy that flag blindly to a generic OIDC provider —trustEmaillets any external account whose (verified-claimed) email matches a local user take that account over without a password prompt. - The generic
oidcpreset only setsdefaultScope="openid profile email"; supplyissuer(preferred) or explicit endpoint URLs plus credentials.
First broker login
Section titled “First broker login”When no link exists for the external identity, decide_first_broker_login (in crates/issuerd-core/src/broker.rs) picks one of four paths from three inputs: whether the identity’s email already belongs to a local user, the provider’s trustEmail flag, and the IdP’s email_verified claim.
| Situation | Decision | What happens |
|---|---|---|
Email conflict, trustEmail=true, IdP asserted email_verified |
Auto-link | The external identity is silently linked to the existing local user and the login completes. |
| Email conflict, otherwise | Link-or-create page | “Link your account”: the user proves ownership of the existing local account by entering its password (linking then completes the login), or chooses “Create a separate account instead”, which switches to the review-profile page. |
No conflict, trustEmail=true |
Auto-create | A local user is created on the fly and the login completes with no page. |
| No conflict, otherwise | Review-profile page | “Review your profile”: username, email, first and last name pre-filled from the external identity, all editable. Submission validates the username (valid and unique) and the email (valid; duplicates rejected unless the realm allows duplicate emails). |
The pages are served at GET|POST /realms/{realm}/broker/first-login/{execution} with a 10-minute TTL; a flow cookie guards the POST against CSRF, and failed submissions re-render with an error banner (post/redirect/get).
Account creation details (applies to auto-create, review-create, and the separate-account path):
- The username suggestion is
preferred_username→ the email local part →{alias}.{subject}; taken names fall back to{alias}.{subject}and then a random suffix. email_verifiedis kept only when the IdP asserted it and the address survived the review form unedited. If the realm has verify-email enabled and the address is unverified, the new user gets theVERIFY_EMAILrequired action.- Created users carry
federation_link = "idp:{alias}", receive the realm’s default role and default groups, and have no local password — their sign-in method is the IdP (they can set a password later in the account console). - Mapper rules apply at creation/link time — see the next section.
- A new user whose link row cannot be created (race: the external account got linked elsewhere meanwhile) is rolled back, so a retry starts cleanly.
Subsequent logins through an established link skip all of this and complete immediately.
Account linking
Section titled “Account linking”Users manage their own linked identities in the account console at /realms/{realm}/account (Linked accounts page), backed by these account-API endpoints (bearer token of the signed-in user):
GET /realms/{realm}/account/api/linked-accounts— list links (alias,provider_id,display_name,external_username,created_at).POST /realms/{realm}/account/api/linked-accounts/{alias}— start the linking ceremony. Returns{"redirect_url": "/realms/{realm}/broker/{alias}/login?link={token}"}; the SPA navigates there. Answers409when the alias is already linked to this account (re-linking is delete-then-link).DELETE /realms/{realm}/account/api/linked-accounts/{alias}— unlink. Idempotent (204whether or not the link existed), but refuses with400when this is the account’s only link and the account has no password credential — unlinking would strand the account with no sign-in method.
The ceremony reuses the broker login route in link mode: ?link= carries a signed action token (purpose broker-link, 5-minute TTL, intentionally not single-use so a browser restart mid-ceremony does not strand the user). The round-trip to the IdP is identical to a brokered login, but at the callback Issuerd links the external identity to the currently signed-in user instead of issuing a session: the realm’s SSO cookie (issuerd_session_{realm-id}) must be present and its subject must match the token’s subject. This blocks login-CSRF link injection (steering a victim’s external identity onto an attacker’s account). Outcomes redirect back to the account console:
- success →
/realms/{realm}/account/linked-accounts?linked={alias}(linking the same external account to the same user again is idempotent), - session cookie missing/mismatched →
?error=link-session-mismatch, - external account already linked to a different local user →
?error=already-linked, - anything else →
?error=link-failed.
Uniqueness invariants enforced by storage: one external (alias, subject) maps to at most one local user, and a local user has at most one link per provider alias.
Identity provider mappers
Section titled “Identity provider mappers”Mappers translate external claims into local user data. They live in the IdP’s mappers config key as a JSON array of objects {"name", "mapper_type", "config"} and are managed via the mapper sub-resources of the Admin API (or the admin console’s mapper tab). The mapper-type list is exposed at GET /admin/enums/idp-mapper-types. A malformed mappers value is treated as an empty list — a broken mapper set never locks users out.
mapper_type |
Config keys | Effect |
|---|---|---|
attribute |
claim, attribute |
Copy the claim’s values into the user attribute (multi-valued; strings, and string/number/bool array members). When the claim is absent, the attribute is removed. |
role |
claim, claim_value, role |
Grant the named realm role when any value of the claim equals claim_value. The role must already exist — mappers never create roles; unknown names are skipped with a warning in the log. |
username_template |
template |
Render a username from ${ALIAS} and ${CLAIM.<name>} placeholders. Accepted by the API, but the rendered value is currently not consumed by the login paths — auto-created usernames always follow the suggestion logic described in First broker login. |
Example (as stored in the mappers config key):
[ {"name": "org", "mapper_type": "attribute", "config": {"claim": "org", "attribute": "organization"}}, {"name": "admins", "mapper_type": "role", "config": {"claim": "groups", "claim_value": "admins", "role": "realm-admin"}}]When mappers run is governed by syncMode:
import(default) — mappers run when a user is created and when a new link is established on an existing account (auto-link, link-via-password). Later logins leave the local record alone, so admin edits and user profile changes survive.force— mappers additionally re-run on every brokered login, overwriting mapped attributes with the current claim values and re-granting mapped roles (removing the role out of band does not stick). Unmapped attributes and roles are never touched.
Sending users straight to a provider
Section titled “Sending users straight to a provider”Applications can skip the Issuerd login page entirely by adding the Keycloak-compatible kc_idp_hint parameter to the authorization request:
GET /realms/{realm}/protocol/openid-connect/auth?response_type=code&client_id=...&redirect_uri=...&kc_idp_hint={alias-or-provider-id}Behavior (implemented by the auth-idp-redirect stage of the built-in browser flow):
- The hint matches an enabled provider by alias or by
provider_id—kc_idp_hint=googlefinds the provider whose id isgoogleeven if its alias isgmail. - A matching provider challenges the flow with a redirect straight into
/realms/{realm}/broker/{alias}/login?flow={id}— the user never sees the login form. - An unknown or disabled hint is ignored: the flow falls through to the ordinary login page (no error).
- The redirect stage runs after the cookie/SPNEGO stages, so an existing SSO session still wins over the hint (Keycloak’s ordering).
- The hint is evaluated when the authorization request starts the flow; it is not persisted into a resumed flow.
Logout semantics
Section titled “Logout semantics”Brokered sessions are ordinary Issuerd sessions. RP-initiated logout (/realms/{realm}/protocol/openid-connect/logout), account-console sign-out, and admin session deletion all tear the session down and notify clients per their back-/front-channel logout configuration (see client-integration.md). What deliberately does not happen:
- No upstream IdP logout. The external IdP’s session is left untouched — Issuerd parses the discovery
end_session_endpointbut never calls it. After logging out of your application, the user is typically still signed in at the external IdP, so the next brokered login may complete silently without a credential prompt. Users who want a full sign-out must also log out at the IdP itself. - No upstream token revocation. A stored external refresh token (
storeTokens) is not revoked when the session ends or the link is deleted. - Unlinking does not invalidate active sessions; it only affects the next brokered login.
Troubleshooting
Section titled “Troubleshooting”For general diagnostics (logs, events, metrics), see troubleshooting.md and monitoring.md. Broker-specific symptoms:
- The provider rejects the redirect or returns an error immediately after login. Almost always a redirect-URI mismatch: the URI registered at the IdP must be exactly
{issuer_url}/realms/{realm-name}/broker/{alias}/endpoint(realm name; any trailing slash onissuer_urlis trimmed). Behind a reverse proxy,issuer_urlmust be the external base URL, not the internal listener — see deployment.md. IdP-side errors (access_denied, user cancelled) land the user back on the login page witherror=identity_provider_error, as do code-exchange and token-validation failures; the log lineidentity provider ... failed/brokered code exchange failed(WARN, with realm and alias) carries the cause — unreachable host, token endpoint rejection (invalid_grant, e.g. wrong secret), or id_token validation failure. - “The sign-in session is invalid or has expired — please start again” at the callback: the broker
statewas unknown — the 10-minute round-trip TTL elapsed, the state was already consumed (single-use; browser back button or a double-submitted callback), or the cache lost the entry. Start the login again. - “this identity provider could not be reached” / “not configured correctly” on the broker login kickoff: the IdP config failed validation or discovery. Run
POST /admin/realms/{realm}/identity-provider/instances/{alias}/test-connectionfor a precise problem list. - Missing email or profile on the created user. Code-flow id_tokens may legally carry only the subject (OIDC Core §5.4); Issuerd then fetches userinfo when an endpoint is configured/discovered. If the IdP has no userinfo endpoint, or the claims are absent there too, the identity has no email — the review page shows an empty email field and email-based auto-linking cannot happen. GitHub-specific:
/useronly returnsemailwhen the user’s address is public. - Logins failing after the provider rotated signing keys. The JWKS is cached for one hour; an unknown
kidalready triggers one immediate refetch, so a single retry usually succeeds. Endpoint changes at the IdP (new authorization/token URLs) wait on the one-hour discovery cache — restart the node or flush thebroker_meta:*cache keys to pick them up sooner. - Clock skew. External id_token
exp/nbfare validated with 60 seconds of leeway; beyond that, validation fails and the user seesidentity_provider_error. Keep NTP healthy on both Issuerd and the IdP. - Accounts linked to the wrong user / unexpected auto-linking. That is
trustEmaildoing exactly what it says: it auto-links any external identity whose (IdP-asserted) verified email matches a local account, with no password prompt. Only enable it for providers that truly verify addresses; otherwise users get the link-via-password page instead. - Provider button missing from the login page. The provider is disabled, its
provider_idis a federation type (ldap/kerberos/saml), or the login page was opened outside an authorization flow (buttons render only mid-flow). CheckGET /realms/{realm}/login/context— it lists exactly what the page will show.