Skip to content

Platform integration checklist ​

Use this page as the implementation contract for any IESF platform BFF (the server-side part of your web application). Do not integrate with the identity service (identity-api) from the platform: you never see passwords, identity tokens or the identity login pages' internals.

{CENTRAL_BASE} below is the gateway origin, for example https://auth.iesf.gg. The machine-readable contract is {CENTRAL_BASE}/openapi.json (Swagger UI: {CENTRAL_BASE}/docs).

Prerequisites ​

  1. Central administrators register your platform via IESF Card Platform Admin (/admin/platforms) or POST /v1/platforms (platforms:admin). See Admin operations.
  2. You receive a clientId and clientSecret once (create, generate or rotate). Store them in a secret manager; never put them in browser storage or public config.
    • Every redirect pair has its own BFF key. Configure each BFF deployment with the key of the pair whose callback it serves: the key only redeems codes sent to that callback.
    • A server-side API that only checks sessions (step 6b) uses the platform's backend key. It needs no registered URL and cannot exchange codes.
    • After a rotation the previous secret works for 60 more minutes; deploy the new one within that window.
  3. Registration includes:
    • redirectPairs (required, at least one) — pairs of a return URL root (final redirect destination) and the callbackUrl on your BFF that accepts ?code= for logins returning there. Use one pair per deployment host, e.g. https://example.com/ → https://example.com/api/auth/callback
    • identityEnvironmentKey — the identity environment (issuer) users sign in through; defaults to PROD. Register a separate platform (with separate credentials) per environment, e.g. COMPETITIONS_DEV on DEV and COMPETITIONS on PROD. The same e-mail address in DEV and PROD is two different users. Production environments only accept public HTTPS URLs
    • defaultOnboardingMode — none, platform, or central
    • centralOnboardingEnabled — must be true if default mode is central
  4. Your platform is active.
  5. Onboarding fields for your platformKey are configured in Card Onboarding Admin (/admin/onboarding) when the platform needs a schema.

Checklist ​

1. Start authentication ​

Redirect the browser to Central:

http
GET {CENTRAL_BASE}/v1/auth/start?platformKey={YOUR_KEY}&returnTo={PATH_OR_URL}

Rules:

  • platformKey must match the registry exactly
  • returnTo must fall under a registered return URL (absolute URL, or a root-relative path resolved against the default pair); its pair decides which callback receives the handoff
  • Optional onboardingMode must match the registered default if supplied
  • Login and registration use the same URL; the identity login page offers both. Add defaultScreen=up to open the registration page (/sign-up) instead of sign-in (defaultScreen=in, the default), e.g. for a "Create account" button
  • Do not send users to identity authorize URLs yourself
  • Start is a browser navigation: if it fails (unregistered returnTo, inactive platform, missing BFF key…), the user sees the gateway's JSON error. Try every return URL you use once after registration. The full error table is in Authentication flow
  • Recommended: set a short-lived HttpOnly "login pending" cookie on your own domain right before this redirect and require it in step 2 (login CSRF protection; the gateway does not pass a platform state through)

2. Own the callback ​

Implement the callbackUrl of every pair you registered. Central redirects to the callback paired with the matched return URL:

http
GET {callbackUrl}?code=handoff_…

Reject callbacks when:

  • code is missing
  • code does not start with handoff_
  • your "login pending" cookie from step 1 is missing (clear it once used)

The code expires after 60 seconds by default (AUTH_HANDOFF_TTL_SECONDS, at most 300) and works once: exchange it immediately. A user who cancels on the identity login page does not come back to your callback.

3. Exchange server-to-server ​

From the BFF only, with the BFF key of the pair whose callback received the code:

http
POST {CENTRAL_BASE}/v1/auth/exchange
Authorization: Basic {base64(clientId:clientSecret)}
Content-Type: application/json
Accept: application/json

{"code":"handoff_…"}

Validate the JSON:

  • platform.key equals your platform key
  • environment.key is the identity environment this deployment expects (for example reject DEV in production)
  • session.gatewayToken is present
  • session.expiresAt is in the future

Responses: 200 exchange body, 400 malformed body, 401 wrong credentials, 403 a backend key was used, 404 the code is unknown, expired, already used or was sent to another callback (start the login again).

4. Create your local session ​

Build a platform-owned session from the exchange body. Suggested minimum:

FieldSource
Local user iduser.id
Stable identityuser.issuer + user.sub (unique only as a pair)
Platformplatform.key
Onboarding snapshotonboarding.* (and profile.*, always COMPLETE here)
Permissionspermissions
Gateway credentialsession.gatewayToken + session.expiresAt (default lifetime 12 hours)
Post-login destinationreturnTo

