{
  "docId": "baas.iam.authn.oauth",
  "title": "OAuth and federated identity",
  "summary": "The OAuth 2.0 authorization-code flow in iam.svc — routes, state and PKCE handling, the callback contract, just-in-time provisioning — and why every route answers 404 in the shipping binary.",
  "url": "https://docs.kis.ai/blocks/baas/iam/authn/oauth/",
  "markdown": "https://docs.kis.ai/blocks/baas/iam/authn/oauth.md",
  "product": "iam",
  "area": "guide",
  "documentType": "guide",
  "status": "active",
  "authority": "canonical",
  "visibility": "public",
  "documentationVersion": "2026.07",
  "productVersion": "2.0.0",
  "apiContractVersion": null,
  "httpPathVersioning": "none",
  "intent": [],
  "audience": [],
  "lastModified": "2026-07-29",
  "lastReviewed": null,
  "owners": [],
  "appliesTo": [
    ">=2.0.0 <2.1.0"
  ],
  "prerequisites": [],
  "related": [],
  "supersedes": [],
  "supersededBy": null,
  "ai": {
    "discoverable": true,
    "retrievable": true,
    "authoritative": true,
    "chunking": "heading",
    "answerableQuestions": []
  },
  "headings": [
    {
      "depth": 2,
      "text": "What exists and what does not",
      "id": "what-exists-and-what-does-not"
    },
    {
      "depth": 2,
      "text": "Routes",
      "id": "routes"
    },
    {
      "depth": 2,
      "text": "What a provider record requires",
      "id": "what-a-provider-record-requires"
    },
    {
      "depth": 2,
      "text": "providers.yaml declares a name, not a provider",
      "id": "providersyaml-declares-a-name-not-a-provider"
    },
    {
      "depth": 2,
      "text": "Start: state and the PKCE verifier",
      "id": "start-state-and-the-pkce-verifier"
    },
    {
      "depth": 2,
      "text": "The callback contract",
      "id": "the-callback-contract"
    },
    {
      "depth": 2,
      "text": "Realms are not consulted",
      "id": "realms-are-not-consulted"
    },
    {
      "depth": 2,
      "text": "MFA",
      "id": "mfa"
    },
    {
      "depth": 2,
      "text": "Account linking and just-in-time provisioning",
      "id": "account-linking-and-just-in-time-provisioning"
    },
    {
      "depth": 2,
      "text": "Back-channel logout",
      "id": "back-channel-logout"
    },
    {
      "depth": 2,
      "text": "Errors",
      "id": "errors"
    },
    {
      "depth": 2,
      "text": "Events",
      "id": "events"
    },
    {
      "depth": 2,
      "text": "Continue with",
      "id": "continue-with"
    }
  ],
  "wordCount": 2595,
  "body": "`iam.svc` implements the OAuth 2.0 authorization-code flow with PKCE (S256), single-use\nTTL-bounded state, and just-in-time user provisioning. **The deployable binary registers no\nOAuth provider.** Both routes look the provider name up in an empty map, so every request to\nevery provider name — on every tenant — answers:\n\n```json\n{ \"error\": \"OAuth provider not configured\", \"code\": \"unknown_provider\" }\n```\n\nwith HTTP `404`.\n\n:::caution\nThere is no configuration path. No config key, no environment variable, and no YAML file\nregisters an OAuth provider. A provider is registered only as a construction-time option\ninside the service process, and the shipping build does not pass one — it is exercised only by\nthe flow's integration test. You cannot turn on a social or enterprise login on `iam.svc`\ntoday.\n:::\n\n## What exists and what does not\n\n| Capability | State |\n|---|---|\n| Authorization-code flow, start + callback | Implemented, integration-tested |\n| PKCE S256 (verifier persisted server-side, redeemed at exchange) | Implemented |\n| Single-use state, 10-minute TTL, fail-closed expiry | Implemented |\n| Just-in-time user provisioning by email | Implemented |\n| MFA gate on the callback (TOTP) | Implemented |\n| Registering a provider from configuration | Does not exist |\n| `id_token` / OIDC validation (`iss`, `aud`, `nonce`) | Does not exist |\n| OIDC discovery (`/.well-known/openid-configuration`) | Does not exist |\n| Account linking across providers | Does not exist |\n| Back-channel logout correlation | Does not exist |\n| SAML, LDAP | No handler exists anywhere in the block |\n\n## Routes\n\nBoth routes are mounted at the router root — there is no `/v2` prefix. Both are public and\nboth require the four CEPT headers (`X-Customer`, `X-Product`, `X-Env`, `X-Tenant`); the state\nrow and the provisioned user live in the request tenant's datastore. **Neither route is\nrate-limited** — unlike password login, magic link and MFA verify, no limiter profile wraps\nthem, so the callback's outbound token exchange and its user-creating path are unthrottled.\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/auth/oauth/:provider/start` | Persist state + PKCE verifier, `302` to the provider's authorization endpoint |\n| `GET` | `/auth/oauth/:provider/callback` | Validate state, redeem the code, provision, mint |\n\n`:provider` is lowercased before lookup. The provider-name check runs **before** the tenant\ncheck, so a request with no CEPT headers still returns `404 unknown_provider` rather than\n`400 no_tenant`.\n\nSee the [IAM API reference](/blocks/baas/iam/api/) for the endpoint-level detail.\n\n## What a provider record requires\n\nThe flow needs the following per provider. None of these fields is readable from\n`providers.yaml`, the tenant config, or any other operator surface.\n\n| Field | Meaning | Default |\n|---|---|---|\n| Name | The `:provider` path segment, lowercased | required |\n| Client ID | Credential issued by the IdP | required |\n| Client secret | Credential issued by the IdP | required |\n| Authorization URL | Where the user is redirected | required |\n| Token URL | Where the code is redeemed | required |\n| User-info URL | Fetched with the provider's access token | required |\n| Scopes | Scope list sent on the authorization request | none |\n| Redirect URL | Absolute callback URL, must match the IdP registration | required |\n| Email claim | Key in the user-info JSON holding the email | `email` |\n| Name claim | Key in the user-info JSON holding the display name | `name` |\n\nMultiple providers are supported by registering more than one record; the map is keyed by the\nlowercased name. The redirect URL is taken from the record verbatim — it is never derived from\nthe incoming request's host.\n\n## `providers.yaml` declares a name, not a provider\n\nA provider declaration in a product's `iam/providers.yaml` has exactly three fields, and none\nof them carries a credential:\n\n```yaml\n# iam/providers.yaml\nproviders:\n  - name: password\n    type: challenge\n  - name: o365\n    type: oauth\n  - name: magiclink\n    type: magic\n    template: magictemplate\n```\n\n`type` and `template` are parsed and stored, and then read by nothing. No auth handler\nconsults either at request time. Declaring `type: oauth` creates a **name** that a realm's\n`providers:` list can reference; it configures no IdP, registers no route target, and changes\nno response. `/auth/oauth/o365/start` on a tenant whose product ships the file above still\nreturns `404 unknown_provider`.\n\n## Start: state and the PKCE verifier\n\n`GET /auth/oauth/:provider/start` does the following, in order:\n\n1. Resolve the provider by lowercased name — `404 unknown_provider` if absent.\n2. Require a tenant on the request — `400 no_tenant` if absent.\n3. Emit `before_oauth_start` with `provider`, `redirect_url` and `client_ip`. A hook that\n   aborts produces `403 hook_denied`.\n4. Resolve the tenant's datastore engine — `500 engine_unavailable` on failure.\n5. Generate `state` — 32 random bytes, base64url, unpadded.\n6. Generate a PKCE verifier.\n7. Write one `oauthstate` row and `302` to the provider.\n\nThe row bounds the whole flow:\n\n| Column | Value |\n|---|---|\n| `statekey` | the `state` value |\n| `statevalue` | the lowercased provider name |\n| `codeverifier` | the PKCE verifier (marked `nolog`) |\n| `expireson` | now + 10 minutes |\n\nThe redirect carries `state`, `code_challenge` and `code_challenge_method=S256`, plus\n`access_type=online` — no provider refresh token is requested.\n\nThe `oauthstate` entity is `final: true` and its access rules restrict read, create, update and\ndelete to the `iam-service` role, so the state and its verifier are not reachable through\n`/rest`, `/anon/rest` or `/admin`.\n\n## The callback contract\n\n`GET /auth/oauth/:provider/callback` accepts `code` and `state` as query parameters, and\n`error` when the IdP declines. The order of checks is:\n\n| Step | Failure |\n|---|---|\n| Resolve provider by name | `404 unknown_provider` |\n| Require a tenant | `400 no_tenant` |\n| Resolve the datastore engine | `500 engine_unavailable` |\n| `code` and `state` both present — otherwise, if `error` is present | `401 provider_error` (message is the IdP's raw value) |\n| `code` and `state` both present — otherwise | `400 missing_callback` |\n| `oauthstate` row exists for (`statekey`, `statevalue`) | `401 invalid_state` |\n| **Delete the state row** | — |\n| `expireson` is a timestamp in the future | `401 invalid_state` |\n| Redeem the code at the token endpoint with the verifier | `401 exchange_failed` |\n| Fetch and decode user-info | `500 userinfo_failed` |\n| Email claim is non-empty | `500 no_email` |\n| Find or create the user by email | `500 user_provision_failed` |\n| MFA lookup | `500 mfa_lookup_failed` |\n| Load the identity row for the mint | `401 invalid_token` |\n\nTwo properties are worth stating exactly. The state row is **deleted before any network\nround-trip**, so two callbacks presenting the same state cannot race — the second one loses at\nthe lookup. And expiry is **fail-closed**: an absent, null, zero or non-timestamp `expireson`\ncounts as expired, not as unexpired.\n\nThe verifier is applied only when the stored `codeverifier` is non-empty; the exchange also\nsends the client secret.\n\nOn success the callback returns one of three shapes, the same three every credential flow in\nthe block returns:\n\n```json\n{\n  \"token\": \"v4.public.…\",\n  \"refresh_token\": \"01K….<secret>\",\n  \"expires_in\": 900,\n  \"user\": {\n    \"id\": \"01K…\",\n    \"email\": \"oauth-test@example.com\",\n    \"displayname\": \"OAuth Person\"\n  }\n}\n```\n\nIn this stateless shape `email` and `displayname` are echoed from the IdP's claims for this\nrequest — they are not read back from the stored row, so an existing local user whose stored\nname differs sees the IdP's value here.\n\nOn a tenant whose [session preset](/blocks/baas/iam/tokens/sessions/) is managed, the response\nis the managed-session shape instead (`session_id`, `expires_at`, `user`, plus the\n`kis_session` cookie) and the session records AAL 1 with `auth_method` set to\n`idp:<provider>`. That shape is the mirror image on naming: its `user` object is built from the\nstored identity row, not from the IdP claims.\n\nWhen the user has TOTP enrolled the response is the MFA-required shape — see below.\n\n## Realms are not consulted\n\nThe OAuth callback hard-codes the default realm `users` and queries the base `users` entity by\nemail. It never calls the [realm provider gate](/blocks/baas/iam/authn/realms/).\n\nConsequences you must plan for:\n\n- Listing (or omitting) an OAuth provider name under a realm's `providers:` list in\n  `realms.yaml` changes nothing about this flow. The gate is checked on password login only,\n  and only ever against the literal name `password`.\n- A product realm bound to its own entity (`patient`, `vendor`) cannot be reached by OAuth. A\n  federated login always lands in the base `users` table.\n- The minted token's `realm` claim is always `users` for an OAuth login.\n\n## MFA\n\nIf the resolved user has a TOTP enrolment, the callback returns `200` with the MFA-required\nbody instead of tokens:\n\n```json\n{ \"mfa_required\": true, \"mfa_token\": \"v4.public.…\", \"methods\": [\"totp\"] }\n```\n\nThe caller completes the login at `POST /auth/mfa/verify`. `methods` is the hard-coded literal\n`[\"totp\"]`; there is no other second factor. See [multi-factor\nauthentication](/blocks/baas/iam/authn/mfa/).\n\nNothing is asserted to, or required from, the external IdP. The callback does not read an\n`acr` or `amr` claim and does not validate an `id_token`, so the MFA decision is made entirely\nfrom local enrolment state. A provider that performed its own step-up is invisible here, and a\nprovider that performed none is equally invisible.\n\n## Account linking and just-in-time provisioning\n\nThere is no account linking. **The email address is the sole join key.** The callback looks up\n`users` by the email returned at the configured email claim and creates the row if it is\nmissing. There is no federation-identity table, no per-provider subject binding, and no record\non the user of which IdP created or last authenticated it — the provider name reaches the\nprovisioning helper but is never written to the row.\n\nTwo consequences:\n\n- Two different providers returning the same email address resolve to the same local user. A\n  provider that lets a user set an unverified email is therefore a full account takeover of\n  that email's local user.\n- A provider that returns no value at the email claim cannot log anyone in: `500 no_email`.\n\nA just-in-time user is created with sentinel values for the columns the entity requires:\n\n| Column | Value written |\n|---|---|\n| `email` | from the IdP |\n| `mobile` | the literal `+0000000000000` |\n| `firstname` / `lastname` | derived from the display name, see below |\n| `middlename`, `avatar` | empty |\n| `password` | 32 random bytes, base64url — unusable, never shown to anyone |\n| `active` | `true` |\n\nThe name split satisfies the entity's 3-character minimum on both name columns rather than\npreserving what the IdP sent:\n\n| Display name from the IdP | `firstname` | `lastname` |\n|---|---|---|\n| empty | `OAuth` | `User` |\n| one word, 3+ characters (`Prince`) | `Prince` | `Prince` |\n| one word, under 3 characters (`Jo`) | `OAuth` | `Jo` |\n| two or more words | first word | the remaining words joined |\n\nOn the multi-word row either part is replaced — `firstname` with `OAuth`, `lastname` with\n`User` — when it is shorter than three characters.\n\n:::note\nA just-in-time user has no usable password, so `POST /auth/login` for that identity returns\n`401 invalid_credentials` — the same response as a wrong password, on the same argon2 time\nbudget. Password reset is the path to give such a user a password.\n:::\n\n## Back-channel logout\n\nThe managed-session row's IdP issuer and IdP session id are written empty on purpose. OIDC\nback-channel logout correlates on the `id_token`'s `iss` and `sid`, and this flow does not\nvalidate an `id_token`, so neither value is available. A fabricated value would match no\nsession or the wrong one. Federated sessions are revocable through the block's own [session\ncontrol plane](/blocks/baas/iam/tokens/sessions/), not by an IdP-initiated logout.\n\n## Errors\n\nEvery error on this surface is the flat auth envelope — `{\"error\": \"<message>\", \"code\":\n\"<slug>\"}` — with no `details` key.\n\n| Code | HTTP | Route | Cause |\n|---|---|---|---|\n| `unknown_provider` | 404 | both | No provider registered under that name. Always, in the shipping binary |\n| `no_tenant` | 400 | both | CEPT headers missing |\n| `engine_unavailable` | 500 | both | Tenant datastore could not be resolved |\n| `hook_denied` | 403 | start | A `before_oauth_start` hook aborted |\n| `state_failed` | 500 | start | State value could not be generated |\n| `persist_failed` | 500 | start | State row could not be written |\n| `provider_error` | 401 | callback | The IdP redirected back with an `error` param |\n| `missing_callback` | 400 | callback | `code` or `state` absent, with no `error` param |\n| `invalid_state` | 401 | callback | State unknown, bound to another provider, already used, or expired |\n| `exchange_failed` | 401 | callback | Code-to-token exchange failed |\n| `userinfo_failed` | 500 | callback | User-info could not be fetched or decoded |\n| `no_email` | 500 | callback | Email claim empty |\n| `user_provision_failed` | 500 | callback | Just-in-time create failed, commonly on a validation |\n| `mfa_lookup_failed` | 500 | callback | MFA enrolment could not be read |\n| `sign_failed` | 500 | callback | Access or MFA token could not be signed |\n| `issue_failed` | 500 | callback | Refresh token could not be issued |\n| `invalid_token` | 401 | callback | Identity row disappeared between provisioning and mint |\n\nOn a managed-session tenant the callback also reaches the shared session fork, so these four\nare reachable from this route as well:\n\n| Code | HTTP | Cause |\n|---|---|---|\n| `policy_unavailable` | 503 | The tenant's session policy could not be resolved; the service refuses rather than guessing stateless |\n| `session_limit` | 409 | `max_concurrent_sessions` reached under `on_limit: reject` |\n| `session_failed` | 500 | The session row could not be created |\n\n`exchange_failed` is also emitted at `500` by the managed-session exchange, which is a\ndifferent surface. Key on the (status, code) pair, not the code alone. The full catalogue is\non the [error page](/blocks/baas/iam/error/).\n\n## Events\n\n| Event | Kind | Payload |\n|---|---|---|\n| `before_oauth_start` | Abortable — `403 hook_denied` | `provider`, `redirect_url`, `client_ip` |\n| `before_signup` / `after_signup` | Observer, new user only | `email`, `source: oauth:<provider>`; `after_signup` adds `user_id` |\n| `after_oauth_callback` | Observer | `provider`, `user_id`, `email`, `is_new_user` |\n| `after_login_success` | Observer | `user_id`, `mfa_required: false`, `flow: oauth:<provider>` |\n\n`before_oauth_start` is the only gate on this flow. Three things follow from where the other\nemits sit, and each is easy to get wrong when building on the event bus:\n\n- **`before_signup` cannot abort just-in-time provisioning.** The callback discards its return\n  value, so a hook that denies still leaves the user created. It is an observer here despite\n  the `before_` name.\n- **`after_oauth_callback` does not fire on every successful login.** It is emitted after the\n  stateless mint only. A login that returns the MFA-required shape, and a login on a\n  managed-session tenant, both return earlier — neither emits it. Do not use it to count\n  federated logins.\n- **`after_login_success` fires on managed tenants with a different payload** — `user_id`,\n  `managed_session: true` and `auth_method`, with no `flow` key. A consumer keying on `flow`\n  silently sees nothing on those tenants.\n\nA failed callback emits nothing at all; there is no `after_login_failure` on this path.\n\n## Continue with\n\n- [Realms](/blocks/baas/iam/authn/realms/) — the provider gate this flow does not consult\n- [Magic link, WebAuthn and passkeys](/blocks/baas/iam/authn/passwordless/) — the other flow implemented but not enabled\n- [Sessions](/blocks/baas/iam/tokens/sessions/) — the managed fork the callback shares\n- [Tokens](/blocks/baas/iam/tokens/) — what the callback mints\n- [Identity entities](/blocks/baas/iam/identity/entities/) — the `users` entity a federated login lands in\n- [IAM API reference](/blocks/baas/iam/api/) — endpoint-level request and response shapes"
}