# OAuth and federated identity

> 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.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authn/oauth/ -->

`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:

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

with HTTP `404`.

:::caution
There is no configuration path. No config key, no environment variable, and no YAML file
registers an OAuth provider. A provider is registered only as a construction-time option
inside the service process, and the shipping build does not pass one — it is exercised only by
the flow's integration test. You cannot turn on a social or enterprise login on `iam.svc`
today.
:::

## 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

Both routes are mounted at the router root — there is no `/v2` prefix. Both are public and
both require the four CEPT 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 CEPT headers still returns `404 unknown_provider` rather than
`400 no_tenant`.

See the [IAM API reference](/blocks/baas/iam/api/) for the endpoint-level detail.

## 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

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

```yaml
# 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`.

## Start: state and the PKCE verifier

`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:

| 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

`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:

```json
{
  "token": "v4.public.…",
  "refresh_token": "01K….<secret>",
  "expires_in": 900,
  "user": {
    "id": "01K…",
    "email": "oauth-test@example.com",
    "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](/blocks/baas/iam/tokens/sessions/) 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

The OAuth callback hard-codes the default realm `users` and queries the base `users` entity by
email. It never calls the [realm provider gate](/blocks/baas/iam/authn/realms/).

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.

## MFA

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

```json
{ "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](/blocks/baas/iam/authn/mfa/).

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

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.

:::note
A just-in-time user has no usable password, so `POST /auth/login` for that identity returns
`401 invalid_credentials` — the same response as a wrong password, on the same argon2 time
budget. Password reset is the path to give such a user a password.
:::

## 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](/blocks/baas/iam/tokens/sessions/), not by an IdP-initiated logout.

## 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 | CEPT 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_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](/blocks/baas/iam/error/).

## 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_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 payload** — `user_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.

## Continue with

- [Realms](/blocks/baas/iam/authn/realms/) — the provider gate this flow does not consult
- [Magic link, WebAuthn and passkeys](/blocks/baas/iam/authn/passwordless/) — the other flow implemented but not enabled
- [Sessions](/blocks/baas/iam/tokens/sessions/) — the managed fork the callback shares
- [Tokens](/blocks/baas/iam/tokens/) — what the callback mints
- [Identity entities](/blocks/baas/iam/identity/entities/) — the `users` entity a federated login lands in
- [IAM API reference](/blocks/baas/iam/api/) — endpoint-level request and response shapes