Requirements:

  • Persist gatewayToken only server-side or in an HttpOnly cookie you control
  • Never put gatewayToken, Basic credentials, or the handoff code into browser JavaScript, localStorage, or URLs after the callback
  • Redirect the browser to returnTo only after the session exists (or to your onboarding UI — see below)

5. Onboarding branch ​

Before any platform onboarding, the gateway enforces the global profile requirements on every login, in every mode: a user with a missing required global field is sent to the identity UI first and reaches your callback only once it is filled. The exchange reports this as profile.status (COMPLETE for every handoff it issues). Expect the extra identity UI step even in none and platform mode.

Inspect onboarding.status after exchange:

ModeIncomplete behavior
noneProceed; status is informational
platformIN_PROGRESS (always with a non-empty missingFields): send the user to your onboarding UI; your BFF calls Central onboarding APIs with gatewayToken as the cookie (step 6) and finally POST /v1/onboarding/{platformKey}/complete
centralIncomplete users never reach you: the identity UI collects the fields and the gateway hands off only when COMPLETE; if you somehow see incomplete, treat it as an error

See Onboarding.

6. Calling Central user APIs with the gateway token ​

Your BFF calls Central user APIs server to server, sending the gatewayToken from the exchange as a cookie:

http
Cookie: iesf_auth_session={gatewayToken}

Your browser code never calls Central directly and never sees gatewayToken. It calls your own BFF routes, and your BFF calls Central.

