Skip to content
Talk to our solutions team

OAuth and federated identity

iam.svc implements the OAuth 2.0 authorization-code flow with PKCE (S256), single-use TTL-bounded state, and just-in-time user provisioning. The deployable binary registers no OAuth provider. Both routes look the provider name up in an empty map, so every request to every provider name — on every tenant — answers:

{ "error": "OAuth provider not configured", "code": "unknown_provider" }

with HTTP 404.

CapabilityState
Authorization-code flow, start + callbackImplemented, integration-tested
PKCE S256 (verifier persisted server-side, redeemed at exchange)Implemented
Single-use state, 10-minute TTL, fail-closed expiryImplemented
Just-in-time user provisioning by emailImplemented
MFA gate on the callback (TOTP)Implemented
Registering a provider from configurationDoes not exist
id_token / OIDC validation (iss, aud, nonce)Does not exist
OIDC discovery (/.well-known/openid-configuration)Does not exist
Account linking across providersDoes not exist
Back-channel logout correlationDoes not exist
SAML, LDAPNo handler exists anywhere in the block

Both routes are mounted at the router root — there is no /v2 prefix. Both are public and both require the four CPET headers (X-Customer, X-Product, X-Env, X-Tenant); the state row and the provisioned user live in the request tenant’s datastore. Neither route is rate-limited — unlike password login, magic link and MFA verify, no limiter profile wraps them, so the callback’s outbound token exchange and its user-creating path are unthrottled.

MethodPathPurpose
GET/auth/oauth/:provider/startPersist state + PKCE verifier, 302 to the provider’s authorization endpoint
GET/auth/oauth/:provider/callbackValidate state, redeem the code, provision, mint

:provider is lowercased before lookup. The provider-name check runs before the tenant check, so a request with no CPET headers still returns 404 unknown_provider rather than 400 no_tenant.

See the IAM API reference for the endpoint-level detail.

The flow needs the following per provider. None of these fields is readable from providers.yaml, the tenant config, or any other operator surface.

FieldMeaningDefault
NameThe :provider path segment, lowercasedrequired
Client IDCredential issued by the IdPrequired
Client secretCredential issued by the IdPrequired
Authorization URLWhere the user is redirectedrequired
Token URLWhere the code is redeemedrequired
User-info URLFetched with the provider’s access tokenrequired
ScopesScope list sent on the authorization requestnone
Redirect URLAbsolute callback URL, must match the IdP registrationrequired
Email claimKey in the user-info JSON holding the emailemail
Name claimKey in the user-info JSON holding the display namename

Multiple providers are supported by registering more than one record; the map is keyed by the lowercased name. The redirect URL is taken from the record verbatim — it is never derived from the incoming request’s host.

providers.yaml declares a name, not a provider

Section titled “providers.yaml declares a name, not a provider”

A provider declaration in a product’s iam/providers.yaml has exactly three fields, and none of them carries a credential:

iam/providers.yaml
providers:
- name: password
type: challenge
- name: o365
type: oauth
- name: magiclink
type: magic
template: magictemplate

type and template are parsed and stored, and then read by nothing. No auth handler consults either at request time. Declaring type: oauth creates a name that a realm’s providers: list can reference; it configures no IdP, registers no route target, and changes no response. /auth/oauth/o365/start on a tenant whose product ships the file above still returns 404 unknown_provider.

GET /auth/oauth/:provider/start does the following, in order:

  1. Resolve the provider by lowercased name — 404 unknown_provider if absent.
  2. Require a tenant on the request — 400 no_tenant if absent.
  3. Emit before_oauth_start with provider, redirect_url and client_ip. A hook that aborts produces 403 hook_denied.
  4. Resolve the tenant’s datastore engine — 500 engine_unavailable on failure.
  5. Generate state — 32 random bytes, base64url, unpadded.
  6. Generate a PKCE verifier.
  7. Write one oauthstate row and 302 to the provider.

The row bounds the whole flow:

