Appearance
Authentication flow
This page mirrors the runtime behavior of src/modules/auth-flow/routes.ts and the supporting transaction, handoff, and gateway-session services. Platform teams implement only four things: the start redirect, the callback, the server-to-server exchange and logout. Everything between start and callback (identity login, registration, e-mail verification, onboarding) is handled by IESF.
Responsibility boundary
text
identity-api / -web credentials, login/registration, e-mail verification, identity sessions, OIDC tokens
IESF Central Gateway OIDC client, transaction, callback, JIT user, onboarding, handoff, logout
Platform BFF handoff exchange, local platform session, final returnTo redirectEach identity environment (one OIDC issuer, e.g. https://identity.iesf.gg/api/auth for PROD, plus the gateway's OIDC client in it) registers the same gateway callback, /v1/auth/callback (AUTH_CALLBACK_URL). A platform is bound to one environment; platforms are not identity clients.
Happy path (nothing to onboard)
When the user has every required detail, the browser goes straight from the identity login to your callback. Otherwise one or two onboarding pages come in between (see Onboarding steps); your integration does not change.
Onboarding steps
When something required is missing, the gateway does not issue a handoff yet. It sends the browser to the identity UI, which collects the data, and resumes the login afterwards. There are up to two steps:
- Global profile (every platform, every onboarding mode): while a required global profile field is empty,
…/onboarding?platform={platformKey}&resume={transactionId}&scope=global. - Platform fields (only
centralmode): while the platform's own required fields are empty,…/onboarding?platform={platformKey}&resume={transactionId}.
resume never trusts a client completed=true flag; only the server-side evaluation continues the login. Platforms never call resume themselves.
1. Start login
http
GET /v1/auth/start?platformKey=IESF_COMPETITIONS&returnTo=/matches/42Optional query: onboardingMode=none|platform|central. When present it is an assertion only and must equal the platform's registered default_onboarding_mode. It cannot override policy.
Optional query: defaultScreen=in|up (default in). It chooses the identity UI page an anonymous user lands on: in the sign-in page (/sign-in), up the registration page (/sign-up). It is not stored in the transaction and changes nothing else in the flow.
Gateway behavior (AuthenticationTransactionService.create):
- Load the active platform by
platformKey - Resolve onboarding mode (query or platform default)
- Reject
centralwhencentral_onboarding_enabledis false (409 CENTRAL_ONBOARDING_DISABLED) - Require at least one redirect pair (
409 PLATFORM_CALLBACK_NOT_CONFIGURED) and an active identity environment (409 IDENTITY_ENVIRONMENT_INACTIVE) - Match
returnToagainst the platform's redirect pairs (absolute URL, or root-relative path resolved against the default pair); the most specific return URL wins - Persist transaction (~10 minutes TTL): hashed
state, hashednonce, AES-GCM encrypted PKCE verifier and nonce ciphertext, mode, the matched pair's callback, the platform's identity environment, normalizedreturnTo 302to theauthorization_endpointof the environment's discovery document (<issuer>/.well-known/openid-configuration, i.e.<issuer>/oauth2/authorize) with the environment'sclient_id,redirect_uri=AUTH_CALLBACK_URL,response_type=code,scope=openid profile email,state,nonce,code_challenge(S256),code_challenge_method=S256, andscreen_hint=signupwhendefaultScreen=up
Login and registration use the same start path; there is no separate registration URL. defaultScreen only decides which page opens first:
http
GET /v1/auth/start?platformKey=IESF_COMPETITIONS&returnTo=/matches/42&defaultScreen=upidentity-api always sends an anonymous browser to its login page (/sign-in) with the signed authorize request. When that request carries screen_hint=signup, identity-web switches to /sign-up and keeps the signed query, so registration continues the same OIDC flow. The user can still switch between the two tabs. A browser that already has a session at the identity environment skips both pages, whatever defaultScreen says.
Start is a browser navigation, so any error is shown to the user as a JSON body on the gateway's host. A 4xx here always points at the platform's configuration or the returnTo it sent:
| Status | code | Cause |
|---|---|---|
400 | VALIDATION_ERROR | Missing or malformed platformKey / returnTo |
400 | INVALID_REDIRECT | returnTo is not an absolute http(s) URL or root-relative path, or has userinfo / a hash |
403 | FORBIDDEN | returnTo is not under a registered return URL, or onboardingMode differs from the registered mode |
404 | NOT_FOUND | Unknown or inactive platform |
409 | PLATFORM_CALLBACK_NOT_CONFIGURED | The platform has no redirect pair |
409 | PLATFORM_CREDENTIALS_NOT_CONFIGURED | The matched pair has no BFF key, so its code could never be exchanged |
409 | IDENTITY_ENVIRONMENT_INACTIVE | The platform's identity environment is switched off |
409 | CENTRAL_ONBOARDING_DISABLED | central mode while central onboarding is disabled |
502 | IDENTITY_DISCOVERY_INVALID | Discovery reports another issuer or lacks an endpoint |
503 | IDENTITY_UNAVAILABLE / OIDC_CLIENT_NOT_CONFIGURED | Discovery unreachable / the environment has no gateway client |
Test every return URL you use against the registry once after registration (a 302 to the identity login means it is accepted).
returnTo rules
- Absolute
http:/https:URL, or a root-relative path (/…) - No userinfo, no hash
- Origin must match a redirect pair's return URL root; the pathname must equal the root's path or lie beneath it on a
/boundary (/appaccepts/app/xbut not/application) - When several roots match, the one with the longest path determines the callback
- Malformed values →
400 INVALID_REDIRECT; well-formed but unregistered destinations →403
2. Central callback
http
GET /v1/auth/callback?code=...&state=...Gateway behavior (handleIdentityCallback):
- Atomically consume the transaction by hashed
state(invalid, older than 10 minutes, or already used →404) - Resolve the identity environment captured on the transaction (never the platform's current binding; inactive →
409 IDENTITY_ENVIRONMENT_INACTIVE) and exchange the code at itstoken_endpointwithclient_secret_basicand the stored PKCE verifier (401 AUTHORIZATION_CODE_EXCHANGE_FAILED;401 TOKEN_RESPONSE_INVALIDwhen no ID token comes back;503 IDENTITY_UNAVAILABLE) - Verify the ID token against the environment's JWKS: issuer exactly equal, audience = the environment's client ID, algorithm EdDSA/RS256/ES256,
subpresent,noncematches (401 ID_TOKEN_INVALID/401 NONCE_MISMATCH). The access token in the token response is ignored. - Find or create the local user by (issuer,
sub) and update e-mail,email_verifiedand last login (403 ACCOUNT_DELETED/ACCOUNT_INACTIVEfor blocked accounts). JIT copies the OIDCpicture(HTTPS only) into an empty profileavatarUrlandnameinto profilemetadata.displayNamewhen not set yet. The user is attached to the transaction. - PROD only: when the e-mail is verified and listed in
PLATFORM_ADMIN_BOOTSTRAP_EMAILS, grantplatform_admin(audituser_role.bootstrapped) - Bootstrap membership + onboarding evaluation for the platform
- Create the gateway session bound to the environment, with a permission snapshot of the user's local roles (
platform_admin→platforms:admin,onboarding:schema:read,onboarding:schema:write). Only deployment (PROD) users get permissions; other environments never grant them. The ID token is stored encrypted on the session, solely asid_token_hintfor logout. Set-CookieHttpOnly gateway session (Path=/,SameSite=Lax,Securein production)- Redirect:
- a required global profile field is missing (any mode): the identity UI on the host of the environment's issuer,
/env/{key}/onboarding?platform={platformKey}&resume={transactionId}&scope=global({key}is the issuer's/api/auth/env/{key}path key, otherwisedefault) centralmode and a required platform field is missing: the same page withoutscope(409 CENTRAL_ONBOARDING_DISABLEDwhen central onboarding is disabled)- otherwise: the matched pair's callback
?code=handoff_…
- a required global profile field is missing (any mode): the identity UI on the host of the environment's issuer,
When the identity provider sends the browser back with an error instead of a code (for example error=access_denied because the user cancelled), the callback answers 400 VALIDATION_ERROR and the user stays on the gateway; the platform callback is not called.
Because users are keyed by (issuer, subject), the same e-mail address signing in through DEV and PROD produces two separate local users.
3. Resume (after each onboarding step)
http
GET /v1/auth/resume?transaction={uuid}Requires the gateway session cookie. The transaction must already be consumed, bound to the same local user, and belong to an active platform; a session from another identity environment is rejected (401). Onboarding is re-bootstrapped. While a required global field is missing it redirects to the global step, then (central mode) while a required platform field is missing to the platform step; otherwise it issues the handoff. Only the identity UI calls this endpoint.
At most one handoff row exists per transaction (authentication_handoff_codes.transaction_id unique). A second issue attempt maps to 409 CONFLICT via PostgreSQL unique violation handling.
4. Platform handoff exchange
Gateway redirects to the server-registered callback:
text
https://competition.example/api/auth/callback?code=handoff_…Handoff properties (HandoffService + config):
- Prefix
handoff_ - Stored only as SHA-256
- TTL =
AUTH_HANDOFF_TTL_SECONDS(default 60, allowed range 30–300) - Bound to one platform, transaction, user, and gateway session
- Gateway session token encrypted at rest while pending
- Atomically single-use
Your BFF exchanges it with its Central platform credentials, not identity credentials:
http
POST /v1/auth/exchange
Authorization: Basic base64(platformClientId:platformClientSecret)
Content-Type: application/json
{ "code": "handoff_…" }Exchange response (runtime)
Exact object returned by HandoffService.exchange:
json
{
"session": {
"id": "<gateway-session-uuid>",
"expiresAt": "<ISO-8601>",
"gatewayToken": "<opaque-token>"
},
"user": {
"id": "<local-user-uuid>",
"sub": "<identity-subject>",
"issuer": "https://identity.iesf.gg/api/auth",
"picture": "https://… (only when known)"
},
"platform": {
"key": "IESF_COMPETITIONS"
},
"environment": {
"key": "PROD",
"tier": "production"
},
"onboarding": {
"status": "IN_PROGRESS | COMPLETE",
"requirementVersion": 4,
"missingFields": ["…"],
"canCompleteCentrally": true
},
"profile": {
"status": "COMPLETE",
"missingFields": []
},
"permissions": ["platforms:admin", "onboarding:schema:read", "onboarding:schema:write"],
"returnTo": "https://competition.example/matches/42"
}Notes:
- No identity tokens are returned.
user.issueris additive.subis unique only together withissuer, so key any platform-side mapping on both (or onuser.id).pictureis present only when known.permissionsare the gateway session's permission snapshot (may be empty; always empty outside PROD). A newly granted role appears at the next login; a removed role is stripped from active sessions immediately.returnTois the server-validated absolute URL captured at start.onboardingis your platform's status. When nothing required is missing the gateway records completion itself, soIN_PROGRESSalways comes with a non-emptymissingFields(only possible inplatformornonemode; incentralmode the user is not handed off before it isCOMPLETE).profileis the global profile status. It is alwaysCOMPLETEin a handoff, because the gateway stops every login until the global requirements are met.environmentis the identity environment the user signed in through. Reject the exchange if it is not the environment your deployment expects (for example a production BFF receivingDEV).- Store
gatewayTokenonly in a protected server-side / HttpOnly session. Your BFF forwards it to Central asCookie: iesf_auth_session=<gatewayToken>(see Platform integration, step 6). Never expose it to browser JavaScript.
Invalid / expired / already used / wrong-platform codes → 404. Bad Basic credentials → 401. A backend key → 403.
The callback receives only ?code=handoff_…; the gateway does not carry a platform-generated state. To protect against login CSRF (a victim's browser being sent to your callback with an attacker's code), set a short-lived, HttpOnly "login pending" cookie on your own domain before redirecting to start, and accept a callback only when that cookie is present.
5. Logout
http
POST /v1/auth/logout
Cookie: iesf_auth_session=<URL-encoded gatewayToken>
Origin: https://competition.example
Content-Type: application/json
{ "returnTo": "/" }- Cookie and Origin. It is a cookie-authenticated write, so
Originmust be allowlisted (CORS_ORIGINS, an active identity environment's host or a return URL origin of an active platform), otherwise403 FORBIDDEN. A BFF forwards the user'sgatewayTokenas the cookie together with its ownOrigin; server-side HTTP clients do not addOriginby themselves. - Body. Optional. Send no body, an empty body or
{}when you do not needreturnTo; unknown properties are rejected with400. returnTo. Where the browser ends up after the identity logout. Same rules as the startreturnTo, matched against the redirect pairs of the platform whose login created the session (400 INVALID_REDIRECTwhen malformed,403 FORBIDDENwhen not registered). It travels through the identity provider as an encryptedstate(valid for 1 hour) toGET /v1/auth/logged-out, which redirects to it. WithoutreturnTo, the browser goes to the return URL of the platform's default pair the same way. Only a session without a platform falls back to the environment's post-logout URI (PROD:IDENTITY_POST_LOGOUT_REDIRECT_URI, defaultACCOUNT_CENTER_URL).- Response headers. The response always clears the gateway cookie (
Max-Age=0) and sendsCache-Control: no-store. The session is revoked in every case, also whenreturnTois rejected.
json
{ "logoutUrl": "https://identity.iesf.gg/api/auth/oauth2/end-session?id_token_hint=…&client_id=iesf-gateway-prod&post_logout_redirect_uri=https%3A%2F%2Fauth.iesf.gg%2Fv1%2Fauth%2Flogged-out&state=…" }Navigate the browser to logoutUrl to end the identity session as well. If you skip it, the identity session stays alive and the next login completes without a password prompt, possibly as the same account.
{ "logoutUrl": null } means the gateway cannot end the identity session for you: there was no cookie, the session was already invalid, expired or revoked (the call is idempotent), or the environment advertises no end_session_endpoint / its discovery is unreachable. Send the browser to your own signed-out page in that case.
GET /v1/auth/logged-out is the gateway client's post-logout redirect URI in every identity environment; platforms never call it. A missing, tampered or expired state sends the browser to ACCOUNT_CENTER_URL (or IDENTITY_POST_LOGOUT_REDIRECT_URI) instead of your returnTo.
6. Session check from a platform backend (introspect)
http
POST /v1/auth/introspect
Authorization: Basic base64(clientId:clientSecret)
Content-Type: application/json
{ "token": "<gatewayToken>" }Server to server only. Authenticate with the platform's backend key (an API that is not a browser-facing BFF; it needs no registered URL) or any of its BFF keys. The answer always carries Cache-Control: no-store.
Active session (200):
json
{
"active": true,
"sessionId": "25f654d9-464d-49c9-a27f-97208ea9067b",
"expiresAt": "2026-08-11T22:00:00.000Z",
"platformKey": "IESF_COMPETITIONS",
"user": {
"id": "6f1c2c3e-8f7d-4b8e-9f1a-2b3c4d5e6f70",
"issuer": "https://identity.iesf.gg/api/auth",
"subject": "<identity-subject>",
"email": "jane.doe@example.org",
"emailVerified": true,
"status": "ACTIVE"
},
"environment": { "key": "PROD", "tier": "production" }
}| Response | Meaning | What your backend does |
|---|---|---|
200 { "active": true, … } | Live session created by your platform's login | Accept the request; key the user by (user.issuer, user.subject) |
200 { "active": false } | Unknown, expired, revoked or logged-out token, a suspended or deleted user, or a session created by another platform's login | Answer 401; the user must log in again |
400 | Malformed body | Fix the request |
401 | Your platform credentials are wrong (never the user's session) | Fix configuration; do not sign the user out |
5xx / timeout | Central is unavailable | Answer 503; do not sign the user out |
Recommended caching: 30–60 seconds.
- Cache only
active: trueresults, keyed bysha256(token)(never the raw token), for at most 30–60 s and never beyondexpiresAt. - Do not cache
active: false, errors or timeouts. - Consequence: a logout, an admin session revocation or a suspended account reaches your backend only after the cache period. Choose the shorter end for sensitive operations, or skip the cache for them.
Platform registry fields that drive this flow
Each active platform must have:
| Field | Role |
|---|---|
platform_key | Start query identity (^[A-Z][A-Z0-9_]{1,63}$) |
redirect pairs (platform_redirect_pairs) | Allowlisted returnTo roots, each with its browser handoff callback |
| identity environment | Issuer and gateway OIDC client used for the login |
default_onboarding_mode | none | platform | central |
central_onboarding_enabled | Required true when mode is central |
| BFF key (one per redirect pair) | Basic auth for /v1/auth/exchange (only for codes sent to that pair's callback) and /v1/auth/introspect |
| backend key (at most one) | Basic auth for /v1/auth/introspect only |
Secrets are returned only on create/rotate and stored as scrypt hashes. Callback and return URLs are never taken from untrusted callback query parameters.