> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# IDP-M1 — OIDC exchange

> Server-side authorisation-code exchange with the regional identity provider (IDP-M1)

IDP-M1 provides the relying-party half of the OAuth2 authorisation-code flow. The Global API redeems the authorisation code server-side, verifies the ID token against the identity provider's JWKS, and returns a standard Stralos session — the same shape as `POST /api/global/auth/otp/verify`. No IdP artefact is ever persisted or handed back to a browser.

The OTP API endpoints remain available for compatible clients. The current central login flow uses OIDC and redeems the code on the login host. This regional exchange remains on api-global, but no web client calls it: the shell retired its regional `/auth/callback` page.

## Endpoint

```
POST /api/global/auth/oidc/exchange
```

The endpoint requires no `Authorization` header. It is rate-limited per source IP (`429`).

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `code` | string | Yes | The authorisation code from the identity provider redirect. |
| `codeVerifier` | string | Yes | The PKCE code verifier (43–128 characters, unreserved characters only). |
| `nonce` | string | Yes | The nonce the shell generated for this ceremony. |
| `acceptMembershipId` | UUID | No | The `membershipId` of a pending invitation the user chose to accept at login. |

The `redirectUri` is held server-side. The client never supplies it, so it cannot redirect the redemption.

### Response codes

| Status | Meaning |
| - | - |
| `200` | Authenticated. Returns the same shape as `POST /api/global/auth/otp/verify` — `accessToken`, `refreshToken`, `user`, and `memberships`. |
| `400` | Request body failed validation. |
| `401` | Authentication failed. One uniform body for every redemption or verification failure: an invalid, expired, replayed, PKCE-mismatched, or cross-client code; a bad signature; a wrong or multi-valued audience; a stale token; or a nonce mismatch. The body carries no `code` and is byte-identical across all failure causes. Do not retry the code. |
| `403` | Authenticated, but not authorised to start a session here. See 403 codes below. |
| `409` | An accepted invitation can no longer be accepted (disabled membership, disabled organisation). |
| `429` | Rate limited — per-IP limit exceeded. |
| `503` | OIDC exchange is not configured, or signing keys are not available. |

### 403 codes

The `code` field discriminates the reason:

| Code | Meaning |
| - | - |
| `no_membership_in_region` | The user authenticated successfully but holds no active membership in this region. The response body carries `regions` (the regions the user does belong to) and `pendingInvites` (invitations pending in this region). No web client calls this exchange, so no shell screen renders them. |
| `account_disabled` | The user's account is disabled. This is a terminal outcome — the shell displays a contact-your-administrator message and stops. A `401` is not returned for a disabled account because it would send the user round the authorize loop indefinitely. |
| `invalid_invite` | The supplied `acceptMembershipId` is not this user's, or does not exist. These two cases are deliberately indistinguishable. |

### `pendingInvites` shape

The `pendingInvites` array (present on `no_membership_in_region` 403 responses) uses `membershipId` as its identifier field:

```json theme={null}
{
  "membershipId": "<uuid>",
  "organisationId": "<uuid>",
  "organisationName": "Acme Construction",
  "role": "member"
}
```

Pass `membershipId` as `acceptMembershipId` on the next exchange request to accept the invitation at login.

## IdP subject invariant (`users.idp_subject`)

Migration `0020_idp_m1_users_idp_subject.sql` adds a nullable `idp_subject` column to the `users` table with a partial unique index (only rows where `idp_subject IS NOT NULL` are indexed).

On a user's first successful exchange the API binds the ID token's `sub` claim to `users.idp_subject` with a compare-and-swap (`WHERE idp_subject IS NULL`). On every subsequent exchange, the presented `sub` must match the bound value exactly. A mismatch is a hard authentication failure (uniform `401`) and is logged as an `idp_subject_mismatch` event. There is no rebind path — a pool rebuild or a misconfigured client requires an operator investigation.

The CAS is concurrent-safe: two simultaneous first exchanges cannot both bind. The loser re-reads the row; if the winner bound the same subject, both succeed. If a different subject was bound concurrently the loser returns `401`.

## At-login activation and acceptance

### User activation (`users.status invited → active`)

When a user with `status = 'invited'` completes a successful exchange, the API performs a CAS setting their status to `active`. This is an identity fact that runs before the membership gate: the assertion proves the user's identity, regardless of which organisation they are accessing.

A concurrent disable wins the CAS. If a disable lands before the activation CAS, the user's status is not `active` and the `account_disabled` 403 is returned.

The runtime reports the accepted transition to the admin store through the signed runtime-report channel. The admin store remains the canonical owner and redistributes the authoritative state through its versioned outbox.

