# 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.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 policy**: link-only, with opt-in sign-up when `player_registration` allows 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/`) | | 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`, 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. - `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.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_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). - 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. - 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 `/link` code? **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.