Rules:

  • Cookie name. Use iesf_auth_session. Keep it in configuration rather than hard-coded.
  • Encode the token. Send encodeURIComponent(gatewayToken).
  • No Bearer. Do not send gatewayToken as Authorization: Bearer.
  • Set Origin on writes. PUT/POST/PATCH/DELETE calls must carry Origin set to the origin of one of your registered return URLs (e.g. Origin: https://example.com), otherwise Central answers 403 FORBIDDEN. Server-side fetch does not add it for you. Your return URL origins are accepted automatically once your platform is active.
  • 401 means the session is gone. The token expired, was revoked, or the user logged out: drop your local session and send the user through step 1 again.

Example (Node / Next.js route handler):

ts
async function central<T>(path: string, gatewayToken: string, init: RequestInit = {}): Promise<T> {
  const headers = new Headers(init.headers);
  headers.set('Accept', 'application/json');
  headers.set('Cookie', `iesf_auth_session=${encodeURIComponent(gatewayToken)}`);
  if (init.method && !['GET', 'HEAD'].includes(init.method)) headers.set('Origin', new URL(APP_BASE_URL).origin);
  if (init.body) headers.set('Content-Type', 'application/json');
  const response = await fetch(`${CENTRAL}${path}`, { ...init, headers, cache: 'no-store' });
  if (response.status === 401) throw new SessionExpiredError();
  if (!response.ok) throw new Error(`central_${response.status}`);
  return response.json() as Promise<T>;
}

// await central('/v1/onboarding/MY_PLATFORM/requirements', session.gatewayToken);
// await central('/v1/onboarding/MY_PLATFORM/values', session.gatewayToken, { method: 'PUT', body: JSON.stringify({ values }) });

Calls that use this header: /v1/users/me, /v1/profile/me*, /v1/account/me, /v1/onboarding/{platformKey}/* (platform onboarding mode) and /v1/auth/logout (step 7).

Admin and onboarding-schema APIs are not called with gatewayToken. A platform backend that needs them uses a machine client (see Identity operations).

GET /v1/account/me returns accountManagementUrl (the identity UI's /account page) — link users there for password, Google link and identity sessions; the gateway has no password endpoints.

6b. Checking the session from a platform backend (introspect) ​

A platform backend that receives the gatewayToken from its BFF checks it with the gateway. Do not call /v1/users/me with the cookie for this. Authenticate with the platform's backend key (or a BFF key). The call is server to server, so no URL of the backend has to be registered: CORS and Origin checks only apply to browsers and cookie-authenticated writes.

http
POST {CENTRAL_BASE}/v1/auth/introspect
Authorization: Basic base64(clientId:clientSecret)
Content-Type: application/json

{ "token": "{gatewayToken}" }
  • Active session: 200 { "active": true, "sessionId", "expiresAt", "platformKey", "user": { "id", "issuer", "subject", "email", "emailVerified", "status" }, "environment": { "key", "tier" } }.
  • Any unusable token: 200 { "active": false }. This covers unknown, expired, revoked and suspended-user tokens, and tokens created by another platform's login, because sessions are bound to the platform that started the login.
  • 401 means your platform credentials are wrong; it never means the user's session is wrong.
  • 5xx or a timeout means Central is unavailable: answer 503 and do not sign the user out.
  • Caching. Cache only an active result, for 30–60 s and never beyond expiresAt, keyed by sha256(token). Do not cache active: false or errors. Logout and admin session revocation then take effect after that delay. Full response example: API reference.
  • User key. Key your users by (user.issuer, user.subject). The subject is unique only within its issuer.

7. Logout ​

Make logout a POST on your own BFF (never a GET link, which prefetching could trigger). In it:

  1. Drop your local session.
  2. Call Central from the BFF with the user's gatewayToken and your own Origin:
http
POST {CENTRAL_BASE}/v1/auth/logout
Cookie: iesf_auth_session={URL-encoded gatewayToken}
Origin: {your registered return URL origin}
Content-Type: application/json

{ "returnTo": "https://example.com/" }
  1. Redirect the browser to logoutUrl when it is not null; after the identity logout the browser comes back to returnTo (through {CENTRAL_BASE}/v1/auth/logged-out). When logoutUrl is null, or the call failed, redirect to your own signed-out page.

Notes:

  • returnTo is optional and follows the start returnTo rules for your registered return URLs. Without it the user ends on the return URL of your default pair. Either way, only the return URLs registered for your platform under /admin/platforms are used; nothing has to be registered in the identity environment.
  • returnTo may also be sent as a query parameter instead of the body, as with /v1/auth/start: POST {CENTRAL_BASE}/v1/auth/logout?returnTo=https%3A%2F%2Fexample.com%2F. When both are present the body wins.
  • The body may be omitted, empty or {}.
  • 403 means a missing or unregistered Origin, or a returnTo outside your return URLs; 400 a malformed returnTo or body. The gateway session is revoked even then, so still drop your session and redirect.
  • Skipping logoutUrl keeps the identity session alive: the next login then completes without a password prompt.
  • If the identity session already ended elsewhere (expired, or the user logged out in another tab), the identity provider currently answers logoutUrl with an error page instead of redirecting back.

Details: Authentication flow.

8. Hard no’s ​

  • Do not create an identity client for the platform
  • Do not expect ID / access / refresh tokens from Central
  • Do not reuse a handoff code
  • Do not trust client-supplied completion flags
  • Do not accept returnTo or callback URLs from the query string as overrides of the registry

Minimal BFF pseudocode ​

ts
// GET /api/auth/callback?code=handoff_…
async function handleCallback(code: string) {
  if (!code.startsWith('handoff_')) throw new Error('invalid_callback');

  const response = await fetch(`${CENTRAL}/v1/auth/exchange`, {
    method: 'POST',
    headers: {
      Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({ code }),
  });
  if (!response.ok) throw new Error('handoff_exchange_failed');

  const exchange = await response.json();
  if (exchange.platform.key !== PLATFORM_KEY || exchange.environment.key !== ENVIRONMENT_KEY
    || !exchange.session.gatewayToken || Date.parse(exchange.session.expiresAt) <= Date.now()) {
    throw new Error('invalid_handoff');
  }

  await savePlatformSession({
    userId: exchange.user.id,
    sub: exchange.user.sub,
    issuer: exchange.user.issuer,
    gatewayToken: exchange.session.gatewayToken,
    expiresAt: exchange.session.expiresAt,
    onboarding: exchange.onboarding,
    permissions: exchange.permissions,
  });

  if (ONBOARDING_MODE === 'platform' && exchange.onboarding.status !== 'COMPLETE') {
    return redirect(PLATFORM_ONBOARDING_PATH);
  }
  return redirect(exchange.returnTo);
}

// POST /api/auth/logout
async function handleLogout(session: PlatformSession | null) {
  await deletePlatformSession();
  if (!session) return redirect('/signed-out');
  const response = await fetch(`${CENTRAL}/v1/auth/logout`, {
    method: 'POST',
    headers: {
      Cookie: `iesf_auth_session=${encodeURIComponent(session.gatewayToken)}`,
      Origin: new URL(APP_BASE_URL).origin,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({ returnTo: `${APP_BASE_URL}/signed-out` }),
  }).catch(() => null);
  const body = response?.ok ? await response.json() : null;
  return redirect(body?.logoutUrl ?? '/signed-out', 303);
}