Files
docs/website/STEAM_SIGNIN.md
wtclaude e81c506ca2 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
2026-10-19 20:33:55 -05:00

174 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Sign in with Steam — plan
> **Status: plan, written 2026-10-14; its questions answered 2026-10-19 (§7, D392–D396); built 2026-10-19 on
> the website's and Module-Rust's `edge` (§8).** It ships in the cutover in progress (the org lead, 2026-10-19).
> The live walk with a real Steam account (§6) is owed.
> It is the website's own plan for Steam sign-in, as Rust's
> [`PLAN_FIXES.md`](../modules/rust/PLAN_FIXES.md) D171 set out (§4.8 there), and it is on the roadmap as the
> `website` milestone *Steam sign-in*, issue website#214. Rust's [`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §15 links
> here for what the Rust module needs from it.
## 1. Goal
**Steam becomes a sign-in provider in the same manner as the others** (the org lead, 2026-10-14; D360 in
PLAN_REDESIGNS). That means:
- It is a **built-in provider beside Google and Discord**. An admin turns it on in Admin → Auth.
- It follows the **same link-only policy**, more strictly: **Steam never signs anybody up**, whatever
`player_registration` says (D395).
- It creates sessions through the **same session service**, with the TOTP gate and the trusted-device skip.
- It is linked and unlinked from a player's account page, as the others are.
- It works **in the Android app** through the existing mobile SSO bridge (M9), like the others.
What it adds beyond that: **modules can learn a signed-in account's verified Steam ids**. That is what the Rust
module has needed since its dry run (`modules/rust-dryrun.md` finding 1, MODULE_SYSTEM.md's "a module cannot
register an identity provider").
Out of scope: a module registering its own identity provider. Steam is core's, so session creation stays a part of
core that no module can weaken (MODULE_SYSTEM.md).
## 2. What exists
| Piece | Where | Shape |
|---|---|---|
| Providers | `server/src/auth/providers/` | `registry.js` maps a `kind` to a class. Built-ins `google` and `discord` always exist as defaults; `oidc`/`oauth2` are custom. Each class implements `getAuthorizationUrl(state, options)` and `handleCallback(params)` → `{ subject, email, emailVerified, name }` |
| Provider config | `auth_providers` | `kind ENUM('google','discord','oidc','oauth2')`, `client_id`, `client_secret_enc` (AES-256-GCM via `secretBox`), URLs, scopes |
| Identities | `user_identities` | `(provider, subject)` unique, `user_id` indexed. **Several identities per provider per user are already allowed**, which several Steam accounts need |
| The flow | `router/v1/auth/sso.controller.js` | `beginFlow` (modes `login`, `link`, `mobile`) signs a single-use `sso_tx` cookie. `callback` reads `code` and `state` from the query, verifies the tx, calls `handleCallback`, then `finishLogin` / `finishLink` / `finishMobileLogin`. `provisionSsoPlayer` creates a player when registration allows SSO sign-up |
| Android | `/auth/mobile/sso/*` (M9) | A Custom Tab runs the same `/auth/sso/:provider/*` flow; the app receives a one-time code and exchanges it with PKCE |
## 3. How Steam differs: OpenID 2.0, not OAuth2
| OAuth2 / OIDC (today) | Steam |
|---|---|
| Redirect to an authorize URL with `client_id`, `state`, PKCE | Redirect to `https://steamcommunity.com/openid/login` with `openid.mode=checkid_setup`, `openid.return_to`, `openid.realm` and the identifier-select fields. **No client id, no secret, no `state` parameter** |
| Callback carries `code` and `state` | Callback carries `openid.*` fields. **Our `state` must ride inside `return_to`**, and the tx cookie verifies it as today |
| Exchange the code at the token endpoint | **Verify the assertion** by POSTing the same fields back to Steam with `openid.mode=check_authentication`, and accepting only `is_valid:true` |
| `subject` from the ID token or userinfo | `subject` is the 17-digit SteamID64 at the end of `openid.claimed_id` (`https://steamcommunity.com/openid/id/<id>`) |
| Email and name from the IdP | **No email.** The name and avatar come from the Steam Web API, with the key the site must hold (D394) |
The checks the adapter must make: `openid.op_endpoint` is Steam's, `return_to` matches the URL we sent, the
claimed id matches `^https://steamcommunity\.com/openid/id/\d{17}$`, and `check_authentication` says valid. Each
assertion is single-use, which the existing single-use tx cookie already enforces.
## 4. The build
**Core (`website`):**
- `steam.provider.js`, a `BaseProvider` with `getAuthorizationUrl` and `handleCallback` doing §3. It is a built-in in
`registry.js` (`{ id: 'steam', kind: 'steam', name: 'Steam' }`). Its config needs no client id or secret, and
**needs a Steam Web API key to be enabled** (D394): saving Steam as enabled with no key is refused with a message.
The key is stored in `client_secret_enc`, AES-256-GCM through `secretBox`, and is write-only like a client secret.
- After the assertion is verified, `handleCallback` calls `ISteamUser/GetPlayerSummaries` for the persona name and
avatar. **That call never decides a sign-in:** if it fails, the sign-in goes ahead with the bare id and a warning
in the log. The proof is `check_authentication` alone (§5).
- `auth_providers.kind` gains `steam`, and `user_identities` gains nullable `display_name` and `avatar_url`, written at
link and refreshed at each Steam sign-in. Both schema changes are additive; other providers leave the columns
null.
- `sso.controller.js`'s `callback` accepts Steam's `openid.*` fields where it now requires `code`, for `kind: steam`
only, and passes them to `handleCallback`. After that, `finishLogin`, `finishLink`, `finishMobileLogin` and the
TOTP gate are unchanged, with two differences for Steam: **an unlinked Steam id never reaches
`provisionSsoPlayer`** (D395) and gets the ordinary *"no account is linked"* answer, and **`finishLink` asks the
modules first** (below).
- Admin → Auth shows Steam with an enable switch and the Web API key field.
- The account page lists Steam identities with a *Link Steam* button. Each linked Steam account shows its persona
name and avatar, its id, and an *Unlink* button. Unlinking removes the sign-in identity and nothing else (D393).
- **Module contract, MODULE_API 1.17.0 (additions only; 1.15.0 was taken by a step's `retryAfterMs` and 1.16.0 by a
dry run's advice, Rust PLAN_REDESIGNS §14.3.13 and §11.4.6):**
- a **read** of a user's verified identities for one provider;
- a **pre-link check**: before an identity is written, each module that registered one may refuse it with a
message the player sees, and nothing is written (D396). A refusal only ever stops a link; it cannot let one
through that core would refuse, and it never touches sign-in or session creation;
- a **linked hook** after the identity is written. No unlink hook: D393 leaves nothing for a module to undo.
Names are settled at build. Module-uo uses none of them, and runs its usual frozen-manifest proof against the new
core.
- OpenAPI annotations and the regenerated spec for anything new or changed; `BACKEND_DESIGN.md` §3/§4/§6;
`MODULE_API.md` for 1.17.0.
**Android (`Android-app`):** the provider list comes from `/auth/providers`, so Steam appears where the app lists
providers. The work is the button's mark and a check of the M9 bridge with Steam's callback. Confirmed in the build.
**Module-Rust:** a Steam identity linked on the site **is** a linked Rust player (D392), beside the in-game `/link`
code (R1): the linked hook writes the Rust link, and the pre-link check refuses a Steam id that is already another
website account's Rust link (D396). Unlinking Steam leaves the Rust link in place (D393); it is removed on the Rust
page as today.
## 5. Security
- Nothing secret ships to a client. The Web API key is server-side and write-only in the API, like client secrets.
- The `check_authentication` call is the only proof of a Steam assertion. It is made server to server, and never
skipped. The Web API lookup only decorates an identity; it is never proof.
- **Link-only, with no exception:** a Steam id that is not linked signs nobody in, and SSO sign-up never applies to
Steam (D395).
- A module's pre-link check can only refuse. It runs inside `finishLink` for an already signed-in user, so it has no
path to a session.
- Login hardening (rate limits, bot score, honeypot) applies to the Steam routes as to the others.
## 6. Tests
- Unit: the claimed-id parser, `return_to` matching, a `check_authentication` reply of `is_valid:false`, a replayed
assertion, a wrong `op_endpoint`, a failed Web API lookup that still signs in, enabling with no key refused.
- Flow: login, link and mobile modes against a stubbed Steam endpoint, including the TOTP gate, an unlinked Steam id
refused with SSO sign-up on, a module's pre-link refusal writing nothing, and the linked hook firing once.
- Module-uo proof against the new core.
- A live walk: sign in with a real Steam account on the local review stack, link, unlink, and the same in the app.
It needs the org lead (a real Steam account, and a Web API key for the review stack), so it joins the end walk's
in-game rows rather than holding the build.
## 7. Questions, answered 2026-10-19
The org lead answered Q1–Q4 and ruled on the case Q1 and Q2 raised together. The D-numbers continue Rust's
[`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §10, where D360 was recorded.
- **Q1 — Does a Steam sign-in count as a linked Rust player?** For example, Alice clicks *Link Steam* on her account
page and approves it on steamcommunity.com. Does the Rust module then treat her as owning that Steam account, so
her kit rewards and permissions reach it, without the in-game `/link` code? *Proposed:* yes. **Answered: yes
(D392).**
- **Q2 — What does unlinking remove?** For example, Alice unlinks Steam on her account page. Does her Rust link go
too, and with it the grants D28 resolves to that Steam id? *Proposed:* both go. **Answered: only the sign-in goes
(D393).**
- **Q3 — Names and avatars.** Steam's sign-in returns only the Steam id. Showing *"Steam: CoolGuy99"* with an avatar
needs a Steam Web API key, which an admin would paste into Admin → Auth like a client secret. *Proposed:* an
optional key. **Answered: the key is required (D394).**
- **Q4 — Sign-up through Steam.** When registration allows SSO sign-up, a new account from Steam has **no email**.
*Proposed:* allowed. **Answered: never (D395).**
- **The conflict.** Bob linked Steam id 7656…001 with the in-game `/link` code months ago. Alice, a different
website account, clicks *Link Steam* and signs in to steamcommunity.com as 7656…001. **Answered: Alice's link is
refused (D396).**
| # | Decision | Rejected |
|---|---|---|
| **D392** | **A Steam account linked on the site is a linked Rust player**, beside the in-game `/link` code (R1). Steam's sign-in proves the account at least as well as the code does, and both routes stay. | Steam sign-in as sign-in only. |
| **D393** | **Unlinking Steam removes only the sign-in identity.** A Rust link it made stays, with its grants, until it is removed on the Rust page. | One link with two ways to make it, gone with either. |
| **D394** | **Steam needs a Steam Web API key to be enabled.** Admin → Auth refuses to enable Steam without one; with it, a linked Steam account shows its persona name and avatar. The key is stored like a client secret and is never returned. A failed lookup never stops a sign-in. | An optional key with bare ids without it; never calling the Web API. |
| **D395** | **Steam never signs anybody up.** An unlinked Steam id signs nobody in, even when `player_registration` allows SSO sign-up; a player links Steam to an account they already have. | Sign-up through Steam with no email. |
| **D396** | **A Steam link whose id is already another website account's Rust link is refused.** Alice sees *"This Steam account is already linked to another account on this site"*, and nothing changes. If her claim is real, staff remove Bob's Rust link and she links again. Core makes this possible through a module pre-link check (§4). | Steam wins and moves the Rust link to Alice; Alice gets the sign-in but not the Rust link. |
## 8. Built (2026-10-19)
On the `edge` branches of the cutover in progress, as §4 laid out:
- **Core (`website`):** `steam.provider.js` does §3's checks and the `check_authentication` POST;
`registry.js` makes Steam a built-in (priority 3) whose only requirement is the Web API key;
`sso.controller.js` reads Steam's `openid.*` callback (`openid.mode=cancel` reads as denied), never
provisions for Steam, refreshes the name and avatar on each sign-in, and asks the modules before and
after a link (`auth/identityHooks.js`). Admin → Auth has a Steam tab; the account pages list each Steam
account with its name, avatar and id, keep *Link Steam* offered for another, and unlink one by
`?id=`. MODULE_API **1.17.0**. 32 Steam tests beside the existing suite.
- **Module-Rust:** `checkSteamSignin` (D396) and `linkFromSteamSignin` (D392) in the links model,
`coreApi ^1.17.0`; the Rust page says linking Steam on the account page links it there too.
- **Android-app:** the sign-in sheet already lists providers by name, so Steam needed no button work.
The account screen shows *Steam: &lt;name&gt;* with the SteamID64 and unlinks one account by id; before
this, unlinking would have removed every Steam account at once.
**Walked locally (2026-10-19)** on a copy of the Rust walk database with Module-Rust loaded: the schema
upgrade and a fresh install; Admin → Auth refusing to enable Steam with no key and saving one
encrypted; *Continue with Steam* on the player login and the redirect to steamcommunity.com with §3's
fields; through core's real `finishLink`, a free Steam id becoming a Rust link (D392), another
account's Rust link refused with nothing written (D396), a second Steam account linked, and an id
already linked elsewhere refused by core; and, in the browser, both accounts on the account page,
unlinking one, and its Rust link staying (D393).
**Owed:** §6's live walk with a real Steam account and key, on the web and in the app. It needs the org
lead and joins the end walk's in-game rows.