Skip to content

Security model

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.

  • Guest personal data (name, email) lives in Keycloak; core keeps a read-only copy for staff. The brief requires compliance with Indonesia’s PDP Law and GDPR: consent capture and data-subject requests are not designed yet.
  • Deleting a guest account in Keycloak marks the customer deleted in core and stops it appearing by name; ledger history is kept for integrity.
  • No service logs codes, tokens, session ids, QR contents or emails.

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