- MODULE_API.md: 1.15.0, the failure envelope's retryAfterMs (clamped to one to thirty minutes, one attempt, ignored beside retry: false), the rule bullet and the registration line. - EVENTS.md §F: the envelope rule and the flat delay as a default. - STEAM_SIGNIN.md: its module contract moves to 1.16.0. - PLAN_REDESIGNS: §14.3.15, the stage-2 record (six settled points, the test-harness gap found on the way), and pointers from §9's summary and §14.3.13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
8.0 KiB
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. It is the website's own plan for Steam sign-in, as Rust's
PLAN_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 policy: link-only, with opt-in sign-up when
player_registrationallows SSO. - 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. No name either, unless the site holds a Steam Web API key (Q3) |
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' }), and its config validates with no client id or secret.auth_providers.kindgainssteam. The schema change is additive.sso.controller.js'scallbackaccepts Steam'sopenid.*fields where it now requirescode, forkind: steamonly, and passes them tohandleCallback. Everything afterhandleCallbackis 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.16.0 (additions only; 1.15.0 was taken by a step's
retryAfterMs, 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.
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.
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.
- The
check_authenticationcall 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).
- 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. - Flow: login, link and mobile modes against a stubbed Steam endpoint, including the TOTP gate and provisioning.
- 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.
7. Questions for the org lead
- 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. Steam's own sign-in proves the account at least as well as the code does, and both routes stay. - 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.
- 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. - 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.