Skip to content

How guests sign in

Every guest has one FINNS account, held in Keycloak (realm finns). The guest app, the booking website and any future channel sign in against that same account with standard OpenID Connect. No partner runs its own sign-up or stores passwords.

A guest has four identifiers, and each party gets only the ones it needs.

Identifier Looks like Issued by Who sees it
Database id (customer.id) Core’s row id, used in foreign keys Core Core only. It never leaves core: not in any API, token or log shared with others
Subject (sub) Keycloak user id, permanent and internal Keycloak The app, the admin console, core, and the booking engine (it signs in with Keycloak itself). The one id internal apps address a guest by
Session id (sid) Keycloak session id Keycloak Core and the booking engine, to sign one session out
FINNS ID Keycloak username Keycloak The guest only, who can sign in with it instead of their email. It can change (rarely), so nothing uses it as a key
External id (extId) lowercase UUID Core Partners. It is the only identifier a partner ever receives, so partners store nothing else, and the guest never sees it. Internal apps never address a guest by it

Name and email are copied from Keycloak into core, and are given to the booking engine at sign-in only. The engagement partner never receives them.

The app uses the authorization code flow with PKCE as a public client (trident-app). Registration, password reset and any second factor are Keycloak’s own pages, so they can change without an app release.

sequenceDiagram
  autonumber
  actor G as Guest
  box rgb(227,244,243) FINNS platform
    participant APP as Guest app
    participant KC as Keycloak
    participant CORE as Core
  end

  G->>APP: Tap "Sign in"
  APP->>KC: Authorization request (PKCE, scope openid profile email)
  KC->>G: Sign-in or registration page
  G->>KC: Credentials
  KC-->>APP: Redirect with authorization code
  APP->>KC: Exchange code + PKCE verifier
  KC-->>APP: Access token, refresh token, ID token
  Note over APP: nowrap:Tokens kept in Keychain / Keystore (mobile)<br/>or browser storage (web)

  par Keycloak tells core about the account
    KC-)CORE: POST /api/v1/keycloak/user-changes {userId}
    CORE->>KC: Read user (Admin API, service account)
    Note over CORE: nowrap:Create or update the customer:<br/>public external id, name, email copy
  and The app uses its token
    APP->>CORE: GET /api/v1/app/points (Bearer access token)
    CORE-->>APP: {balance}
  end

If the notice from Keycloak is lost, core creates the customer on the app’s first call anyway, and a reconciliation every ten minutes repairs any drift.

On mobile, the app asks for the password at each sign-in (prompt=login, ephemeral browser session) and then keeps the guest signed in with an offline refresh token. On the web, the app relies on Keycloak’s browser session, which is also what lets the booking website sign the guest in silently.

The app never gives a partner its access token. When a guest moves from the app to a partner (the booking website, or a widget inside the app), the app asks core for a one-time code and hands that over instead. The partner’s server exchanges it with core.

sequenceDiagram
  autonumber
  box rgb(227,244,243) FINNS platform
    participant APP as Guest app
    participant CORE as Core
  end
  box rgb(246,241,233) Partner
    participant P as Partner server
  end

  APP->>CORE: Ask for a code (Bearer access token)
  CORE-->>APP: {code, ttlSeconds: 60}
  APP->>P: Hand over the code
  P->>CORE: Redeem the code (Bearer partner token)
  CORE-->>P: Who it is (identifiers this partner may see)
  Note over P: nowrap:Start the partner's own short session.<br/>The code is now spent.

When a guest signs out of the app, core tells every partner, so no session outlives the app’s.

sequenceDiagram
  autonumber
  actor G as Guest
  box rgb(227,244,243) FINNS platform
    participant APP as Guest app
    participant CORE as Core
    participant KC as Keycloak
  end
  box rgb(246,241,233) Partners
    participant BOOK as Booking engine
    participant PTS as Sota Platforms
  end

  G->>APP: Sign out
  APP->>CORE: POST /api/v1/app/logout (Bearer access token)
  Note over CORE: nowrap:Void this session's unused codes
  CORE-->>APP: 204
  par Core notifies partners (async, one retry)
    CORE-)BOOK: POST /auth/revoke {sub, sid}
    BOOK-->>CORE: 200 (sessions deleted)
  and
    CORE-)PTS: POST /auth/revoke {extId, signedOutAt}
    PTS-->>CORE: 200 (sessions deleted)
  end
  APP->>KC: Revoke refresh token / end Keycloak session
  opt Web sign-out ends the Keycloak session
    KC-)BOOK: Back-channel logout (signed logout_token)
  end
  1. Each partner hosts POST /auth/revoke at the root of its origin and answers exactly 200. Core retries once on anything else.
  2. A partner must also refuse any later attempt to start a session from a code issued before the sign-out, because a code can still be in flight when the revoke arrives.
  3. Partners that sign guests in with Keycloak directly (the booking engine) also receive Keycloak’s own back-channel logout.
Client Type Used by Purpose
trident-app Public, PKCE Guest app Guest sign-in; core accepts only tokens issued to this client
poc-booking Public, PKCE Booking website Direct sign-in, silent SSO, fresh sign-in before payment
finns-core-sync Confidential, service account only Core Read users (view-users), nothing else

A new partner that signs guests in itself gets its own client, with its own redirect URIs and back-channel logout URL.

Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).