docs(website): Steam sign-in built (MODULE_API 1.17.0)

STEAM_SIGNIN.md gains its Built section (section 8) and status;
MODULE_API.md records 1.17.0 (ctx.identities.list, onIdentityLinking,
onIdentityLinked); BACKEND_DESIGN.md gains the auth_providers /
user_identities shapes, the identities route with ?id=, and the Steam
security notes in section 6; PLAN_REDESIGNS section 15 notes the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-19 20:33:55 -05:00
parent c748a54e9a
commit e81c506ca2
5 changed files with 119 additions and 6 deletions

View File

@@ -901,6 +901,20 @@ list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `las
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
is otherwise unchanged.
### auth_providers / user_identities — SSO config and linked identities
`auth_providers` holds one row per configured provider: `kind ENUM('google','discord','oidc','oauth2','steam')`,
`client_id`, `client_secret_enc` (AES-256-GCM via `secretBox`, write-only in the API) and, for custom
providers, the three endpoint URLs. Google, Discord and **Steam** are built-ins (rows created on first
save). Steam has no client id: its `client_secret_enc` is the **Steam Web API key**, without which
Admin → Auth refuses to enable it (`STEAM_SIGNIN.md` D394).
`user_identities` maps `(provider, subject)` — unique — to a `user_id`; several identities per provider
per user are allowed. `display_name VARCHAR(191) NULL` and `avatar_url VARCHAR(500) NULL` (added for
Steam, additive `ALTER … ADD COLUMN IF NOT EXISTS`) hold the persona name and Steam-CDN avatar from the
Web API, written at link and refreshed at each Steam sign-in; a failed lookup leaves the last good
values. Other providers leave both null.
### trusted_devices — MFA "Trust this device"
Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical
@@ -1413,7 +1427,7 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
| PATCH | `/me/account/email` | cookie / bearer (rate-limited, **password step-up**) | `{email, currentPassword?}` | request an address. **Stages it in `email_pending`; `email` is untouched**, so the account keeps receiving password-reset mail at the address it already has until the emailed link is opened — a typo cannot redirect account recovery. `currentPassword` is required when the account has one (an address is where recovery lands); an SSO-provisioned account with a null hash is exempt, the same carve-out `/me/account/password` makes. Returns `{email_pending, emailed, reason}` — `emailed:false` is reported honestly rather than pretending, because the caller typed this address themselves and there is no enumeration reason to hide it. **429** past the per-user send ceiling |
| POST | `/me/account/email/resend` | cookie / bearer (rate-limited) | — | re-send the link for the staged address; **400** when nothing is pending |
| DELETE | `/me/account/email/pending` | cookie / bearer | — | abandon the staged address **and retire its outstanding links**, so a confirmation email already delivered can no longer install it |
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
| GET | `/me/account/identities` · DELETE `…/:provider[?id=]` | cookie / bearer | — | list / unlink own SSO identities. Each row carries `id`, `provider`, `subject` (the user's own external id; for Steam the SteamID64), `email`, and for Steam `display_name` + `avatar_url`. DELETE removes every identity of the provider, or with `?id=` that one (a person may link several Steam accounts). Only the sign-in goes: a game module's own link made from it stays (`STEAM_SIGNIN.md` D393) |
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
@@ -1533,7 +1547,7 @@ tab it does not control, and without a trust token ever appearing in a start URL
|---|---|---|---|---|
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) |
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` (Steam: `?state&openid.*`) | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}`. When the session carries `trust_device`, also mint a `platform:'mobile'` trusted device and add `trustToken` — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in |
| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
@@ -1800,6 +1814,15 @@ who"; `activity_log` provides the history feed.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`).
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **SSO is link-only**, through the one session service. **Steam** (`STEAM_SIGNIN.md`) is OpenID 2.0:
the callback's `openid.*` fields are proved only by a server-to-server `check_authentication` POST to
Steam (`is_valid:true`, never skipped; a transport failure refuses), after `op_endpoint`, an exact
`return_to` (our callback plus the tx `state`, so a captured assertion cannot be replayed into another
flow) and a 17-digit claimed id have been checked. The Web API key is used only for the persona name
and avatar, never as proof, and never leaves the server. **Steam never signs anybody up**, whatever
`player_registration` says (D395), on the web and the mobile bridge alike. A module may refuse a link
before it is written (`onIdentityLinking`, MODULE_API 1.17.0); the refusal fails closed and its text is
logged, never reflected into the redirect (`link_error=in_use|refused` only).
- **Rate limiting** (`express-rate-limit`) on `/auth/login`, `/public/contact`, and the account-change
routes. Every core public read is an indexed lookup of bounded size, so none is limited. A module
gets the same factory through `ctx.middleware.rateLimit` and is expected to use it on any read that