ColumnValue
statekeythe state value
statevaluethe lowercased provider name
codeverifierthe PKCE verifier (marked nolog)
expiresonnow + 10 minutes

The redirect carries state, code_challenge and code_challenge_method=S256, plus access_type=online — no provider refresh token is requested.

The oauthstate entity is final: true and its access rules restrict read, create, update and delete to the iam-service role, so the state and its verifier are not reachable through /rest, /anon/rest or /admin.

GET /auth/oauth/:provider/callback accepts code and state as query parameters, and error when the IdP declines. The order of checks is:

StepFailure
Resolve provider by name404 unknown_provider
Require a tenant400 no_tenant
Resolve the datastore engine500 engine_unavailable
code and state both present — otherwise, if error is present401 provider_error (message is the IdP’s raw value)
code and state both present — otherwise400 missing_callback
oauthstate row exists for (statekey, statevalue)401 invalid_state
Delete the state row
expireson is a timestamp in the future401 invalid_state
Redeem the code at the token endpoint with the verifier401 exchange_failed
Fetch and decode user-info500 userinfo_failed
Email claim is non-empty500 no_email
Find or create the user by email500 user_provision_failed
MFA lookup500 mfa_lookup_failed
Load the identity row for the mint401 invalid_token

Two properties are worth stating exactly. The state row is deleted before any network round-trip, so two callbacks presenting the same state cannot race — the second one loses at the lookup. And expiry is fail-closed: an absent, null, zero or non-timestamp expireson counts as expired, not as unexpired.

The verifier is applied only when the stored codeverifier is non-empty; the exchange also sends the client secret.

On success the callback returns one of three shapes, the same three every credential flow in the block returns:

{
"token": "v4.public.…",
"refresh_token": "01K….<secret>",
"expires_in": 900,
"user": {
"id": "01K…",
"email": "[email protected]",
"displayname": "OAuth Person"
}
}

In this stateless shape email and displayname are echoed from the IdP’s claims for this request — they are not read back from the stored row, so an existing local user whose stored name differs sees the IdP’s value here.

On a tenant whose session preset is managed, the response is the managed-session shape instead (session_id, expires_at, user, plus the kis_session cookie) and the session records AAL 1 with auth_method set to idp:<provider>. That shape is the mirror image on naming: its user object is built from the stored identity row, not from the IdP claims.

When the user has TOTP enrolled the response is the MFA-required shape — see below.

The OAuth callback hard-codes the default realm users and queries the base users entity by email. It never calls the realm provider gate.

Consequences you must plan for:

  • Listing (or omitting) an OAuth provider name under a realm’s providers: list in realms.yaml changes nothing about this flow. The gate is checked on password login only, and only ever against the literal name password.
  • A product realm bound to its own entity (patient, vendor) cannot be reached by OAuth. A federated login always lands in the base users table.
  • The minted token’s realm claim is always users for an OAuth login.

If the resolved user has a TOTP enrolment, the callback returns 200 with the MFA-required body instead of tokens:

{ "mfa_required": true, "mfa_token": "v4.public.…", "methods": ["totp"] }

The caller completes the login at POST /auth/mfa/verify. methods is the hard-coded literal ["totp"]; there is no other second factor. See multi-factor authentication.

Nothing is asserted to, or required from, the external IdP. The callback does not read an acr or amr claim and does not validate an id_token, so the MFA decision is made entirely from local enrolment state. A provider that performed its own step-up is invisible here, and a provider that performed none is equally invisible.

Account linking and just-in-time provisioning

Section titled “Account linking and just-in-time provisioning”

There is no account linking. The email address is the sole join key. The callback looks up users by the email returned at the configured email claim and creates the row if it is missing. There is no federation-identity table, no per-provider subject binding, and no record on the user of which IdP created or last authenticated it — the provider name reaches the provisioning helper but is never written to the row.