The login-host service relays sign-in activations to admin-api via `POST /api/admin/activations/sign-in`. Admin-api performs the full activation in one transaction. See [Admin provisioning — sign-in activation relay](https://help.stralos.app/internal/admin-provisioning.md#sign-in-activation-relay) and [Admin provisioning — activation paths](https://help.stralos.app/internal/admin-provisioning.md#activation-paths).

### Membership acceptance (`invited → active`)

If `acceptMembershipId` is supplied, the API performs an at-login CAS accepting the membership invite. The CAS is scoped to `(membershipId, userId)` so a foreign or unknown ID cannot flip any row. Outcomes:

| Outcome | Behaviour |
| - | - |
| `accepted` | Membership status set `invited → active`; the exchange mints for the accepted organisation and reports the transition to the admin store. |
| `already_active` | Idempotent retry — the membership was already active. The exchange mints for that organisation. |
| `not_found` | `403 invalid_invite` (indistinguishable from a foreign-user ID). |
| `not_acceptable` | `409` — disabled or revoked membership, or a disabled organisation. |

## OIDC configuration and SSM resolution

The exchange is configured by four required variables and up to four optional overrides. A completely absent configuration results in `503`. A partial configuration (some variables present, others missing) is an operator error that causes a loud failure at cold start, naming the missing variables.

In deployed environments, Terraform passes SSM parameter *names* for the three identity-stack-managed values. The API resolves the values from SSM at cold start rather than baking them as literal environment variables:

| SSM parameter env var | Value it resolves |
| - | - |
| `OIDC_ISSUER_SSM_PARAMETER` | The provider's issuer URL (embeds the pool ID). |
| `OIDC_CLIENT_ID_SSM_PARAMETER` | The app client ID for this region. |
| `OIDC_CLIENT_SECRET_SSM_PARAMETER` | The app client secret (a shared secret — never passed as a literal env var in deployed environments). |
| `OIDC_DOMAIN_SSM_PARAMETER` | The ceremony domain, used to derive the token endpoint for Cognito. |

`OIDC_REDIRECT_URI` is always a literal environment variable — it is a deployment constant, not an identity-stack output.

Literal `OIDC_*` variables take precedence over the SSM path. A local development environment using `.env` makes no SSM calls.

An optional `OIDC_TOKEN_ENDPOINT` override takes precedence over the domain-derived endpoint and the issuer-derived default. This is required for Amazon Cognito, where the token endpoint lives on the pool domain (`login.<env>.stralos.app`) rather than on the issuer host.

## Security properties

* **Client credentials are server-held.** The client supplies only the code, its PKCE verifier, and the nonce. The `client_secret` and `redirect_uri` never reach a browser.
* **Confidential client, `client_secret_basic`.** The secret rides in an `Authorization: Basic` header on the token endpoint request, never in the form body.
* **Exact audience.** The ID token's `aud` must be exactly the configured `client_id` — a scalar string or a single-element array. A token that lists multiple audiences is rejected.
* **Asymmetric algorithms only.** The verifier accepts RS256/384/512, PS256/384/512, ES256/384/512, and EdDSA. Symmetric algorithms and `"alg": "none"` are excluded to prevent key-confusion attacks.
* **Short ID-token age window.** The token's `iat` is clamped to 600 seconds from the time of exchange, much tighter than the provider's 1-hour `exp`. A token captured from one exchange cannot be replayed into a later one even if the code were somehow reused.
* **Nonce binding.** The nonce generated by the shell for the specific ceremony must match the `nonce` claim in the ID token. This binds the token to the ceremony the caller started and rejects replayed or injected tokens from other ceremonies.
* **OIDC domain validation.** The ceremony domain (`OIDC_DOMAIN_SSM_PARAMETER`) is validated as a bare host at boot — no credentials, path, query, or fragment. This is a fail-fast configuration check, not a security boundary.
* **Uniform 401.** Every redemption and verification failure — expired code, replayed code, bad PKCE, bad signature, wrong audience, stale token, nonce mismatch, subject mismatch — returns the same `401` body. No information about the failure cause is returned to the caller.

## Relationship to other auth flows

The exchange produces an identical session to `POST /api/global/auth/otp/verify`. After a `200`:

* `POST /api/global/auth/refresh` carries the server-held active organisation forward.
* `POST /api/global/auth/switch-org` changes the active organisation.
* Both flows re-check the membership table before minting.

This refresh token belongs to the retained regional session store. It cannot authenticate to `/api/login/auth/*`, which uses a separate central table and pepper.

See [Auth token architecture](https://help.stralos.app/internal/auth-token-architecture.md) for the full token shape and [Organisation security — Org-users M2](https://help.stralos.app/internal/org-security.md#org-users-m2-mint-from-membership-and-switch-org) for the mint-from-membership contract.

## Signing key resolution at cold start

The API resolves the four OIDC configuration values above from SSM at cold start. The signing key (`AUTH_SIGNING_KEY_SSM_PARAMETER`) follows the resolution order documented in [Auth token architecture — Signing key resolution](https://help.stralos.app/internal/auth-token-architecture.md#signing-key-resolution-api-global).

## Database migration

`0020_idp_m1_users_idp_subject.sql` adds:

* `users.idp_subject text` — nullable, no default. `ADD COLUMN` with no default is metadata-only.
* `users_idp_subject_unique` — a partial unique index (`WHERE idp_subject IS NOT NULL`). Rows with a `NULL` subject are not indexed; the index constrains only bound subjects.

No backfill is required. Every existing row has `idp_subject = NULL` and is unaffected.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.