Skip to content

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 redirect

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

  1. Global profile (every platform, every onboarding mode): while a required global profile field is empty, …/onboarding?platform={platformKey}&resume={transactionId}&scope=global.
  2. Platform fields (only central mode): 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/42

Optional 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):

  1. Load the active platform by platformKey
  2. Resolve onboarding mode (query or platform default)
  3. Reject central when central_onboarding_enabled is false (409 CENTRAL_ONBOARDING_DISABLED)
  4. Require at least one redirect pair (409 PLATFORM_CALLBACK_NOT_CONFIGURED) and an active identity environment (409 IDENTITY_ENVIRONMENT_INACTIVE)
  5. Match returnTo against the platform's redirect pairs (absolute URL, or root-relative path resolved against the default pair); the most specific return URL wins
  6. Persist transaction (~10 minutes TTL): hashed state, hashed nonce, AES-GCM encrypted PKCE verifier and nonce ciphertext, mode, the matched pair's callback, the platform's identity environment, normalized returnTo
  7. 302 to the authorization_endpoint of the environment's discovery document (<issuer>/.well-known/openid-configuration, i.e. <issuer>/oauth2/authorize) with the environment's client_id, redirect_uri=AUTH_CALLBACK_URL, response_type=code, scope=openid profile email, state, nonce, code_challenge (S256), code_challenge_method=S256, and screen_hint=signup when defaultScreen=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=up

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

StatuscodeCause
400VALIDATION_ERRORMissing or malformed platformKey / returnTo
400INVALID_REDIRECTreturnTo is not an absolute http(s) URL or root-relative path, or has userinfo / a hash
403FORBIDDENreturnTo is not under a registered return URL, or onboardingMode differs from the registered mode
404NOT_FOUNDUnknown or inactive platform
409PLATFORM_CALLBACK_NOT_CONFIGUREDThe platform has no redirect pair
409PLATFORM_CREDENTIALS_NOT_CONFIGUREDThe matched pair has no BFF key, so its code could never be exchanged
409IDENTITY_ENVIRONMENT_INACTIVEThe platform's identity environment is switched off
409CENTRAL_ONBOARDING_DISABLEDcentral mode while central onboarding is disabled
502IDENTITY_DISCOVERY_INVALIDDiscovery reports another issuer or lacks an endpoint
503IDENTITY_UNAVAILABLE / OIDC_CLIENT_NOT_CONFIGUREDDiscovery 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 (/app accepts /app/x but 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):

  1. Atomically consume the transaction by hashed state (invalid, older than 10 minutes, or already used → 404)
  2. Resolve the identity environment captured on the transaction (never the platform's current binding; inactive → 409 IDENTITY_ENVIRONMENT_INACTIVE) and exchange the code at its token_endpoint with client_secret_basic and the stored PKCE verifier (401 AUTHORIZATION_CODE_EXCHANGE_FAILED; 401 TOKEN_RESPONSE_INVALID when no ID token comes back; 503 IDENTITY_UNAVAILABLE)
  3. Verify the ID token against the environment's JWKS: issuer exactly equal, audience = the environment's client ID, algorithm EdDSA/RS256/ES256, sub present, nonce matches (401 ID_TOKEN_INVALID / 401 NONCE_MISMATCH). The access token in the token response is ignored.
  4. Find or create the local user by (issuer, sub) and update e-mail, email_verified and last login (403 ACCOUNT_DELETED / ACCOUNT_INACTIVE for blocked accounts). JIT copies the OIDC picture (HTTPS only) into an empty profile avatarUrl and name into profile metadata.displayName when not set yet. The user is attached to the transaction.
  5. PROD only: when the e-mail is verified and listed in PLATFORM_ADMIN_BOOTSTRAP_EMAILS, grant platform_admin (audit user_role.bootstrapped)
  6. Bootstrap membership + onboarding evaluation for the platform
  7. 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 as id_token_hint for logout.
  8. Set-Cookie HttpOnly gateway session (Path=/, SameSite=Lax, Secure in production)
  9. 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, otherwise default)
    • central mode and a required platform field is missing: the same page without scope (409 CENTRAL_ONBOARDING_DISABLED when central onboarding is disabled)
    • otherwise: the matched pair's callback ?code=handoff_…

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.issuer is additive. sub is unique only together with issuer, so key any platform-side mapping on both (or on user.id). picture is present only when known.
  • permissions are 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.
  • returnTo is the server-validated absolute URL captured at start.
  • onboarding is your platform's status. When nothing required is missing the gateway records completion itself, so IN_PROGRESS always comes with a non-empty missingFields (only possible in platform or none mode; in central mode the user is not handed off before it is COMPLETE).
  • profile is the global profile status. It is always COMPLETE in a handoff, because the gateway stops every login until the global requirements are met.
  • environment is 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 receiving DEV).
  • Store gatewayToken only in a protected server-side / HttpOnly session. Your BFF forwards it to Central as Cookie: 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 Origin must be allowlisted (CORS_ORIGINS, an active identity environment's host or a return URL origin of an active platform), otherwise 403 FORBIDDEN. A BFF forwards the user's gatewayToken as the cookie together with its own Origin; server-side HTTP clients do not add Origin by themselves.
  • Body. Optional. Send no body, an empty body or {} when you do not need returnTo; unknown properties are rejected with 400.
  • returnTo. Where the browser ends up after the identity logout. Same rules as the start returnTo, matched against the redirect pairs of the platform whose login created the session (400 INVALID_REDIRECT when malformed, 403 FORBIDDEN when not registered). It travels through the identity provider as an encrypted state (valid for 1 hour) to GET /v1/auth/logged-out, which redirects to it. Without returnTo, 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, default ACCOUNT_CENTER_URL).
  • Response headers. The response always clears the gateway cookie (Max-Age=0) and sends Cache-Control: no-store. The session is revoked in every case, also when returnTo is 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" }
}
ResponseMeaningWhat your backend does
200 { "active": true, … }Live session created by your platform's loginAccept 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 loginAnswer 401; the user must log in again
400Malformed bodyFix the request
401Your platform credentials are wrong (never the user's session)Fix configuration; do not sign the user out
5xx / timeoutCentral is unavailableAnswer 503; do not sign the user out

Recommended caching: 30–60 seconds.

  • Cache only active: true results, keyed by sha256(token) (never the raw token), for at most 30–60 s and never beyond expiresAt.
  • 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:

FieldRole
platform_keyStart query identity (^[A-Z][A-Z0-9_]{1,63}$)
redirect pairs (platform_redirect_pairs)Allowlisted returnTo roots, each with its browser handoff callback
identity environmentIssuer and gateway OIDC client used for the login
default_onboarding_modenone | platform | central
central_onboarding_enabledRequired 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.

Next ​