Two consequences:

  • Two different providers returning the same email address resolve to the same local user. A provider that lets a user set an unverified email is therefore a full account takeover of that email’s local user.
  • A provider that returns no value at the email claim cannot log anyone in: 500 no_email.

A just-in-time user is created with sentinel values for the columns the entity requires:

ColumnValue written
emailfrom the IdP
mobilethe literal +0000000000000
firstname / lastnamederived from the display name, see below
middlename, avatarempty
password32 random bytes, base64url — unusable, never shown to anyone
activetrue

The name split satisfies the entity’s 3-character minimum on both name columns rather than preserving what the IdP sent:

Display name from the IdPfirstnamelastname
emptyOAuthUser
one word, 3+ characters (Prince)PrincePrince
one word, under 3 characters (Jo)OAuthJo
two or more wordsfirst wordthe remaining words joined

On the multi-word row either part is replaced — firstname with OAuth, lastname with User — when it is shorter than three characters.

The managed-session row’s IdP issuer and IdP session id are written empty on purpose. OIDC back-channel logout correlates on the id_token’s iss and sid, and this flow does not validate an id_token, so neither value is available. A fabricated value would match no session or the wrong one. Federated sessions are revocable through the block’s own session control plane, not by an IdP-initiated logout.

Every error on this surface is the flat auth envelope — {"error": "<message>", "code": "<slug>"} — with no details key.

CodeHTTPRouteCause
unknown_provider404bothNo provider registered under that name. Always, in the shipping binary
no_tenant400bothCPET headers missing
engine_unavailable500bothTenant datastore could not be resolved
hook_denied403startA before_oauth_start hook aborted
state_failed500startState value could not be generated
persist_failed500startState row could not be written
provider_error401callbackThe IdP redirected back with an error param
missing_callback400callbackcode or state absent, with no error param
invalid_state401callbackState unknown, bound to another provider, already used, or expired
exchange_failed401callbackCode-to-token exchange failed
userinfo_failed500callbackUser-info could not be fetched or decoded
no_email500callbackEmail claim empty
user_provision_failed500callbackJust-in-time create failed, commonly on a validation
mfa_lookup_failed500callbackMFA enrolment could not be read
sign_failed500callbackAccess or MFA token could not be signed
issue_failed500callbackRefresh token could not be issued
invalid_token401callbackIdentity row disappeared between provisioning and mint

On a managed-session tenant the callback also reaches the shared session fork, so these four are reachable from this route as well:

CodeHTTPCause
policy_unavailable503The tenant’s session policy could not be resolved; the service refuses rather than guessing stateless
session_service_unavailable500The tenant is configured for managed sessions and the session service is not wired
session_limit409max_concurrent_sessions reached under on_limit: reject
session_failed500The session row could not be created

exchange_failed is also emitted at 500 by the managed-session exchange, which is a different surface. Key on the (status, code) pair, not the code alone. The full catalogue is on the error page.

EventKindPayload
before_oauth_startAbortable — 403 hook_deniedprovider, redirect_url, client_ip
before_signup / after_signupObserver, new user onlyemail, source: oauth:<provider>; after_signup adds user_id
after_oauth_callbackObserverprovider, user_id, email, is_new_user
after_login_successObserveruser_id, mfa_required: false, flow: oauth:<provider>

before_oauth_start is the only gate on this flow. Three things follow from where the other emits sit, and each is easy to get wrong when building on the event bus:

  • before_signup cannot abort just-in-time provisioning. The callback discards its return value, so a hook that denies still leaves the user created. It is an observer here despite the before_ name.
  • after_oauth_callback does not fire on every successful login. It is emitted after the stateless mint only. A login that returns the MFA-required shape, and a login on a managed-session tenant, both return earlier — neither emits it. Do not use it to count federated logins.
  • after_login_success fires on managed tenants with a different payloaduser_id, managed_session: true and auth_method, with no flow key. A consumer keying on flow silently sees nothing on those tenants.

A failed callback emits nothing at all; there is no after_login_failure on this path.