docs(website): Steam sign-in's questions answered (D392-D396)

The org lead answered STEAM_SIGNIN.md's Q1-Q4 on 2026-10-19 and ruled on
the conflict they raise together:

- D392 a Steam account linked on the site is a linked Rust player (Q1)
- D393 unlinking Steam removes only the sign-in identity (Q2)
- D394 Steam needs a Steam Web API key to be enabled (Q3)
- D395 Steam never signs anybody up (Q4)
- D396 a Steam link whose id is another account's Rust link is refused

The build section follows: the key is required, the identity row keeps the
persona name and avatar, and MODULE_API 1.17.0 adds a pre-link check (which
can only refuse) and a linked hook, with no unlink hook. PLAN_REDESIGNS
tables the three decisions Module-Rust builds to and points §15 at them.
The build goes on the website's edge and ships in the cutover in progress.

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 13:28:53 -05:00
parent f1ae0bd5b3
commit 1d0a492b5b
2 changed files with 86 additions and 39 deletions

View File

@@ -1,6 +1,7 @@
# Sign in with Steam — plan
> **Status: plan, written 2026-10-14. Nothing is built.** Its four questions (§7) are for the org lead before code.
> **Status: plan, written 2026-10-14; its questions answered 2026-10-19 (§7, D392–D396). Nothing is built.** It is
> built on the website's `edge` and ships in the cutover in progress (the org lead, 2026-10-19).
> 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
@@ -12,7 +13,8 @@
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 policy**: link-only, with opt-in sign-up when `player_registration` allows SSO.
- 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.
@@ -42,7 +44,7 @@ core that no module can weaken (MODULE_SYSTEM.md).
| 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.** No name either, unless the site holds a Steam Web API key (Q3) |
| 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
@@ -52,57 +54,91 @@ assertion is single-use, which the existing single-use tx cookie already enforce
**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' }`), and its config validates with no client id or secret.
- `auth_providers.kind` gains `steam`. The schema change is additive.
`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`. Everything after `handleCallback` is unchanged: `finishLogin`,
`finishLink`, `finishMobileLogin`, the TOTP gate and provisioning.
- Admin → Auth shows Steam with an enable switch and, per Q3, an optional Web API key stored like a client secret.
- The account page lists Steam identities with a *Link Steam* button, and each linked Steam account is shown with its
id (or name, Q3) and an *Unlink* button.
- **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):** a read of a user's verified identities for one provider,
and a hook when one is linked or unlinked. Names are settled at build. Module-uo uses neither, 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.
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 on the account becomes a linked Rust player (Q1), beside the in-game `/link` code
(R1). Unlinking follows Q2.
**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 optional Web API key is server-side and write-only in the API, like client
secrets.
- 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.
- **Link-only stays the rule:** a Steam id that is not linked signs nobody in, unless registration opts into SSO
sign-up (Q4).
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`.
- Flow: login, link and mobile modes against a stubbed Steam endpoint, including the TOTP gate and provisioning.
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 for the org lead
## 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. Steam's own sign-in
proves the account at least as well as the code does, and both routes stay.
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:** the two are one link with two ways to
make it, so removing either removes the Rust link. The site says so before it does.
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. Without it the account page shows the Steam id, and a provisioned username is `steam_` plus the
last digits of the id.
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, as for any provider that sends no email. The player adds and verifies an address through
the ordinary flow later.
*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. |