# 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](decisions/0021-nestjs-zitadel-local-poc.md), [ADR 0024](decisions/0024-nest-owned-portal-auth.md), [infra/README.md](../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):

```text
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](decisions/0021-nestjs-zitadel-local-poc.md), [ADR 0024](decisions/0024-nest-owned-portal-auth.md)):

- 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](https://github.com/zitadel/zitadel/issues/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](decisions/0022-official-login-app-fork.md)) remains optional for skins; not the authoritative password/passkey path.

See [infra/README.md](../infra/README.md) for login-policy and Google IdP setup. Schema: TypeORM ([ADR 0023](decisions/0023-typeorm-data-layer.md)).

## 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](decisions/0028-v1-recovery-without-operator-assist.md)).

### 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](decisions/0028-v1-recovery-without-operator-assist.md)).
