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.
What exists and what does not
Section titled “What exists and what does not”| Capability | State |
|---|---|
| Authorization-code flow, start + callback | Implemented, integration-tested |
| PKCE S256 (verifier persisted server-side, redeemed at exchange) | Implemented |
| Single-use state, 10-minute TTL, fail-closed expiry | Implemented |
| Just-in-time user provisioning by email | Implemented |
| MFA gate on the callback (TOTP) | Implemented |
| Registering a provider from configuration | Does not exist |
id_token / OIDC validation (iss, aud, nonce) | Does not exist |
OIDC discovery (/.well-known/openid-configuration) | Does not exist |
| Account linking across providers | Does not exist |
| Back-channel logout correlation | Does not exist |
| SAML, LDAP | No handler exists anywhere in the block |
Routes
Section titled “Routes”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.
| Method | Path | Purpose |
|---|---|---|
GET | /auth/oauth/:provider/start | Persist state + PKCE verifier, 302 to the provider’s authorization endpoint |
GET | /auth/oauth/:provider/callback | Validate 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.
What a provider record requires
Section titled “What a provider record requires”The flow needs the following per provider. None of these fields is readable from
providers.yaml, the tenant config, or any other operator surface.
| Field | Meaning | Default |
|---|---|---|
| Name | The :provider path segment, lowercased | required |
| Client ID | Credential issued by the IdP | required |
| Client secret | Credential issued by the IdP | required |
| Authorization URL | Where the user is redirected | required |
| Token URL | Where the code is redeemed | required |
| User-info URL | Fetched with the provider’s access token | required |
| Scopes | Scope list sent on the authorization request | none |
| Redirect URL | Absolute callback URL, must match the IdP registration | required |
| Email claim | Key in the user-info JSON holding the email | email |
| Name claim | Key in the user-info JSON holding the display name | name |
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:
providers: - name: password type: challenge - name: o365 type: oauth - name: magiclink type: magic template: magictemplatetype 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.
Start: state and the PKCE verifier
Section titled “Start: state and the PKCE verifier”GET /auth/oauth/:provider/start does the following, in order:
- Resolve the provider by lowercased name —
404 unknown_providerif absent. - Require a tenant on the request —
400 no_tenantif absent. - Emit
before_oauth_startwithprovider,redirect_urlandclient_ip. A hook that aborts produces403 hook_denied. - Resolve the tenant’s datastore engine —
500 engine_unavailableon failure. - Generate
state— 32 random bytes, base64url, unpadded. - Generate a PKCE verifier.
- Write one
oauthstaterow and302to the provider.
The row bounds the whole flow:
| Column | Value |
|---|---|
statekey | the state value |
statevalue | the lowercased provider name |
codeverifier | the PKCE verifier (marked nolog) |
expireson | now + 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.
The callback contract
Section titled “The callback contract”GET /auth/oauth/:provider/callback accepts code and state as query parameters, and
error when the IdP declines. The order of checks is:
| Step | Failure |
|---|---|
| Resolve provider by name | 404 unknown_provider |
| Require a tenant | 400 no_tenant |
| Resolve the datastore engine | 500 engine_unavailable |
code and state both present — otherwise, if error is present | 401 provider_error (message is the IdP’s raw value) |
code and state both present — otherwise | 400 missing_callback |
oauthstate row exists for (statekey, statevalue) | 401 invalid_state |
| Delete the state row | — |
expireson is a timestamp in the future | 401 invalid_state |
| Redeem the code at the token endpoint with the verifier | 401 exchange_failed |
| Fetch and decode user-info | 500 userinfo_failed |
| Email claim is non-empty | 500 no_email |
| Find or create the user by email | 500 user_provision_failed |
| MFA lookup | 500 mfa_lookup_failed |
| Load the identity row for the mint | 401 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…", "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.
Realms are not consulted
Section titled “Realms are not consulted”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 inrealms.yamlchanges nothing about this flow. The gate is checked on password login only, and only ever against the literal namepassword. - A product realm bound to its own entity (
patient,vendor) cannot be reached by OAuth. A federated login always lands in the baseuserstable. - The minted token’s
realmclaim is alwaysusersfor 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:
| Column | Value written |
|---|---|
email | from the IdP |
mobile | the literal +0000000000000 |
firstname / lastname | derived from the display name, see below |
middlename, avatar | empty |
password | 32 random bytes, base64url — unusable, never shown to anyone |
active | true |
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 IdP | firstname | lastname |
|---|---|---|
| empty | OAuth | User |
one word, 3+ characters (Prince) | Prince | Prince |
one word, under 3 characters (Jo) | OAuth | Jo |
| two or more words | first word | the 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.
Back-channel logout
Section titled “Back-channel logout”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.
Errors
Section titled “Errors”Every error on this surface is the flat auth envelope — {"error": "<message>", "code": "<slug>"} — with no details key.
| Code | HTTP | Route | Cause |
|---|---|---|---|
unknown_provider | 404 | both | No provider registered under that name. Always, in the shipping binary |
no_tenant | 400 | both | CPET headers missing |
engine_unavailable | 500 | both | Tenant datastore could not be resolved |
hook_denied | 403 | start | A before_oauth_start hook aborted |
state_failed | 500 | start | State value could not be generated |
persist_failed | 500 | start | State row could not be written |
provider_error | 401 | callback | The IdP redirected back with an error param |
missing_callback | 400 | callback | code or state absent, with no error param |
invalid_state | 401 | callback | State unknown, bound to another provider, already used, or expired |
exchange_failed | 401 | callback | Code-to-token exchange failed |
userinfo_failed | 500 | callback | User-info could not be fetched or decoded |
no_email | 500 | callback | Email claim empty |
user_provision_failed | 500 | callback | Just-in-time create failed, commonly on a validation |
mfa_lookup_failed | 500 | callback | MFA enrolment could not be read |
sign_failed | 500 | callback | Access or MFA token could not be signed |
issue_failed | 500 | callback | Refresh token could not be issued |
invalid_token | 401 | callback | Identity 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:
| Code | HTTP | Cause |
|---|---|---|
policy_unavailable | 503 | The tenant’s session policy could not be resolved; the service refuses rather than guessing stateless |
session_service_unavailable | 500 | The tenant is configured for managed sessions and the session service is not wired |
session_limit | 409 | max_concurrent_sessions reached under on_limit: reject |
session_failed | 500 | The 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.
Events
Section titled “Events”| Event | Kind | Payload |
|---|---|---|
before_oauth_start | Abortable — 403 hook_denied | provider, redirect_url, client_ip |
before_signup / after_signup | Observer, new user only | email, source: oauth:<provider>; after_signup adds user_id |
after_oauth_callback | Observer | provider, user_id, email, is_new_user |
after_login_success | Observer | user_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_signupcannot 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 thebefore_name.after_oauth_callbackdoes 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_successfires on managed tenants with a different payload —user_id,managed_session: trueandauth_method, with noflowkey. A consumer keying onflowsilently sees nothing on those tenants.
A failed callback emits nothing at all; there is no after_login_failure on this path.
Continue with
Section titled “Continue with”- Realms — the provider gate this flow does not consult
- Magic link, WebAuthn and passkeys — the other flow implemented but not enabled
- Sessions — the managed fork the callback shares
- Tokens — what the callback mints
- Identity entities — the
usersentity a federated login lands in - IAM API reference — endpoint-level request and response shapes