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
Authorization header. It is rate-limited per source IP (429).
Request body
The
redirectUri is held server-side. The client never supplies it, so it cannot redirect the redemption.
Response codes
403 codes
Thecode field discriminates the reason:
pendingInvites shape
The pendingInvites array (present on no_membership_in_region 403 responses) uses membershipId as its identifier field:
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 and Admin provisioning — 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:
OIDC configuration and SSM resolution
The exchange is configured by four required variables and up to four optional overrides. A completely absent configuration results in503. 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:
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_secretandredirect_urinever reach a browser. - Confidential client,
client_secret_basic. The secret rides in anAuthorization: Basicheader on the token endpoint request, never in the form body. - Exact audience. The ID token’s
audmust be exactly the configuredclient_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
iatis clamped to 600 seconds from the time of exchange, much tighter than the provider’s 1-hourexp. 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
nonceclaim 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
401body. No information about the failure cause is returned to the caller.
Relationship to other auth flows
The exchange produces an identical session toPOST /api/global/auth/otp/verify. After a 200:
POST /api/global/auth/refreshcarries the server-held active organisation forward.POST /api/global/auth/switch-orgchanges the active organisation.- Both flows re-check the membership table before minting.
/api/login/auth/*, which uses a separate central table and pepper.
See Auth token architecture for the full token shape and Organisation security — Org-users M2 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.
Database migration
0020_idp_m1_users_idp_subject.sql adds:
users.idp_subject text— nullable, no default.ADD COLUMNwith no default is metadata-only.users_idp_subject_unique— a partial unique index (WHERE idp_subject IS NOT NULL). Rows with aNULLsubject are not indexed; the index constrains only bound subjects.
idp_subject = NULL and is unaffected.
