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
12 KiB
Sign in with Steam — plan
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
edgeand 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'sPLAN_FIXES.mdD171 set out (§4.8 there), and it is on the roadmap as thewebsitemilestone Steam sign-in, issue website#214. Rust'sPLAN_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_registrationsays (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, aBaseProviderwithgetAuthorizationUrlandhandleCallbackdoing §3. It is a built-in inregistry.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 inclient_secret_enc, AES-256-GCM throughsecretBox, and is write-only like a client secret. -
After the assertion is verified,
handleCallbackcallsISteamUser/GetPlayerSummariesfor 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 ischeck_authenticationalone (§5). -
auth_providers.kindgainssteam, anduser_identitiesgains nullabledisplay_nameandavatar_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'scallbackaccepts Steam'sopenid.*fields where it now requirescode, forkind: steamonly, and passes them tohandleCallback. After that,finishLogin,finishLink,finishMobileLoginand the TOTP gate are unchanged, with two differences for Steam: an unlinked Steam id never reachesprovisionSsoPlayer(D395) and gets the ordinary "no account is linked" answer, andfinishLinkasks 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
retryAfterMsand 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.mdfor 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_authenticationcall 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
finishLinkfor 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_tomatching, acheck_authenticationreply ofis_valid:false, a replayed assertion, a wrongop_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 §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
/linkcode? 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
/linkcode 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. |