Skip to main content
InternalMarkdown

Authentication

An IAM platform authenticates humans and machines. PlayerPal remains authoritative for the player identity graph, pairwise aliases, consent, loyalty ownership, analytics and business authorization.

Long-form: spec §5-§9, §13-§14, §19, §23. Decisions: ADR 0011, 0012, 0016, 0020, 0021, 0023, 0024.

IAM vs PlayerPal​

The IAM subject answers: which authentication account logged in?

The PlayerPal player answers: which loyalty identity owns this activity, balance and relationship graph?

Keeping them separate gives IAM vendor portability, no IAM lookup on each gameplay event, product-controlled aliases, and audited link/unlink/merge. Do not store loyalty or alias data as IAM attributes, groups or roles.

Local/dev POC uses ZITADEL with a NestJS BFF (ADR 0021, ADR 0024, infra/README.md). Production IAM still requires the §23.2 checklist. The IAM subject is not the PlayerPal player.

Security domains​

Isolate four populations (separate Keycloak realms or ZITADEL projects/organisations, subject to POC):

IAM platform
├── Player authentication
├── Partner administration (operator / provider / affiliate)
├── PlayerPal workforce
└── Machine identities (operator / provider / batch clients)

At minimum, separate clients, scopes, signing/audience policies and administrative permissions.

Player methods​

MethodEmail requiredStatus
Passkey / WebAuthnNoRequired; preferred
Google / social OIDC using opaque iss + subNo, if configuration permitsRequired after provider POC
Apple / Microsoft OIDC using opaque subjectNo, if claim filtering is validRecommended
PlayerPal ID + passwordNoOffered on player portal; provisional A0 (ADR 0020, 0024)
Recovery codesNoRequired
TOTP / hardware keyNoOptional / recommended MFA
Email magic linkYesExcluded
SMS OTPPhone requiredExcluded

Player-facing OIDC clients request openid only. Do not request email, profile or phone by default. Never link accounts by matching email. If a provider still emits email, product/legal/security must decide whether transient receipt plus immediate filtering is allowed; otherwise do not enable that provider (ADR 0012).

Workforce (PlayerPal staff and partner staff) may use business email and enterprise SSO. That domain is separate from player identity.

Local POC (Nest-owned portal auth)​

For the NestJS + ZITADEL local vertical (ADR 0021, ADR 0024):

  • The player portal is http://player.localhost:8080/ (host routing, ADR 0018). Nest is same-origin under /auth, /v1, /health so the BFF cookie is first-party after social return.
  • Portal chrome: Sign in / Sign up. Passkey and Google are first-class; PlayerPal ID + password under an OR divider. No email or phone fields.
  • Password + passkey stay on the portal card via Nest (POST /auth/password-signin, POST /auth/password-signup, POST /auth/passkey/begin|finish). Login V2 is not the password/passkey UX.
  • Password sign-in resolution: pp_plr_… → authentication_identities.iam_subject (local) → else ZITADEL ListUsers by username (cold) → Session API with opaque userId. Session loginName-only checks often 404.
  • After social: Account sidebar → Add password / Add passkey (POST /auth/factors/*). Uses player_sessions.iam_user_id while signed in. Assurance stays A1 when already A1 (ADR 0020).
  • Social starts from Nest (IdP intent / GET /auth/login?method=… → /auth/idp/success). IAM human username = pp_plr_…; throwaway password never shown. No Login V2 email form.
  • Discoverable passkey: empty PlayerPal ID → Nest challenge with empty allowCredentials; verify against webauthn_credentials public-key mirror (bridges Session API COMMAND-eeR2e / zitadel#8899). Identifier-first passkey may use Session API with userId.
  • WebAuthn RP: WEBAUTHN_RP_DOMAIN (default hostname of APP_BASE_URL, e.g. player.localhost). Passkeys enrolled on Login V2 (localhost) are a different RP.
  • IAM may hold a synthetic verified placeholder email for User API create; PlayerPal never stores it (ADR 0012).
  • IdP resource ids: ZITADEL_*_IDP_ID on the API; otherwise GET /auth/methods reports unavailable.
  • Logout clears the PlayerPal cookie only (no ZITADEL end_session / Login V2 park).
  • Login V2 fork (ADR 0022) remains optional for skins; not the authoritative password/passkey path.

See infra/README.md for login-policy and Google IdP setup. Schema: TypeORM (ADR 0023).

Sessions and tokens​

Player web: BFF with HttpOnly, Secure, SameSite cookies (ADR 0016). Do not store long-lived tokens in localStorage. Cookie may be set after Nest Session password/passkey or Nest social success (ADR 0024), or after classic Auth Code + PKCE when used.

Active portal sessions live in player_sessions. Players list them on Account (GET /v1/me/sessions) and may revoke other devices (DELETE /v1/me/sessions/:id, POST /v1/me/sessions/revoke-others). Revoking the current session is Sign out / POST /auth/logout only. At create, Nest stores a truncated User-Agent and a derived client_label for display; no IP, fingerprint, or geolocation. Device hints are never used for merge or assurance (ADR 0019). Staff POC: GET/DELETE /v1/staff/players/:pp_plr/sessions with MACHINE_API_KEY (workforce SSO later). Never return token hashes, iam_user_id, oidc_id_token, or the internal player UUID.

Embedded widget: first-party PlayerPal popup; one-time codes; exact postMessage origin checks; no third-party cookies; no refresh tokens in the widget.

Machine clients: short-lived access tokens (recommended 5-15 minutes), private_key_jwt, key overlap during rotation. Token validation uses cached JWKS. No introspection on every call.

The public player token carries the IAM subject, not the private PlayerPal UUID and not live loyalty state. The API maps iss + sub server-side. Opaque iam_subject / session iam_user_id are never returned on /v1/me.

This supersedes ADR 0003 (JWT at operator login for an embedded widget). See ADR 0015 and ADR 0016.

Recovery​

Because PlayerPal does not store email or phone (spec §19):

  • Encourage at least two passkeys.
  • Issue one-time recovery codes (PlayerPal-owned hashed codes — not ZITADEL MFA backup codes).
  • Password reset (if a password exists) uses recovery code, an existing authenticated device/passkey, or a linked social method — not email, SMS, or operator take-over (ADR 0028).

Local POC recovery codes​

Nest owns recovery end-to-end:

  • POST /v1/me/recovery-codes/rotate — generate/replace (plaintext once; Cache-Control: no-store). Requires recent reauth (reauth_at within 10 minutes, set at full login or via POST /auth/reauth/password).
  • POST /v1/me/recovery-codes/confirm — marks setup complete (separate from generation).
  • GET /v1/me/recovery-codes / GET /v1/me.recovery — status (remaining, generated_at, setup_confirmed).
  • POST /auth/recovery/verify — public; consumes one code atomically; issues a short-lived session_scope=recovery cookie.
  • During recovery, only factor enroll (/auth/factors/password, passkey begin/finish), GET /auth/factors, GET /v1/me (scope metadata), and logout are allowed. Dashboard/partner/session-management APIs stay full-session only. Saving a credential upgrades the session to full and revokes other sessions.
  • Codes are HMAC-SHA256 with SUBJECT_PEPPER; presentation separators are stripped on verify. Uniform failure message; rate-limited attempts.

Staff/business portals are unchanged (fake sessionStorage sign-in).

v1 does not include operator-assisted recovery. Staff may list or revoke portal sessions; they must not reset credentials or take ownership of an Account (ADR 0028).