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
| Method | Email required | Status |
|---|---|---|
| Passkey / WebAuthn | No | Required; preferred |
Google / social OIDC using opaque iss + sub | No, if configuration permits | Required after provider POC |
| Apple / Microsoft OIDC using opaque subject | No, if claim filtering is valid | Recommended |
| PlayerPal ID + password | No | Offered on player portal; provisional A0 (ADR 0020, 0024) |
| Recovery codes | No | Required |
| TOTP / hardware key | No | Optional / recommended MFA |
| Email magic link | Yes | Excluded |
| SMS OTP | Phone required | Excluded |
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,/healthso 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 opaqueuserId. SessionloginName-only checks often 404. - After social: Account sidebar → Add password / Add passkey (
POST /auth/factors/*). Usesplayer_sessions.iam_user_idwhile 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 againstwebauthn_credentialspublic-key mirror (bridges Session APICOMMAND-eeR2e/ zitadel#8899). Identifier-first passkey may use Session API withuserId. - WebAuthn RP:
WEBAUTHN_RP_DOMAIN(default hostname ofAPP_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_IDon the API; otherwiseGET /auth/methodsreports 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_atwithin 10 minutes, set at full login or viaPOST /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-livedsession_scope=recoverycookie.- 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 tofulland 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).