diff --git a/README.md b/README.md index fe54954..ef3a827 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ sidecar as a service, and hands you the values the website needs. | [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes | | [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework | | [TRUSTED_DEVICES_MFA.md](website/TRUSTED_DEVICES_MFA.md) | TOTP two-factor, trusted devices and recovery codes | -| [STEAM_SIGNIN.md](website/STEAM_SIGNIN.md) | **Plan** — Sign in with Steam as a built-in provider beside Google and Discord (OpenID 2.0), and a Steam id for modules | +| [STEAM_SIGNIN.md](website/STEAM_SIGNIN.md) | **Plan, built 2026-10-19** — Sign in with Steam as a built-in provider beside Google and Discord (OpenID 2.0), and a Steam id for modules | | [MODERATION_APPEALS.md](website/MODERATION_APPEALS.md) | Moderation actions, content reports and the appeals flow | | [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from the shard's own ServUO files — served over the bridge since Protocol 8, so no shared filesystem | | [CLILOCS.md](website/CLILOCS.md) | UO's id → name table, so items have names. The shard decompresses and serves it over the bridge; the desktop conversion it replaced is gone | diff --git a/modules/rust/PLAN_REDESIGNS.md b/modules/rust/PLAN_REDESIGNS.md index 997555b..70f2e54 100644 --- a/modules/rust/PLAN_REDESIGNS.md +++ b/modules/rust/PLAN_REDESIGNS.md @@ -3673,6 +3673,11 @@ What this module takes from it: - When it lands, Module-Rust's account screens and the link flow are updated in their own PR. Rust's grants already follow the website user to every linked Steam id (D28), so nothing else changes. +**Built 2026-10-19** on `edge` with the website and Android-app halves (`website/STEAM_SIGNIN.md` §8): +`checkSteamSignin` and `linkFromSteamSignin` in the links model, registered as core's +`onIdentityLinking` / `onIdentityLinked`, `coreApi ^1.17.0`. The live walk with a real Steam account is +owed with the end walk's in-game rows. + --- ## 16. Console commands as event steps and rewards (D382–D384) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index c78b91a..4389d39 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -901,6 +901,20 @@ list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `las NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model is otherwise unchanged. +### auth_providers / user_identities — SSO config and linked identities + +`auth_providers` holds one row per configured provider: `kind ENUM('google','discord','oidc','oauth2','steam')`, +`client_id`, `client_secret_enc` (AES-256-GCM via `secretBox`, write-only in the API) and, for custom +providers, the three endpoint URLs. Google, Discord and **Steam** are built-ins (rows created on first +save). Steam has no client id: its `client_secret_enc` is the **Steam Web API key**, without which +Admin → Auth refuses to enable it (`STEAM_SIGNIN.md` D394). + +`user_identities` maps `(provider, subject)` — unique — to a `user_id`; several identities per provider +per user are allowed. `display_name VARCHAR(191) NULL` and `avatar_url VARCHAR(500) NULL` (added for +Steam, additive `ALTER … ADD COLUMN IF NOT EXISTS`) hold the persona name and Steam-CDN avatar from the +Web API, written at link and refreshed at each Steam sign-in; a failed lookup leaves the last good +values. Other providers leave both null. + ### trusted_devices — MFA "Trust this device" Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical @@ -1413,7 +1427,7 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout | PATCH | `/me/account/email` | cookie / bearer (rate-limited, **password step-up**) | `{email, currentPassword?}` | request an address. **Stages it in `email_pending`; `email` is untouched**, so the account keeps receiving password-reset mail at the address it already has until the emailed link is opened — a typo cannot redirect account recovery. `currentPassword` is required when the account has one (an address is where recovery lands); an SSO-provisioned account with a null hash is exempt, the same carve-out `/me/account/password` makes. Returns `{email_pending, emailed, reason}` — `emailed:false` is reported honestly rather than pretending, because the caller typed this address themselves and there is no enumeration reason to hide it. **429** past the per-user send ceiling | | POST | `/me/account/email/resend` | cookie / bearer (rate-limited) | — | re-send the link for the staged address; **400** when nothing is pending | | DELETE | `/me/account/email/pending` | cookie / bearer | — | abandon the staged address **and retire its outstanding links**, so a confirmation email already delivered can no longer install it | -| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities | +| GET | `/me/account/identities` · DELETE `…/:provider[?id=]` | cookie / bearer | — | list / unlink own SSO identities. Each row carries `id`, `provider`, `subject` (the user's own external id; for Steam the SteamID64), `email`, and for Steam `display_name` + `avatar_url`. DELETE removes every identity of the provider, or with `?id=` that one (a person may link several Steam accounts). Only the sign-in goes: a game module's own link made from it stays (`STEAM_SIGNIN.md` D393) | | GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) | | POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap | | DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) | @@ -1533,7 +1547,7 @@ tab it does not control, and without a trust token ever appearing in a start URL |---|---|---|---|---| | GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) | | GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) | -| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** | +| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` (Steam: `?state&openid.*`) | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** | | POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}`. When the session carries `trust_device`, also mint a `platform:'mobile'` trusted device and add `trustToken` — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in | | POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair | | POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) | @@ -1800,6 +1814,15 @@ who"; `activity_log` provides the history feed. - **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`). - **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too. - **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned. +- **SSO is link-only**, through the one session service. **Steam** (`STEAM_SIGNIN.md`) is OpenID 2.0: + the callback's `openid.*` fields are proved only by a server-to-server `check_authentication` POST to + Steam (`is_valid:true`, never skipped; a transport failure refuses), after `op_endpoint`, an exact + `return_to` (our callback plus the tx `state`, so a captured assertion cannot be replayed into another + flow) and a 17-digit claimed id have been checked. The Web API key is used only for the persona name + and avatar, never as proof, and never leaves the server. **Steam never signs anybody up**, whatever + `player_registration` says (D395), on the web and the mobile bridge alike. A module may refuse a link + before it is written (`onIdentityLinking`, MODULE_API 1.17.0); the refusal fails closed and its text is + logged, never reflected into the redirect (`link_error=in_use|refused` only). - **Rate limiting** (`express-rate-limit`) on `/auth/login`, `/public/contact`, and the account-change routes. Every core public read is an indexed lookup of bounded size, so none is limited. A module gets the same factory through `ctx.middleware.rateLimit` and is expected to use it on any read that diff --git a/website/MODULE_API.md b/website/MODULE_API.md index b50bf54..24b111c 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -26,13 +26,37 @@ here extends the contract first, in this file, before the module is written agai Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js -const MODULE_API_VERSION = '1.16.0' +const MODULE_API_VERSION = '1.17.0' ``` The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first module chunk evaluates, which is earlier than any network round trip could answer. +**1.17.0 — a module may take part in linking a sign-in identity** (`website/STEAM_SIGNIN.md` §4; Rust +`modules/rust/PLAN_REDESIGNS.md` §15, D392 and D396). Three optional additions and no removal, so +minor. Linking stays core's: a module can read what core verified, refuse a link, and hear one, and +nothing more. + +- **`ctx.identities.list(userId, provider)`** → `[{ provider, subject, displayName, avatarUrl, + linkedAt }]`: the identities a user has linked for one provider, as core verified them (a Steam id + proved by Steam's own `check_authentication`). Read only; no row id, no email. +- **`api.onIdentityLinking(fn)`**: `fn({ userId, provider, subject })`, called **before** an identity is + written, for every provider, by a user who is already signed in. Answer `{ ok: false, reason }` to + refuse; anything else lets the link through. `reason: 'in_use'` reads to the person as *"already linked + to another user on this site"* and any other reason as a plain refusal. A `message` is logged, **never + shown**: module text in a redirect would let anybody craft a link that prints a sentence of their + choosing on the account page. **It fails closed**: a hook that throws or does not answer in 5 s + refuses the link. It can only refuse, and it never touches sign-in or a session. +- **`api.onIdentityLinked(fn)`**: `fn({ userId, provider, subject, displayName })`, called **after** the + identity is written. Awaited for at most 5 s so the next page already shows what the module made of + it; a failure is logged and never undoes the link. +- **No unlink hook.** Unlinking a sign-in identity removes only the sign-in (D393): a module's own + record made from it is the module's to remove. +- Both hooks reach **`started`** modules only, in registration order, and a module may register each + once. Module-uo's `^1.10.0` still resolves and registers neither: a link is checked and written exactly + as before. + **1.16.0 — a dry run carries a module's advice** (`website/EVENTS.md` §I; Rust `modules/rust/PLAN_REDESIGNS.md` §11.4, D391). One optional reading of an existing member and no removal, so minor. @@ -487,6 +511,11 @@ branches lacks — so it is the only place `permits` is true between two values between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise unchanged, and so is every rule about composition narrowing rather than widening. +**1.17.0 — identity links** (`website/STEAM_SIGNIN.md` §4; Rust PLAN_REDESIGNS §15, D392, D396). +`ctx.identities.list`, and `api.onIdentityLinking` (refuse-only, fails closed) and +`api.onIdentityLinked`; three optional additions, so minor; see §1.1. Module-uo's `^1.10.0` still +resolves. + **1.16.0 — a dry run's `detail.warnings`** (`website/EVENTS.md` §I; Rust PLAN_REDESIGNS §11.4, D391). A module's advice on a step that would run, shown as warning findings that never fail the dry run; one optional reading, so minor; see §1.1. Module-uo's `^1.10.0` still resolves. @@ -798,6 +827,7 @@ module-uo does not need is on the list. | `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (1.7.0) | | `ctx.events.expired` | `({ kind, ref }) => void`, fire-and-forget, owner bound | `events/cleanup` `expireResource` | `module-rust`'s ingest of `world.expired` (1.11.0) | | `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) | +| `ctx.identities.list` | `(userId, provider) => Promise<[{ provider, subject, displayName, avatarUrl, linkedAt }]>`, read only | `model/userIdentities` | Steam sign-in (1.17.0) | **`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with `api.registerEventTriggers` (§2.4). It is the push half of the engagement seam @@ -925,6 +955,8 @@ api.registerEventOptionSources([{ id, label, searchable, resolve }]) // api.registerEventTemplates([{ id, label, description, inputs, build }]) // 1.13.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) +api.onIdentityLinking(async ({ userId, provider, subject }) => ({ ok: true })) // 1.17.0 +api.onIdentityLinked(async ({ userId, provider, subject, displayName }) => {}) // 1.17.0 ``` **Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's @@ -1393,6 +1425,30 @@ with the module named and the run degrades: never a silent skip. **`onBoot(fn)` / `onShutdown(fn)`** — §2.5. +**`onIdentityLinking(fn)` / `onIdentityLinked(fn)`** (1.17.0) — a module's part in linking an external +sign-in identity (`STEAM_SIGNIN.md` §4). Core calls them from `finishLink`, for a user who is already +signed in, for every provider; a module filters by `provider` itself. + +```js +api.onIdentityLinking(async ({ userId, provider, subject }) => { + if (provider !== 'steam') return { ok: true } + const held = await linksDb.getBySteamId(subject) + return held && held.userId !== userId ? { ok: false, reason: 'in_use' } : { ok: true } +}) +api.onIdentityLinked(async ({ userId, provider, subject, displayName }) => { /* make the game link */ }) +``` + +- **Linking** answers `{ ok: false, reason, message? }` to refuse; nothing is written. `reason: 'in_use'` + is shown as core's own "already linked to another user"; anything else as a plain refusal; `message` + goes to core's log and never to the page. A throw or a 5 s silence **refuses** (fails closed). The + first refusal stops the round; later modules are not asked. +- **Linked** is told once the identity row exists. It is awaited (5 s bound) so the page the person lands + on reflects it, but its failure is logged and the link stands. Expect a race: a module's own write may + find the record already taken between the check and this call, and must treat that as "theirs stands". +- Neither hook can mint a session, change who is signed in, or let through a link core refuses (an + identity already linked to another account is refused by core before any module is asked). There is + no unlink hook (D393). + ### 2.5 Lifecycle ``` diff --git a/website/STEAM_SIGNIN.md b/website/STEAM_SIGNIN.md index deb97ed..bbc871f 100644 --- a/website/STEAM_SIGNIN.md +++ b/website/STEAM_SIGNIN.md @@ -1,7 +1,8 @@ # 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 `edge` and ships in the cutover in progress (the org lead, 2026-10-19). +> **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 @@ -142,3 +143,31 @@ The org lead answered Q1–Q4 and ruled on the case Q1 and Q2 raised together. T | **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.