Hashed secrets at rest
Core stores only SHA-256 hashes of QR passes and one-time codes. Partner references store only hashes of their session ids.
flowchart LR
classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
classDef staff fill:#FDE9E2,stroke:#E9724C,color:#12191B
subgraph untrusted["Untrusted: devices and the internet"]
app["Guest app"]:::ours
page["Widget page"]:::ext
gate["Gate scanner"]:::ext
end
subgraph partners["Partner servers"]
book["Booking backend"]:::ext
pts["Sota Platforms<br/>backend"]:::ext
end
subgraph finns["FINNS platform"]
adminw["Admin console<br/>Worker"]:::staff
kc["Keycloak"]:::ext
core["Core"]:::ours
db[("PostgreSQL")]:::ours
end
app -- "Keycloak JWT" --> core
app -- "one-time code" --> book
app -- "one-time code" --> pts
page -- "widget token" --> pts
gate -- "gate token" --> core
book -- "booking token" --> core
pts -- "points token" --> core
adminw -- "admin token<br/>+ actor" --> core
kc -- "webhook token" --> core
core --- db
The central idea: no long-lived credential crosses from one zone to another. The app’s tokens stay in the app. Partners get one-time codes and exchange them server to server. Widget pages get a short-lived token that only works against the partner’s own backend.
| Credential | Held by | Grants | Lifetime |
|---|---|---|---|
| Keycloak access token | Guest app | /api/v1/app/* for that guest |
Minutes, refreshed |
| Keycloak offline token | Guest app (mobile, Keychain / Keystore) | Refresh without signing in | ~30 days idle |
| One-time code | App → one partner | One redeem, one audience | 60 s, single use |
| Widget token | Widget page (memory only) | The partner’s own API, for one guest | ≤ 15 min, and never past the app token’s expiry |
| Booking session | Booking site cookie | Browsing as the guest; paying needs a fresh password | 15 min absolute |
| Booking API token | Booking backend | /api/v1/booking/* |
Static, rotated by hand |
| Points API token | Engagement partner backend | /api/v1/points/*, any customer |
Static, rotated by hand |
| Gate token | Every gate | /api/v1/gate/* |
Static, shared |
| Admin service token | Admin console Worker | /api/v1/admin/*, always with an acting staff member |
Static |
| Revoke tokens | Core | Each partner’s /auth/revoke |
Static, one per partner |
| Webhook token | Keycloak extension | /api/v1/keycloak/* |
Static |
finns-core-sync secret |
Core | Read users in Keycloak (view-users only) |
Static |
| Staff session | Admin console cookie (sealed, AES-256-GCM) | The console, re-checked against core every 30 s | 8 h absolute |
Hashed secrets at rest
Core stores only SHA-256 hashes of QR passes and one-time codes. Partner references store only hashes of their session ids.
Constant-time token checks
Every bearer token is compared as a digest, in constant time, before routing. Unknown paths get the same empty 401.
Least data to partners
The engagement partner only ever sees an opaque external id. Booking codes carry name and email only until used.
Login CSRF defence
The booking site asks “Continue as …?” before redeeming a handoff code, so nobody can sign a victim into the attacker’s account.
Fresh sign-in to pay
A session that arrived by handoff can browse but never pay. Paying needs the password within the last five minutes.
Strict widget sandbox
Widget pages run with a strict CSP and Trusted Types, set no cookies or storage, and talk to the app only through exact-origin messaging.
Two gates for staff
JumpCloud group membership decides who can sign in; core’s staff table decides whether they get in and with which role, re-checked on every request.
Fail closed everywhere
The gate never opens without core’s “granted”. The admin console serves nothing if core can’t confirm access.
Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).