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
174 lines
14 KiB
Markdown
174 lines
14 KiB
Markdown
# 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: <name>* 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.
|