From 5e398d8acdce90cb003a023e5520d6329624aecb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 19 Oct 2026 14:02:49 -0500 Subject: [PATCH 1/2] feat(auth): sign in with Steam (MODULE_API 1.17.0) Steam is a built-in provider beside Google and Discord (STEAM_SIGNIN.md, D392-D396). OpenID 2.0: the assertion is verified server to server with check_authentication, return_to must match byte for byte, and the claimed id must be a 17-digit SteamID64. A Steam Web API key is required to enable it (D394); persona name and avatar are stored on the identity and refreshed at each sign-in, and a failed lookup never blocks one. Steam never signs anybody up (D395), on the web or the mobile bridge. Several Steam accounts may be linked; DELETE /auth/me/account/identities/:provider takes ?id= to unlink one. MODULE_API 1.17.0: ctx.identities.list, api.onIdentityLinking (refuse-only, fails closed, module text never shown) and api.onIdentityLinked. No unlink hook (D393). Schema additive; OpenAPI, routes and engagement manifests regenerated with no modules installed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/api/client.js | 8 +- client/src/components/ProviderIcon.jsx | 13 +- .../components/security/linkedIdentities.jsx | 66 +++ client/src/modules/version.js | 6 +- .../src/routes/admin/views/AccountAdmin.jsx | 33 +- .../routes/admin/views/AuthProvidersAdmin.jsx | 86 +++- client/src/routes/player/PlayerAccount.jsx | 33 +- server/db/schema.sql | 13 +- server/engagement-triggers.json | 2 +- server/routes.guards.json | 2 +- server/src/auth/identityHooks.js | 97 ++++ server/src/auth/providers/registry.js | 26 +- server/src/auth/providers/steam.provider.js | 172 +++++++ .../model/userIdentities/userIdentities.db.js | 38 +- .../userIdentities/userIdentities.model.js | 20 +- server/src/modules/loader.js | 52 ++- server/src/modules/version.js | 17 +- .../v1/admin/authProviders.controller.js | 14 +- .../router/v1/admin/authProviders.router.js | 3 +- .../src/router/v1/auth/account.controller.js | 24 +- server/src/router/v1/auth/me.routes.js | 6 +- server/src/router/v1/auth/sso.controller.js | 111 ++++- server/src/router/v1/auth/sso.routes.js | 11 +- server/swagger/swagger-output.json | 161 ++++++- server/swagger/swagger.js | 12 +- server/test/eventModuleContract.test.js | 2 +- server/test/moduleLoader.test.js | 5 +- server/test/registry.test.js | 4 +- server/test/ssoCallback.test.js | 2 +- server/test/steamSignin.test.js | 433 ++++++++++++++++++ 30 files changed, 1346 insertions(+), 126 deletions(-) create mode 100644 client/src/components/security/linkedIdentities.jsx create mode 100644 server/src/auth/identityHooks.js create mode 100644 server/src/auth/providers/steam.provider.js create mode 100644 server/test/steamSignin.test.js diff --git a/client/src/api/client.js b/client/src/api/client.js index 536d068..279a35c 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -133,8 +133,12 @@ export const api = { totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }), // Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link. myIdentities: () => req('/auth/me/account/identities'), - unlinkIdentity: (provider) => - req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }), + // With an identity id, only that one goes (a person may hold several Steam accounts). + unlinkIdentity: (provider, id) => + req( + `/auth/me/account/identities/${encodeURIComponent(provider)}${id != null ? `?id=${encodeURIComponent(id)}` : ''}`, + { method: 'DELETE' }, + ), // Recovery (backup) codes. status → remaining count; generate → a fresh set, // returned ONCE (password step-up for accounts that have a password). recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'), diff --git a/client/src/components/ProviderIcon.jsx b/client/src/components/ProviderIcon.jsx index 5b8cd57..f99ba86 100644 --- a/client/src/components/ProviderIcon.jsx +++ b/client/src/components/ProviderIcon.jsx @@ -1,6 +1,6 @@ // Inline SVG brand icons for SSO providers. No binary assets — these scale // crisply at any size and keep their own brand colors. `icon` matches the -// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2). +// provider `kind` from the discovery endpoint ('google' | 'discord' | 'steam' | oidc/oauth2). // Anything unknown falls back to a neutral key glyph in the current text color. function GoogleMark({ size }) { @@ -22,6 +22,16 @@ function DiscordMark({ size }) { ) } +// Steam's mark in the current text color: its brand ink is near-black, which +// would vanish on the dark theme. +function SteamMark({ size }) { + return ( + + ) +} + function GenericMark({ size }) { return ( + {isSteam && identity.avatar_url ? ( + + ) : ( + + )} + +
+
+ {isSteam && identity.display_name ? `${providerName}: ${identity.display_name}` : providerName} +
+ {isSteam && identity.subject &&
{identity.subject}
} + {!isSteam && identity.email &&
{identity.email}
} +
+ + ) +} diff --git a/client/src/modules/version.js b/client/src/modules/version.js index aea1ec1..a4665bd 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,10 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md +// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`. +// Server-side; the client half is core's own account pages showing Steam. This +// file bumps for the reason at the top. // 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a // module's advice, shown by core's own event editor as warning findings. // Server-side; this file bumps for the reason at the top. @@ -94,4 +98,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.16.0' +export const MODULE_API_VERSION = '1.17.0' diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx index 4c4ad6e..0745ce7 100644 --- a/client/src/routes/admin/views/AccountAdmin.jsx +++ b/client/src/routes/admin/views/AccountAdmin.jsx @@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx' import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx' import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx' +import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx' import { api } from '../../../api/client.js' // Link/unlink external SSO identities to this account. Linking redirects through @@ -15,13 +16,7 @@ function LinkedAccounts() { const [available, setAvailable] = useState([]) const [error, setError] = useState('') - const banner = (() => { - const q = new URLSearchParams(window.location.search) - if (q.get('linked')) return { ok: true, text: 'Account linked.' } - if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' } - if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' } - return null - })() + const banner = linkBanner(window.location.search) const load = useCallback(async () => { try { @@ -40,12 +35,11 @@ function LinkedAccounts() { }, [load]) const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1) - const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc') - async function unlink(provider) { - if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return + async function unlink(identity) { + if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return try { - await api.unlinkIdentity(provider) + await api.unlinkIdentity(identity.provider, identity.id) await load() } catch (err) { setError(err.message || 'Could not unlink.') @@ -55,8 +49,7 @@ function LinkedAccounts() { if (error) return if (!linked) return null - const linkedIds = new Set(linked.map((i) => i.provider)) - const linkable = available.filter((p) => !linkedIds.has(p.id)) + const linkable = linkableProviders(available, linked) return (
@@ -64,7 +57,7 @@ function LinkedAccounts() { Linked accounts

- Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in + Link a Google, Discord, Steam, or other SSO account so you can sign in with it. SSO can only sign in to an account it is linked to — linking here is what grants that access.

@@ -77,15 +70,9 @@ function LinkedAccounts() { {linked.length > 0 && (
{linked.map((i) => ( -
- - - -
-
{nameFor(i.provider)}
- {i.email &&
{i.email}
} -
-
diff --git a/client/src/routes/admin/views/AuthProvidersAdmin.jsx b/client/src/routes/admin/views/AuthProvidersAdmin.jsx index 4bf852f..27711e1 100644 --- a/client/src/routes/admin/views/AuthProvidersAdmin.jsx +++ b/client/src/routes/admin/views/AuthProvidersAdmin.jsx @@ -5,12 +5,14 @@ import { api } from '../../../api/client.js' // Admin config for authentication providers. Local password + TOTP is always on // (informational tab). Google/Discord are built-ins with a fixed config surface -// (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor. +// (Enabled + Client ID + Client Secret); Steam is a built-in with Enabled + a +// Steam Web API key. Custom providers use the full OIDC editor. const TABS = [ { id: 'local', label: 'Local Accounts' }, { id: 'google', label: 'Google' }, { id: 'discord', label: 'Discord' }, + { id: 'steam', label: 'Steam' }, { id: 'custom', label: 'Custom Providers' }, ] @@ -141,6 +143,87 @@ function BuiltinForm({ provider, onSaved }) { ) } +// ── Steam (OpenID 2.0) ───────────────────────────────────────────────────── +// No client id and no redirect URL to register: Steam's sign-in takes neither. +// What it needs is a Steam Web API key, without which it cannot be enabled +// (STEAM_SIGNIN.md D394) — the key is how a linked account shows its persona +// name and avatar. Steam never creates an account (D395): a player links Steam +// to the account they already have. +function SteamForm({ provider, onSaved }) { + const [enabled, setEnabled] = useState(provider.enabled) + const [apiKey, setApiKey] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [error, setError] = useState('') + + async function save() { + setBusy(true) + setMsg('') + setError('') + try { + const body = { enabled } + if (apiKey) body.secret = apiKey // only send a new key when entered + await api.admin.updateAuthProvider(provider.id, body) + setApiKey('') + setMsg('Saved.') + await onSaved() + } catch (err) { + setError(err.message || 'Could not save.') + } finally { + setBusy(false) + } + } + + return ( +
+
+ + + +

+ {provider.name} +

+
+ +

+ Players link their Steam account from their account page and can then sign in with it. Steam never + creates an account: an unlinked Steam account signs nobody in, whatever the registration setting. +

+ + + + + +

+ Get one at{' '} + + steamcommunity.com/dev/apikey + {' '} + with this site's domain. It is used only to show a linked account's Steam name and avatar, and is + never sent to a browser. No redirect URL needs registering with Steam. +

+ +
+ + {msg && {msg}} + {error && {error}} +
+
+ ) +} + // ── Local accounts (informational) ───────────────────────────────────────── function LocalInfo() { return ( @@ -376,6 +459,7 @@ export default function AuthProvidersAdmin() { {tab === 'local' && } {tab === 'google' && } {tab === 'discord' && } + {tab === 'steam' && byId('steam') && } {tab === 'custom' && } ) diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx index d03645b..8817414 100644 --- a/client/src/routes/player/PlayerAccount.jsx +++ b/client/src/routes/player/PlayerAccount.jsx @@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx' import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx' import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx' +import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx' import { useAuth } from '../../contexts/AuthContext.jsx' import { api } from '../../api/client.js' @@ -224,13 +225,7 @@ function LinkedAccounts() { const [available, setAvailable] = useState([]) const [error, setError] = useState('') - const banner = (() => { - const q = new URLSearchParams(window.location.search) - if (q.get('linked')) return { ok: true, text: 'Account linked.' } - if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' } - if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' } - return null - })() + const banner = linkBanner(window.location.search) const load = useCallback(async () => { try { @@ -247,12 +242,11 @@ function LinkedAccounts() { useEffect(() => { load() }, [load]) const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1) - const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc') - async function unlink(provider) { - if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return + async function unlink(identity) { + if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return try { - await api.unlinkIdentity(provider) + await api.unlinkIdentity(identity.provider, identity.id) await load() } catch (err) { setError(err.message || 'Could not unlink.') @@ -262,13 +256,12 @@ function LinkedAccounts() { if (error) return if (!linked) return null - const linkedIds = new Set(linked.map((i) => i.provider)) - const linkable = available.filter((p) => !linkedIds.has(p.id)) + const linkable = linkableProviders(available, linked) return (

- Link a Google, Discord, or other provider so you can sign in with it. + Link a Google, Discord, Steam, or other provider so you can sign in with it.

{banner && (

{banner.text}

@@ -276,15 +269,9 @@ function LinkedAccounts() { {linked.length > 0 && (
{linked.map((i) => ( -
- - - -
-
{nameFor(i.provider)}
- {i.email &&
{i.email}
} -
- +
+ +
))}
diff --git a/server/db/schema.sql b/server/db/schema.sql index 2253c61..d5a281f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -179,7 +179,7 @@ CREATE TABLE IF NOT EXISTS activity_log ( -- custom (oidc/oauth2) providers. CREATE TABLE IF NOT EXISTS auth_providers ( id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug - kind ENUM('google','discord','oidc','oauth2') NOT NULL, + kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL, name VARCHAR(80) NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 0, client_id VARCHAR(255) NULL, @@ -203,6 +203,8 @@ CREATE TABLE IF NOT EXISTS user_identities ( provider VARCHAR(64) NOT NULL, -- matches auth_providers.id subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id) email VARCHAR(255) NULL, + display_name VARCHAR(191) NULL, -- Steam persona name (D394); null for other providers + avatar_url VARCHAR(500) NULL, -- Steam avatar, a Steam CDN https URL; null for others created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_identity_provider_subject (provider, subject), @@ -2744,3 +2746,12 @@ CREATE TABLE IF NOT EXISTS event_trigger_starts ( updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Sign in with Steam (STEAM_SIGNIN.md §4). Steam is a built-in provider beside +-- Google and Discord, so its row needs the kind; and a linked Steam account +-- shows its persona name and avatar (D394), kept on the identity and refreshed +-- at each Steam sign-in. Both additive. MODIFY has no IF NOT EXISTS form, but +-- re-declaring the same ENUM is an idempotent no-op. +ALTER TABLE auth_providers MODIFY COLUMN kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL; +ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS display_name VARCHAR(191) NULL; +ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500) NULL; diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index cecb171..b4f945a 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", - "moduleApiVersion": "1.16.0", + "moduleApiVersion": "1.17.0", "triggers": [ { "id": "event.phase.changed", diff --git a/server/routes.guards.json b/server/routes.guards.json index 6884d82..0c23f9e 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -1834,7 +1834,7 @@ { "method": "DELETE", "path": "/api/v1/auth/me/account/identities/:provider", - "handlers": 3, + "handlers": 4, "gates": [ "noindex", "requireAuth", diff --git a/server/src/auth/identityHooks.js b/server/src/auth/identityHooks.js new file mode 100644 index 0000000..168d998 --- /dev/null +++ b/server/src/auth/identityHooks.js @@ -0,0 +1,97 @@ +// ── Modules and identity links (MODULE_API 1.17.0) ───────────────────────── +// +// Core owns linking an external sign-in identity to an account. Modules take part +// in exactly two places (STEAM_SIGNIN.md §4): +// +// checkLink() before the identity is written. Each module that registered +// `onIdentityLinking` may refuse. Rust uses it for D396: a Steam +// id that is already another website account's Rust link. +// notifyLinked() after it is written. Rust uses it for D392: the Steam account +// becomes a linked Rust player. +// +// A refusal can only stop a link. It runs inside the link flow of a user who is +// already signed in, so nothing here can mint a session or let through a link +// core would refuse. +// +// What a module says is never shown to the player: `reason: 'in_use'` reads as +// the same "already linked to another user" core says itself, and anything else +// as a plain refusal. A module's own words go to the log. Putting module text in +// a redirect URL would let anybody craft a link that prints a sentence of their +// choosing on our account page. + +const loader = require('../modules/loader') +const log = require('../utils/logger')('sso') + +const HOOK_TIMEOUT_MS = 5_000 + +function withTimeout(fn, ms) { + let timer + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms) + }) + return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer)) +} + +/** + * Ask every started module whether this link may be made. + * + * **Fails closed.** A module that throws or does not answer in time refuses the + * link: the hook exists to stop a link that must not happen, and a module that + * could not look is a module that could not say it was safe. The player is told + * to try again; a link is never the urgent half of anything. + * + * @param {{userId: number, provider: string, subject: string}} link + * @returns {Promise<{ok: true} | {ok: false, reason: 'in_use' | 'refused', module: string}>} + */ +async function checkLink(link) { + const frozen = Object.freeze({ userId: link.userId, provider: link.provider, subject: link.subject }) + for (const { id, hook } of loader.identityHooks('onIdentityLinking')) { + let answer + try { + answer = await withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS) + } catch (err) { + log.error('identity link check failed; refusing the link', { module: id, provider: frozen.provider, message: err.message }) + return { ok: false, reason: 'refused', module: id } + } + if (answer && answer.ok === false) { + const reason = answer.reason === 'in_use' ? 'in_use' : 'refused' + log.info('identity link refused by a module', { + module: id, + provider: frozen.provider, + userId: frozen.userId, + reason, + message: typeof answer.message === 'string' ? answer.message.slice(0, 200) : undefined, + }) + return { ok: false, reason, module: id } + } + } + return { ok: true } +} + +/** + * Tell every started module a link was written. + * + * Awaited (bounded) so the page the player lands on already shows what a module + * made of it — the Rust page listing the Steam account they just linked — but a + * failure never reaches the link: the identity is written, and that is core's + * answer. A module that missed it is logged. + * + * @param {{userId: number, provider: string, subject: string, displayName?: string|null}} link + */ +async function notifyLinked(link) { + const frozen = Object.freeze({ + userId: link.userId, + provider: link.provider, + subject: link.subject, + displayName: link.displayName || null, + }) + await Promise.all( + loader.identityHooks('onIdentityLinked').map(({ id, hook }) => + withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS).catch((err) => { + log.error('identity linked hook failed', { module: id, provider: frozen.provider, message: err.message }) + }), + ), + ) +} + +module.exports = { checkLink, notifyLinked, HOOK_TIMEOUT_MS } diff --git a/server/src/auth/providers/registry.js b/server/src/auth/providers/registry.js index f150596..1530eac 100644 --- a/server/src/auth/providers/registry.js +++ b/server/src/auth/providers/registry.js @@ -12,6 +12,7 @@ const GoogleProvider = require('./google.provider') const DiscordProvider = require('./discord.provider') const GenericOidcProvider = require('./genericOidc.provider') +const SteamProvider = require('./steam.provider') const authProviders = require('../../model/authProviders/authProviders.model') // kind → provider class. @@ -20,6 +21,7 @@ const KINDS = { discord: DiscordProvider, oidc: GenericOidcProvider, oauth2: GenericOidcProvider, + steam: SteamProvider, } // Built-in providers and their fixed display metadata. Endpoints are in the @@ -27,6 +29,9 @@ const KINDS = { const BUILTINS = [ { id: 'google', kind: 'google', name: 'Google', priority: 1 }, { id: 'discord', kind: 'discord', name: 'Discord', priority: 2 }, + // OpenID 2.0, not OAuth2 (STEAM_SIGNIN.md §3): no client id, and the "secret" + // is the Steam Web API key it cannot be enabled without (D394). + { id: 'steam', kind: 'steam', name: 'Steam', priority: 3 }, ] const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id)) @@ -35,6 +40,18 @@ function isBuiltin(id) { return BUILTIN_IDS.has(id) } +// Providers whose callback is OpenID 2.0 (`openid.*` fields) rather than an OAuth2 +// `code`. Steam is the only one, and only as the built-in: a custom provider can +// only be oidc/oauth2, so the kind is known from the id before any row is read. +function isOpenId(id) { + return id === 'steam' +} + +// Providers that never sign anybody up, whatever player_registration says (D395). +function neverProvisions(kind) { + return kind === 'steam' +} + // Instantiate a provider from a config row (secret already decrypted by the // model as `client_secret`). Returns null for an unknown kind. function instantiate(row) { @@ -66,6 +83,11 @@ async function load(id) { // custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }. function validateConfig(row) { const missing = [] + if (row.kind === 'steam') { + // No client id; the Web API key is the one thing it needs (D394). + if (!row.client_secret_enc && !row.client_secret) missing.push('api_key') + return { valid: missing.length === 0, missing } + } if (!row.client_id) missing.push('client_id') // A stored secret shows up as client_secret_enc on plain rows, or client_secret // on decrypted rows — accept either as "has a secret". @@ -110,7 +132,7 @@ async function listEnabledValid() { .map((p) => ({ id: p.id, name: p.name, - icon: p.kind, // 'google' | 'discord' | 'oidc' | 'oauth2' + icon: p.kind, // 'google' | 'discord' | 'steam' | 'oidc' | 'oauth2' loginUrl: `/api/v1/auth/sso/${p.id}/start`, priority: p.priority ?? 100, })) @@ -120,6 +142,8 @@ module.exports = { KINDS, BUILTINS, isBuiltin, + isOpenId, + neverProvisions, instantiate, load, validateConfig, diff --git a/server/src/auth/providers/steam.provider.js b/server/src/auth/providers/steam.provider.js new file mode 100644 index 0000000..323b235 --- /dev/null +++ b/server/src/auth/providers/steam.provider.js @@ -0,0 +1,172 @@ +// Built-in Steam provider (OpenID 2.0, STEAM_SIGNIN.md §3). +// +// Steam is not OAuth2: there is no client id, no token endpoint and no `state` +// parameter. The browser goes to steamcommunity.com with an OpenID 2.0 +// `checkid_setup` request and comes back carrying `openid.*` fields, and the ONLY +// proof those fields are genuine is asking Steam itself — a server-to-server +// `check_authentication` POST of the same fields, which must answer +// `is_valid:true`. Our CSRF `state` rides inside `return_to`, where the existing +// single-use tx cookie verifies it exactly as it does for the OAuth providers. +// +// The admin-configured "secret" is a Steam Web API key (D394), used for one thing: +// the persona name and avatar shown beside a linked Steam account. That lookup +// decorates an identity and never decides a sign-in — if it fails, the sign-in +// goes ahead with the bare id. + +const BaseProvider = require('./base.provider') +const log = require('../../utils/logger')('sso') + +const OP_ENDPOINT = 'https://steamcommunity.com/openid/login' +const OPENID_NS = 'http://specs.openid.net/auth/2.0' +const IDENTIFIER_SELECT = 'http://specs.openid.net/auth/2.0/identifier_select' +const CLAIMED_ID_RE = /^https:\/\/steamcommunity\.com\/openid\/id\/(\d{17})$/ +const SUMMARIES_URL = 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/' +// Steam serves avatars from its own CDNs. Anything else in that field is not +// something we render. +const AVATAR_RE = /^https:\/\/([a-z0-9-]+\.)*(steamstatic\.com|akamaihd\.net)\/[^\s"'<>]{1,400}$/i + +const VERIFY_TIMEOUT_MS = 10_000 +const SUMMARY_TIMEOUT_MS = 5_000 + +// A failure the controller turns into a sentence rather than a 500. +class SteamAssertionError extends Error { + constructor(reason) { + super(`steam assertion refused: ${reason}`) + this.reason = reason + } +} + +// The 17-digit SteamID64 at the end of a claimed id, or null. +function parseClaimedId(claimedId) { + const m = typeof claimedId === 'string' ? CLAIMED_ID_RE.exec(claimedId) : null + return m ? m[1] : null +} + +// The `return_to` we send, and must get back byte for byte: the callback URL with +// our tx nonce as `state`. +function returnToFor(redirectUri, state) { + const sep = redirectUri.includes('?') ? '&' : '?' + return `${redirectUri}${sep}state=${encodeURIComponent(state)}` +} + +// The realm is the origin the return_to lives under — what Steam shows the user +// as "you are signing in to". +function realmFor(redirectUri) { + return new URL(redirectUri).origin +} + +// Every `openid.*` field from a parsed query, as strings. Anything else is ours +// (`state`) and does not go back to Steam. +function openidFields(query = {}) { + const out = {} + for (const [k, v] of Object.entries(query)) { + if (k.startsWith('openid.') && typeof v === 'string') out[k] = v + } + return out +} + +class SteamProvider extends BaseProvider { + constructor(config = {}) { + super({ kind: 'steam', name: 'Steam', ...config, id: config.id || 'steam' }) + // Stored in client_secret_enc like any secret; it is a Web API key here. + this.apiKey = config.clientSecret ?? config.client_secret ?? null + } + + getAuthorizationUrl(state, { redirectUri } = {}) { + const params = new URLSearchParams({ + 'openid.ns': OPENID_NS, + 'openid.mode': 'checkid_setup', + 'openid.return_to': returnToFor(redirectUri, state), + 'openid.realm': realmFor(redirectUri), + 'openid.identity': IDENTIFIER_SELECT, + 'openid.claimed_id': IDENTIFIER_SELECT, + }) + return `${OP_ENDPOINT}?${params.toString()}` + } + + // `query` is the callback's parsed query string; `redirectUri` and `state` are + // what we sent, so `return_to` can be matched exactly. Resolves to the usual + // { subject, email, emailVerified, name } plus displayName/avatarUrl, or throws + // SteamAssertionError. + async handleCallback({ query, redirectUri, state } = {}) { + const fields = openidFields(query) + if (fields['openid.mode'] !== 'id_res') throw new SteamAssertionError('mode') + if (fields['openid.ns'] !== OPENID_NS) throw new SteamAssertionError('ns') + if (fields['openid.op_endpoint'] !== OP_ENDPOINT) throw new SteamAssertionError('op_endpoint') + if (fields['openid.return_to'] !== returnToFor(redirectUri, state)) throw new SteamAssertionError('return_to') + const subject = parseClaimedId(fields['openid.claimed_id']) + if (!subject) throw new SteamAssertionError('claimed_id') + if (fields['openid.identity'] !== fields['openid.claimed_id']) throw new SteamAssertionError('identity') + + if (!(await this.verifyAssertion(fields))) throw new SteamAssertionError('not_valid') + + const summary = await this.playerSummary(subject) + return { + subject, + email: null, // Steam never sends one + emailVerified: false, + name: summary.displayName, + displayName: summary.displayName, + avatarUrl: summary.avatarUrl, + } + } + + // The one proof (§5): send the same fields back with mode check_authentication. + // Never skipped; any transport failure is a refusal, not a pass. + async verifyAssertion(fields) { + const body = new URLSearchParams({ ...fields, 'openid.mode': 'check_authentication' }) + let text + try { + const res = await fetch(OP_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS), + }) + if (!res.ok) { + log.warn('steam check_authentication failed', { status: res.status }) + return false + } + text = await res.text() + } catch (err) { + log.warn('steam check_authentication unreachable', { message: err.message }) + return false + } + return /^is_valid:true$/m.test(text) + } + + // Persona name + avatar through the Web API key. Decoration only: every failure + // answers nulls and the sign-in carries on (D394). The key rides in the URL, so + // the URL is never logged. + async playerSummary(steamId) { + const none = { displayName: null, avatarUrl: null } + if (!this.apiKey) return none + try { + const url = `${SUMMARIES_URL}?key=${encodeURIComponent(this.apiKey)}&steamids=${steamId}` + const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(SUMMARY_TIMEOUT_MS) }) + if (!res.ok) { + log.warn('steam player summary failed; signing in with the bare id', { status: res.status }) + return none + } + const data = await res.json() + const player = data && data.response && Array.isArray(data.response.players) ? data.response.players[0] : null + if (!player || player.steamid !== steamId) return none + const name = typeof player.personaname === 'string' ? player.personaname.trim().slice(0, 191) : '' + const avatar = player.avatarfull || player.avatarmedium || player.avatar + return { + displayName: name || null, + avatarUrl: typeof avatar === 'string' && AVATAR_RE.test(avatar) ? avatar : null, + } + } catch (err) { + log.warn('steam player summary unreachable; signing in with the bare id', { message: err.message }) + return none + } + } +} + +SteamProvider.SteamAssertionError = SteamAssertionError +SteamProvider.parseClaimedId = parseClaimedId +SteamProvider.returnToFor = returnToFor +SteamProvider.OP_ENDPOINT = OP_ENDPOINT + +module.exports = SteamProvider diff --git a/server/src/model/userIdentities/userIdentities.db.js b/server/src/model/userIdentities/userIdentities.db.js index b24b8cc..9ebcb4f 100644 --- a/server/src/model/userIdentities/userIdentities.db.js +++ b/server/src/model/userIdentities/userIdentities.db.js @@ -13,21 +13,45 @@ async function findByProviderSubject(provider, subject) { // All identities linked to a given internal user (for the Account page). async function listForUser(userId) { return query( - 'SELECT id, provider, subject, email, created_at FROM user_identities WHERE user_id = ? ORDER BY provider', + `SELECT id, provider, subject, email, display_name, avatar_url, created_at + FROM user_identities WHERE user_id = ? ORDER BY provider, created_at`, [userId], ) } -async function insert({ userId, provider, subject, email = null }) { +async function insert({ userId, provider, subject, email = null, displayName = null, avatarUrl = null }) { const res = await query( - 'INSERT INTO user_identities (user_id, provider, subject, email) VALUES (?, ?, ?, ?)', - [userId, provider, subject, email], + `INSERT INTO user_identities (user_id, provider, subject, email, display_name, avatar_url) + VALUES (?, ?, ?, ?, ?, ?)`, + [userId, provider, subject, email, displayName, avatarUrl], ) return res.insertId } -// Remove a user's link to a provider. Returns rows deleted. -async function deleteForUserProvider(userId, provider) { +// Refresh the name and avatar a provider reported at sign-in (Steam, D394). Only +// what the provider sent is written: a lookup that failed leaves the last good +// values in place rather than blanking them. +async function updateProfile(id, { displayName, avatarUrl }) { + const sets = [] + const vals = [] + if (displayName) { sets.push('display_name = ?'); vals.push(displayName) } + if (avatarUrl) { sets.push('avatar_url = ?'); vals.push(avatarUrl) } + if (!sets.length) return 0 + const res = await query(`UPDATE user_identities SET ${sets.join(', ')} WHERE id = ?`, [...vals, id]) + return Number(res.affectedRows || 0) +} + +// Remove a user's link to a provider — every identity of it, or, given an +// identity id, that one only (a user may hold several Steam accounts). Returns +// rows deleted. +async function deleteForUserProvider(userId, provider, identityId = null) { + if (identityId != null) { + const res = await query( + 'DELETE FROM user_identities WHERE user_id = ? AND provider = ? AND id = ?', + [userId, provider, identityId], + ) + return Number(res.affectedRows || 0) + } const res = await query( 'DELETE FROM user_identities WHERE user_id = ? AND provider = ?', [userId, provider], @@ -35,4 +59,4 @@ async function deleteForUserProvider(userId, provider) { return Number(res.affectedRows || 0) } -module.exports = { findByProviderSubject, listForUser, insert, deleteForUserProvider } +module.exports = { findByProviderSubject, listForUser, insert, updateProfile, deleteForUserProvider } diff --git a/server/src/model/userIdentities/userIdentities.model.js b/server/src/model/userIdentities/userIdentities.model.js index 8971788..4980d90 100644 --- a/server/src/model/userIdentities/userIdentities.model.js +++ b/server/src/model/userIdentities/userIdentities.model.js @@ -15,13 +15,21 @@ async function listForUser(userId) { // Link an external identity to an internal user. Returns the new row id. The // (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB. -async function link({ userId, provider, subject, email }) { - return db.insert({ userId, provider, subject, email }) +// `displayName` / `avatarUrl` are set by providers that report them (Steam); +// the others leave them null. +async function link({ userId, provider, subject, email, displayName, avatarUrl }) { + return db.insert({ userId, provider, subject, email, displayName, avatarUrl }) } -// Unlink a provider from a user. Returns rows removed (0 if nothing was linked). -async function unlink(userId, provider) { - return db.deleteForUserProvider(userId, provider) +// Refresh a linked identity's name and avatar from a sign-in's profile. +async function refreshProfile(identityId, { displayName, avatarUrl } = {}) { + return db.updateProfile(identityId, { displayName, avatarUrl }) } -module.exports = { findByProviderSubject, listForUser, link, unlink } +// Unlink a provider from a user: every identity of it, or the one `identityId` +// names. Returns rows removed (0 if nothing was linked). +async function unlink(userId, provider, identityId = null) { + return db.deleteForUserProvider(userId, provider, identityId) +} + +module.exports = { findByProviderSubject, listForUser, link, refreshProfile, unlink } diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 51dcf6f..4a82eca 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -311,6 +311,26 @@ function buildCtx(id, moduleRoot) { // exports creation, role changes and password handling, none of which is a // module's business. users: { getById: users.getById }, + // API 1.17.0 (STEAM_SIGNIN.md §4). READ only, one provider at a time: what a + // user has linked, as core verified it — the Steam id a module can trust is + // the one Steam's own `check_authentication` proved, never one a player typed. + // Linking and unlinking stay core's (the hooks on `api` are how a module takes + // part), and the row id and email are core's business, so they are not here. + identities: { + list: async (userId, provider) => { + // eslint-disable-next-line global-require + const rows = await require('../model/userIdentities/userIdentities.model').listForUser(userId) + return rows + .filter((r) => r.provider === provider) + .map((r) => ({ + provider: r.provider, + subject: r.subject, + displayName: r.display_name || null, + avatarUrl: r.avatar_url || null, + linkedAt: r.created_at, + })) + }, + }, // Where this deployment is reachable, for a module that has to build an // absolute link (an announcement carries one into a game window or a chat // message, where a relative path means nothing). §2.7 forbids a module @@ -472,6 +492,17 @@ function buildApi(record) { // never calls them. onBoot: hook('onBoot'), onShutdown: hook('onShutdown'), + // API 1.17.0 (STEAM_SIGNIN.md §4). Core calling the module about an external + // identity a signed-in user is linking, dispatched from auth/identityHooks.js. + // + // `onIdentityLinking` answers before anything is written and can only REFUSE + // (D396: a Steam id already another account's Rust link). It runs inside the + // link flow of a user who is already signed in, so it has no path to a session + // and cannot let through a link core would refuse. `onIdentityLinked` hears a + // link after it is written. There is no unlink hook: D393 leaves a module + // nothing to undo when a sign-in identity goes. + onIdentityLinking: hook('onIdentityLinking'), + onIdentityLinked: hook('onIdentityLinked'), } } @@ -794,7 +825,7 @@ function load(tierRouters) { staged: registries.stage(id), tables: new Set(), called: new Set(), - hooks: { onBoot: null, onShutdown: null }, + hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null }, client: null, ctx: null, state: 'installed', @@ -1022,6 +1053,24 @@ function shutdownHooks() { .reverse() } +/** + * The identity hooks of one name (`onIdentityLinking` | `onIdentityLinked`), in + * registration order (API 1.17.0). + * + * `started` only, the rule every dispatch here follows: a module that is + * disabled, or whose `onBoot` threw, is not serving and has no say in a link. + * Empty in a process that never loaded modules (the seed script, unit tests), so + * the link flow there behaves exactly as a bare core. + * + * @returns {{id: string, hook: Function}[]} + */ +function identityHooks(name) { + if (!loaded) return [] + return [...modules.values()] + .filter((r) => r.state === 'started' && r.hooks[name]) + .map((r) => ({ id: r.id, hook: r.hooks[name] })) +} + /** * One module's `onShutdown`, for stopping it on its own rather than at exit. * @@ -1147,6 +1196,7 @@ module.exports = { fragments, bootable, shutdownHooks, + identityHooks, stopHook, clientChunks, clientEntryUrls, diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 9943cb3..33f0e53 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,21 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.17.0 — a module may take part in linking an external sign-in identity +// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396). +// Three additions, each optional: +// • `ctx.identities.list(userId, provider)` — the identities a user has linked +// for one provider, as core verified them (a Steam id proved by Steam's own +// `check_authentication`), with the display name and avatar when there are; +// • `api.onIdentityLinking(fn)` — asked before a link is written, and able only +// to refuse it: `{ ok: false, reason }`, where `reason: 'in_use'` reads to the +// player as "already linked to another account" and anything else as a +// plain refusal. A module's own text is logged, never shown; +// • `api.onIdentityLinked(fn)` — told after a link is written. +// No unlink hook (D393). Additions only, so minor. Module-uo declares `^1.10.0` +// and uses none of them: with no hook registered a link is checked and written +// exactly as before. +// // 1.16.0 — a dry run carries a module's advice: an action that answers // `verify: true` with `{ ok: true, detail: { warnings: [...] } }` has each line // shown as a WARNING finding (code `advice`) that does not fail the dry run @@ -216,6 +231,6 @@ // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.16.0' +const MODULE_API_VERSION = '1.17.0' module.exports = { MODULE_API_VERSION } diff --git a/server/src/router/v1/admin/authProviders.controller.js b/server/src/router/v1/admin/authProviders.controller.js index 3f99d94..21b24d4 100644 --- a/server/src/router/v1/admin/authProviders.controller.js +++ b/server/src/router/v1/admin/authProviders.controller.js @@ -1,6 +1,6 @@ // ── Admin: auth provider configuration ───────────────────────────────────── // -// CRUD for SSO providers. Built-ins (google, discord) are configured here too but +// CRUD for SSO providers. Built-ins (google, discord, steam) are configured here too but // can only be enabled/disabled and given a client id/secret — their kind, name, // and endpoints are fixed in code and cannot be edited or deleted. Custom // (oidc/oauth2) providers are fully editable. @@ -80,6 +80,18 @@ async function update(req, res) { // Built-ins: kind/name/priority are fixed; only enable + credentials change. const meta = registry.BUILTINS.find((x) => x.id === id) fields = { kind: meta.kind, name: meta.name, priority: meta.priority, enabled: b.enabled, clientId: b.clientId, secret: b.secret } + if (meta.kind === 'steam') { + // Steam has no client id; its secret is the Steam Web API key, and it + // cannot be enabled without one (D394) — refused here, with a sentence, + // rather than saved as an "Incomplete" provider nobody can use. + fields.clientId = undefined + if (b.enabled === true && !b.secret) { + const current = await authProviders.get(id) + if (!current || !current.client_secret_enc) { + return res.status(400).json({ message: 'Steam needs a Steam Web API key before it can be enabled.' }) + } + } + } } else { if (!(await authProviders.get(id))) { return res.status(404).json({ message: 'Provider not found.' }) diff --git a/server/src/router/v1/admin/authProviders.router.js b/server/src/router/v1/admin/authProviders.router.js index 001f615..ed26ed6 100644 --- a/server/src/router/v1/admin/authProviders.router.js +++ b/server/src/router/v1/admin/authProviders.router.js @@ -59,11 +59,12 @@ providersRouter.put( '/providers/:id', // #swagger.tags = ['Admin · Auth Providers'] // #swagger.summary = 'Update an SSO provider (admin only)' + // #swagger.description = 'Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ /* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error, or Steam enabled without a Steam Web API key', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ diff --git a/server/src/router/v1/auth/account.controller.js b/server/src/router/v1/auth/account.controller.js index 9329020..ec55202 100644 --- a/server/src/router/v1/auth/account.controller.js +++ b/server/src/router/v1/auth/account.controller.js @@ -339,20 +339,36 @@ async function totpDisable(req, res) { async function listIdentities(req, res) { try { const rows = await userIdentities.listForUser(req.user.id) - return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at }))) + // `id` lets one identity be unlinked when a provider has several (a player + // may link more than one Steam account). `subject` is the user's OWN external + // id — for Steam, the SteamID64 shown beside the persona name (D394). + return res.json( + rows.map((r) => ({ + id: r.id, + provider: r.provider, + subject: r.subject, + email: r.email, + display_name: r.display_name || null, + avatar_url: r.avatar_url || null, + linked_at: r.created_at, + })), + ) } catch (err) { log.error('listIdentities', err) return res.status(500).json({ message: 'Internal Server Error' }) } } -// Remove a linked SSO identity from the current user's account. +// Remove a linked SSO identity from the current user's account: every identity +// of that provider, or with `?id=` the one identity named. Only the sign-in goes — +// a module's own link made from it (Rust's, D392) is the module's to remove (D393). async function unlinkIdentity(req, res) { const { provider } = req.params + const identityId = req.query.id != null ? Number(req.query.id) : null try { - const removed = await userIdentities.unlink(req.user.id, provider) + const removed = await userIdentities.unlink(req.user.id, provider, identityId) if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' }) - await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } }) + await activity.log({ req, action: 'auth.sso.unlink', detail: { provider, ...(identityId != null ? { identityId } : {}) } }) log.info('sso identity unlinked', { provider, id: req.user.id }) return res.json({ unlinked: true }) } catch (err) { diff --git a/server/src/router/v1/auth/me.routes.js b/server/src/router/v1/auth/me.routes.js index d3e8af8..ed4de8e 100644 --- a/server/src/router/v1/auth/me.routes.js +++ b/server/src/router/v1/auth/me.routes.js @@ -18,7 +18,7 @@ // expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*. const express = require('express') -const { body, param } = require('express-validator') +const { body, param, query } = require('express-validator') const account = require('./account.controller') const { requireAuth } = require('../../../auth/session.middleware') @@ -177,10 +177,14 @@ meRouter.delete( // #swagger.tags = ['Auth · Me'] // #swagger.summary = 'Unlink an SSO identity (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.description = 'Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).' // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } + // #swagger.parameters['id'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'One identity to unlink (its id from GET /auth/me/account/identities).' } /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ /* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('provider').matches(/^[a-z0-9-]+$/), + query('id').optional().isInt({ min: 1 }), validate, account.unlinkIdentity, ) diff --git a/server/src/router/v1/auth/sso.controller.js b/server/src/router/v1/auth/sso.controller.js index 794d5ac..e5de851 100644 --- a/server/src/router/v1/auth/sso.controller.js +++ b/server/src/router/v1/auth/sso.controller.js @@ -1,6 +1,8 @@ // ── SSO (OAuth2 / OIDC) controller ───────────────────────────────────────── // -// Drives the redirect flow for built-in (Google, Discord) and custom providers: +// Drives the redirect flow for built-in (Google, Discord, Steam) and custom +// providers. Steam is OpenID 2.0 rather than OAuth2 (STEAM_SIGNIN.md §3); the +// difference is confined to readCallback() and the provider class: // GET /auth/providers → public discovery (enabled + valid providers) // GET /auth/sso/:provider/start → begin login (redirect to the IdP) // GET /auth/sso/:provider/link → begin account linking (requireAuth) @@ -17,6 +19,8 @@ const authProviders = require('../../../model/authProviders/authProviders.model' const userIdentities = require('../../../model/userIdentities/userIdentities.model') const settings = require('../../../model/settings/settings.model') const registry = require('../../../auth/providers/registry') +const SteamProvider = require('../../../auth/providers/steam.provider') +const identityHooks = require('../../../auth/identityHooks') const sessionService = require('../../../auth/session.service') const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model') const ssoState = require('../../../auth/ssoState') @@ -148,20 +152,43 @@ async function beginFlow(req, res, mode) { const start = (req, res) => beginFlow(req, res, 'login') const linkStart = (req, res) => beginFlow(req, res, 'link') +// The callback's own answer from the IdP, read the way its protocol writes it. +// OAuth2/OIDC sends `code` (or `error`); Steam's OpenID 2.0 sends `openid.*` +// fields, with `openid.mode=cancel` when the person declined (STEAM_SIGNIN.md §3). +// Our `state` arrives as a query parameter either way — for Steam because it +// rides inside the `return_to` we sent. +function readCallback(req, providerId) { + const q = req.query || {} + if (registry.isOpenId(providerId)) { + const mode = typeof q['openid.mode'] === 'string' ? q['openid.mode'] : '' + return { state: q.state, denied: mode === 'cancel' || mode === 'error', present: mode === 'id_res' } + } + return { state: q.state, denied: Boolean(q.error), present: Boolean(q.code), error: q.error } +} + +// What the provider's handleCallback needs. Steam takes the whole query, and its +// `return_to` must match what we sent (the callback URL plus this very state) +// byte for byte; OAuth2 takes the code and the PKCE verifier. +function callbackParams(req, providerId, tx, answer) { + const redirectUri = redirectUriFor(req, providerId) + if (registry.isOpenId(providerId)) return { query: req.query, redirectUri, state: answer.state } + return { code: req.query.code, redirectUri, codeVerifier: tx.verifier } +} + // GET /auth/sso/:provider/callback async function callback(req, res) { const providerId = req.params.provider const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE] - const { code, state, error: oauthError } = req.query + const answer = readCallback(req, providerId) // The tx cookie is single-use — clear it no matter the outcome. res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req)) - if (oauthError) { - log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) }) + if (answer.denied) { + log.warn('sso callback: provider returned error', { provider: providerId, error: String(answer.error || 'cancel').slice(0, 60) }) return res.redirect(loginError('denied')) } - const tx = ssoState.verifyTx(txToken, state) - if (!tx || tx.provider !== providerId || !code) { + const tx = ssoState.verifyTx(txToken, answer.state) + if (!tx || tx.provider !== providerId || !answer.present) { log.warn('sso callback: bad state', { provider: providerId }) return res.redirect(loginError('bad_state')) } @@ -175,20 +202,34 @@ async function callback(req, res) { return res.redirect(failFor('unavailable')) } const provider = registry.instantiate(row) - const profile = await provider.handleCallback({ - code, - redirectUri: redirectUriFor(req, providerId), - codeVerifier: tx.verifier, - }) + const profile = await provider.handleCallback(callbackParams(req, providerId, tx, answer)) if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile) if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile) return finishLogin(req, res, providerId, row.kind, tx, profile) } catch (err) { + // A Steam assertion that failed one of the §3 checks is a refused sign-in, not + // a server fault: it is what a replayed, forged or tampered callback looks like. + if (err instanceof SteamProvider.SteamAssertionError) { + log.warn('sso callback: steam assertion refused', { provider: providerId, reason: err.reason }) + return res.redirect(failFor('bad_state')) + } log.error('sso callback', err) return res.redirect(failFor('error')) } } +// A profile's name and avatar written back onto an identity the user already +// holds, when the provider reports them (Steam, at every sign-in). Decoration: +// a failure here is logged and never stops the sign-in it rides on. +async function refreshIdentityProfile(identity, profile) { + if (!identity || !(profile.displayName || profile.avatarUrl)) return + try { + await userIdentities.refreshProfile(identity.id, { displayName: profile.displayName, avatarUrl: profile.avatarUrl }) + } catch (err) { + log.warn('sso: could not refresh a linked identity profile', { provider: identity.provider, message: err.message }) + } +} + // Auto-provision a `player` from an SSO profile when no identity is linked yet // and registration allows SSO sign-up. Derives a unique username (reserved-name // safe) with a bounded retry against the UNIQUE index, captures the provider @@ -288,10 +329,12 @@ async function finishLogin(req, res, providerId, kind, tx, profile) { if (identity) { user = await users.getById(identity.user_id) if (!user) return res.redirect(loginError('not_linked', portal)) + await refreshIdentityProfile(identity, profile) } else { - // Unknown identity: auto-provision only if registration opts into SSO sign-up. + // Unknown identity: auto-provision only if registration opts into SSO sign-up, + // and never through a provider that signs nobody up (Steam, D395). const mode = await settings.getRegistrationMode() - if (mode !== 'sso' && mode !== 'both') { + if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) { log.warn('sso login refused: no linked account', { provider: providerId }) return res.redirect(loginError('not_linked', portal)) } @@ -397,7 +440,7 @@ async function requireValidBridgeSession(res, tx, providerId) { // Resolve the linked user for a mobile SSO login (link-only, with opt-in // provisioning when registration mode allows it). On refusal, sends the redirect // and returns null. -async function resolveMobileSsoUser(req, res, sess, providerId, profile) { +async function resolveMobileSsoUser(req, res, sess, providerId, kind, profile) { const identity = await userIdentities.findByProviderSubject(providerId, profile.subject) if (identity) { const user = await users.getById(identity.user_id) @@ -405,10 +448,11 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) { res.redirect(appError(sess, 'not_linked')) return null } + await refreshIdentityProfile(identity, profile) return user } const mode = await settings.getRegistrationMode() - if (mode !== 'sso' && mode !== 'both') { + if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) { log.warn('mobile sso login refused: no linked account', { provider: providerId }) res.redirect(appError(sess, 'not_linked')) return null @@ -425,7 +469,7 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) { const sess = await requireValidBridgeSession(res, tx, providerId) if (!sess) return - const user = await resolveMobileSsoUser(req, res, sess, providerId, profile) + const user = await resolveMobileSsoUser(req, res, sess, providerId, kind, profile) if (!user) return if (user.status && user.status !== 'active') { @@ -555,11 +599,38 @@ async function finishLink(req, res, providerId, tx, profile) { if (existing && existing.user_id !== userId) { return res.redirect(accountError('in_use', portal)) // external identity belongs to another account } - if (!existing) { - await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email }) - await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } }) - log.info('sso account linked', { provider: providerId, userId }) + if (existing) { + // Already theirs: linking twice is not an error, and it is a fresh look at + // the name and avatar. + await refreshIdentityProfile(existing, profile) + return res.redirect(`${accountPath(portal)}?linked=${providerId}`) } + + // Modules may refuse before anything is written (API 1.17.0) — Rust, for a + // Steam id that is already another account's Rust link (D396). Only ever a + // refusal: this user is already signed in, and this decides a link, not a session. + const check = await identityHooks.checkLink({ userId, provider: providerId, subject: profile.subject }) + if (!check.ok) { + await activity.log({ req, userId, action: 'auth.sso.link.refused', detail: { provider: providerId, module: check.module, reason: check.reason } }) + return res.redirect(accountError(check.reason, portal)) + } + + await userIdentities.link({ + userId, + provider: providerId, + subject: profile.subject, + email: profile.email, + displayName: profile.displayName || null, + avatarUrl: profile.avatarUrl || null, + }) + await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } }) + log.info('sso account linked', { provider: providerId, userId }) + await identityHooks.notifyLinked({ + userId, + provider: providerId, + subject: profile.subject, + displayName: profile.displayName || null, + }) return res.redirect(`${accountPath(portal)}?linked=${providerId}`) } diff --git a/server/src/router/v1/auth/sso.routes.js b/server/src/router/v1/auth/sso.routes.js index 07ebf08..70d26b6 100644 --- a/server/src/router/v1/auth/sso.routes.js +++ b/server/src/router/v1/auth/sso.routes.js @@ -52,10 +52,13 @@ ssoRouter.get( '/sso/:provider/callback', // #swagger.tags = ['Auth · SSO'] // #swagger.summary = 'OAuth redirect target — completes login or linking' - // #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).' - // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' } - // #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code.' } - // #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie).' } + // #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).' + // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord, steam).' } + // #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code (OAuth2/OIDC providers).' } + // #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter.' } + // #swagger.parameters['error'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth error (the user declined, or the IdP refused).' } + // #swagger.parameters['openid.mode'] = { in: 'query', required: false, schema: { type: 'string', enum: ['id_res', 'cancel'] }, description: 'Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify.' } + // #swagger.parameters['openid.claimed_id'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Steam only: https://steamcommunity.com/openid/id/.' } /* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */ ctrl.callback, ) diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 9befb3e..2676060 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -13089,7 +13089,7 @@ "Auth · Me" ], "summary": "Unlink an SSO identity (self)", - "description": "", + "description": "Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).", "parameters": [ { "name": "provider", @@ -13099,6 +13099,15 @@ "type": "string" }, "description": "Provider id." + }, + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "One identity to unlink (its id from GET /auth/me/account/identities)." } ], "responses": { @@ -13113,7 +13122,14 @@ } }, "400": { - "description": "Bad Request" + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } }, "401": { "description": "Unauthorized" @@ -15467,7 +15483,7 @@ "Auth · SSO" ], "summary": "OAuth redirect target — completes login or linking", - "description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).", + "description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).", "parameters": [ { "name": "provider", @@ -15476,14 +15492,7 @@ "schema": { "type": "string" }, - "description": "Provider id (e.g. google, discord)." - }, - { - "name": "error", - "in": "query", - "schema": { - "type": "string" - } + "description": "Provider id (e.g. google, discord, steam)." }, { "name": "code", @@ -15492,7 +15501,7 @@ "schema": { "type": "string" }, - "description": "OAuth authorization code." + "description": "OAuth authorization code (OAuth2/OIDC providers)." }, { "name": "state", @@ -15501,7 +15510,50 @@ "schema": { "type": "string" }, - "description": "OAuth state (matched against the tx cookie)." + "description": "OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter." + }, + { + "name": "error", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "OAuth error (the user declined, or the IdP refused)." + }, + { + "name": "openid.mode", + "in": "query", + "required": false, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "id_res", + "cancel" + ], + "items": { + "type": "string" + } + } + } + }, + "description": "Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify." + }, + { + "name": "openid.claimed_id", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Steam only: https://steamcommunity.com/openid/id/." } ], "responses": { @@ -19827,7 +19879,7 @@ }, "description": { "type": "string", - "example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')." + "example": "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2')." }, "example": { "type": "string", @@ -19908,6 +19960,7 @@ "example": [ "google", "discord", + "steam", "oidc", "oauth2" ], @@ -19965,7 +20018,7 @@ }, "description": { "type": "string", - "example": "Whether a client secret is stored (the secret itself is never returned)." + "example": "Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without." }, "example": { "type": "boolean", @@ -20047,7 +20100,7 @@ }, "description": { "type": "string", - "example": "True for the fixed google/discord providers." + "example": "True for the fixed google/discord/steam providers." }, "example": { "type": "boolean", @@ -24740,6 +24793,23 @@ "properties": { "type": "object", "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Identity id, for unlinking one of several (DELETE ...?id=)." + }, + "example": { + "type": "number", + "example": 12 + } + } + }, "provider": { "type": "object", "properties": { @@ -24774,6 +24844,65 @@ } } }, + "subject": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The user’s own external id at the provider; for steam, the 17-digit SteamID64." + }, + "example": { + "type": "string", + "example": "76561197960287930" + } + } + }, + "display_name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Persona name the provider reported (steam only)." + }, + "example": { + "type": "string", + "example": "CoolGuy99" + } + } + }, + "avatar_url": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "uri" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Avatar on the provider’s CDN (steam only)." + } + } + }, "linked_at": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index fcb74b2..2291dd2 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -294,7 +294,7 @@ const doc = { name: { type: 'string', example: 'Google' }, icon: { type: 'string', - description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').", + description: "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2').", example: 'google', }, loginUrl: { @@ -313,17 +313,17 @@ const doc = { type: 'object', properties: { id: { type: 'string', example: 'okta' }, - kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' }, + kind: { type: 'string', enum: ['google', 'discord', 'steam', 'oidc', 'oauth2'], example: 'oidc' }, name: { type: 'string', example: 'Okta' }, enabled: { type: 'boolean', example: true }, clientId: { type: 'string' }, - hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true }, + hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without.', example: true }, authorizeUrl: { type: 'string', format: 'uri' }, tokenUrl: { type: 'string', format: 'uri' }, userinfoUrl: { type: 'string', format: 'uri' }, scopes: { type: 'string', example: 'openid email profile' }, priority: { type: 'integer', example: 10 }, - builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false }, + builtin: { type: 'boolean', description: 'True for the fixed google/discord/steam providers.', example: false }, health: { $ref: '#/components/schemas/ProviderHealth' }, }, }, @@ -961,8 +961,12 @@ const doc = { LinkedIdentity: { type: 'object', properties: { + id: { type: 'integer', description: 'Identity id, for unlinking one of several (DELETE ...?id=).', example: 12 }, provider: { type: 'string', example: 'google' }, email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' }, + subject: { type: 'string', description: 'The user’s own external id at the provider; for steam, the 17-digit SteamID64.', example: '76561197960287930' }, + display_name: { type: 'string', nullable: true, description: 'Persona name the provider reported (steam only).', example: 'CoolGuy99' }, + avatar_url: { type: 'string', format: 'uri', nullable: true, description: 'Avatar on the provider’s CDN (steam only).' }, linked_at: { type: 'string', format: 'date-time' }, }, }, diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index c25dd7f..8117e8a 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => { // 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the // assertion that says it does. The number itself is pinned in version.js. assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION) - assert.equal(MODULE_API_VERSION, '1.16.0') + assert.equal(MODULE_API_VERSION, '1.17.0') }) test('a module registers actions, budgets, leases and option sources', () => { diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index f97c5b9..6e69d4b 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -519,8 +519,11 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { // throws until Phase 7 builds the channel — which is why it has to be in this // list now: a member of a declared version that were absent would make // MODULE_API_VERSION a lie, and this test is what says so. + // API 1.17.0 added `identities` (STEAM_SIGNIN.md §4): READ only, one provider + // at a time. Linking stays core's, and a module takes part through the two + // hooks on `api`, never by writing an identity itself. assert.deepEqual(probe.keys, [ - 'activity', 'auth', 'db', 'events', 'express', 'inbox', 'log', 'middleware', 'moduleId', 'paths', + 'activity', 'auth', 'db', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths', 'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator', ]) // is core's limiter FACTORY, not a limiter: a module states its own diff --git a/server/test/registry.test.js b/server/test/registry.test.js index f3550a8..87f1500 100644 --- a/server/test/registry.test.js +++ b/server/test/registry.test.js @@ -28,11 +28,11 @@ test('validateConfig: custom OIDC also needs the endpoint URLs', () => { assert.deepEqual(registry.validateConfig(noUrls).missing, ['authorize_url', 'token_url', 'userinfo_url']) }) -test('listConfigured always includes both built-ins with health', async () => { +test('listConfigured always includes every built-in with health', async () => { authProvidersModel.list = async () => [] // no rows yet const out = await registry.listConfigured() const ids = out.map((p) => p.id) - assert.deepEqual(ids, ['google', 'discord']) + assert.deepEqual(ids, ['google', 'discord', 'steam']) assert.equal(out[0].builtin, true) assert.equal(out[0].enabled, 0) assert.equal(out[0].health.valid, false) // unconfigured diff --git a/server/test/ssoCallback.test.js b/server/test/ssoCallback.test.js index ecb73dc..94cd814 100644 --- a/server/test/ssoCallback.test.js +++ b/server/test/ssoCallback.test.js @@ -131,7 +131,7 @@ test('link mode → identity linked to the acting user, redirect to account', as const res = mockRes() await ssoCtrl.callback(makeReq(tx), res) - assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com' }) + assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com', displayName: null, avatarUrl: null }) assert.equal(res.redirectedTo, '/admin/account?linked=google') assert.equal(logged.at(-1).action, 'auth.sso.link') assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session') diff --git a/server/test/steamSignin.test.js b/server/test/steamSignin.test.js new file mode 100644 index 0000000..86b5979 --- /dev/null +++ b/server/test/steamSignin.test.js @@ -0,0 +1,433 @@ +// Sign in with Steam (docs/website/STEAM_SIGNIN.md): the OpenID 2.0 provider's +// checks (§3), the callback through the real SSO controller, D394 (key required), +// D395 (never signs up) and the module hooks behind D392/D396 (MODULE_API 1.17.0). +// Steam itself is a stubbed global fetch; no request leaves the process. + +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret' +process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key' +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' +process.env.APP_BASE_URL = 'https://site.example' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const SteamProvider = require('../src/auth/providers/steam.provider') +const registry = require('../src/auth/providers/registry') +const ssoCtrl = require('../src/router/v1/auth/sso.controller') +const ssoState = require('../src/auth/ssoState') +const token = require('../src/auth/token') +const identityHooks = require('../src/auth/identityHooks') +const loader = require('../src/modules/loader') +const users = require('../src/model/users/users.model') +const activity = require('../src/model/activity/activity.model') +const authProviders = require('../src/model/authProviders/authProviders.model') +const userIdentities = require('../src/model/userIdentities/userIdentities.model') +const settings = require('../src/model/settings/settings.model') +const mobileBridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model') +const providersCtrl = require('../src/router/v1/admin/authProviders.controller') +const accountCtrl = require('../src/router/v1/auth/account.controller') +const db = require('../src/utils/db') + +after(() => db.close()) + +const STEAM_ID = '76561197960287930' +const CALLBACK = 'https://site.example/api/v1/auth/sso/steam/callback' +const STEAM_ROW = { id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_id: null, client_secret_enc: 'enc', client_secret: 'WEBAPIKEY' } + +// ── A stand-in for steamcommunity.com and api.steampowered.com ────────────── + +const realFetch = global.fetch +let steam // { valid, verifyStatus, verifyThrows, summary, summaryStatus, calls } +function installSteam(overrides = {}) { + steam = { + valid: true, + verifyStatus: 200, + verifyThrows: false, + summary: { steamid: STEAM_ID, personaname: 'CoolGuy99', avatarfull: 'https://avatars.steamstatic.com/abc_full.jpg' }, + summaryStatus: 200, + calls: [], + ...overrides, + } + global.fetch = async (url, init = {}) => { + const href = String(url) + steam.calls.push({ href, init }) + if (href === SteamProvider.OP_ENDPOINT) { + if (steam.verifyThrows) throw new Error('ECONNRESET') + const body = new URLSearchParams(init.body) + steam.lastVerifyBody = body + return { + ok: steam.verifyStatus === 200, + status: steam.verifyStatus, + text: async () => `ns:http://specs.openid.net/auth/2.0\nis_valid:${steam.valid}\n`, + } + } + if (href.startsWith('https://api.steampowered.com/')) { + return { + ok: steam.summaryStatus === 200, + status: steam.summaryStatus, + json: async () => ({ response: { players: steam.summary ? [steam.summary] : [] } }), + } + } + throw new Error(`unexpected fetch ${href}`) + } +} +afterEach(() => { global.fetch = realFetch }) + +// What Steam appends to our return_to after a successful sign-in. +function assertion(state, overrides = {}) { + const claimed = `https://steamcommunity.com/openid/id/${STEAM_ID}` + return { + state, + 'openid.ns': 'http://specs.openid.net/auth/2.0', + 'openid.mode': 'id_res', + 'openid.op_endpoint': SteamProvider.OP_ENDPOINT, + 'openid.claimed_id': claimed, + 'openid.identity': claimed, + 'openid.return_to': SteamProvider.returnToFor(CALLBACK, state), + 'openid.response_nonce': '2026-10-19T12:00:00Zabc', + 'openid.assoc_handle': '1234567890', + 'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle', + 'openid.sig': 'c2lnbmF0dXJl', + ...overrides, + } +} + +// ── The provider (§3) ─────────────────────────────────────────────────────── + +test('the authorization URL is an OpenID 2.0 checkid_setup with our state inside return_to', () => { + const url = new URL(new SteamProvider({}).getAuthorizationUrl('nonce-1', { redirectUri: CALLBACK })) + assert.equal(url.origin + url.pathname, SteamProvider.OP_ENDPOINT) + assert.equal(url.searchParams.get('openid.mode'), 'checkid_setup') + assert.equal(url.searchParams.get('openid.return_to'), `${CALLBACK}?state=nonce-1`) + assert.equal(url.searchParams.get('openid.realm'), 'https://site.example') + assert.equal(url.searchParams.get('openid.claimed_id'), 'http://specs.openid.net/auth/2.0/identifier_select') + assert.equal(url.searchParams.get('client_id'), null, 'Steam has no client id') +}) + +test('parseClaimedId accepts only a 17-digit id on steamcommunity.com', () => { + assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}`), STEAM_ID) + assert.equal(SteamProvider.parseClaimedId('http://steamcommunity.com/openid/id/76561197960287930'), null) + assert.equal(SteamProvider.parseClaimedId('https://steamcommunity.com/openid/id/123'), null) + assert.equal(SteamProvider.parseClaimedId('https://evil.example/openid/id/76561197960287930'), null) + assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}/x`), null) + assert.equal(SteamProvider.parseClaimedId(undefined), null) +}) + +test('a valid assertion is verified with Steam and yields the id, persona name and avatar', async () => { + installSteam() + const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' }) + const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }) + assert.deepEqual(profile, { + subject: STEAM_ID, email: null, emailVerified: false, name: 'CoolGuy99', + displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg', + }) + // The verification echoed every openid field back with mode check_authentication, + // and did not forward our own `state`. + assert.equal(steam.lastVerifyBody.get('openid.mode'), 'check_authentication') + assert.equal(steam.lastVerifyBody.get('openid.sig'), 'c2lnbmF0dXJl') + assert.equal(steam.lastVerifyBody.get('state'), null) +}) + +for (const [label, overrides, state] of [ + ['a wrong op_endpoint', { 'openid.op_endpoint': 'https://evil.example/openid/login' }, 's1'], + ['a return_to that is not ours', { 'openid.return_to': 'https://evil.example/cb?state=s1' }, 's1'], + ['a claimed id that is not a Steam id', { 'openid.claimed_id': 'https://steamcommunity.com/openid/id/1', 'openid.identity': 'https://steamcommunity.com/openid/id/1' }, 's1'], + ['an identity that differs from the claimed id', { 'openid.identity': 'https://steamcommunity.com/openid/id/76561197960287931' }, 's1'], + ['mode cancel', { 'openid.mode': 'cancel' }, 's1'], +]) { + test(`${label} is refused before Steam is even asked`, async () => { + installSteam() + const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' }) + await assert.rejects( + p.handleCallback({ query: assertion(state, overrides), redirectUri: CALLBACK, state }), + SteamProvider.SteamAssertionError, + ) + assert.equal(steam.calls.length, 0) + }) +} + +test('an assertion replayed into another sign-in fails: its return_to carries the old state', async () => { + installSteam() + const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' }) + // Captured from a sign-in whose state was s-old, replayed against a tx whose state is s-new. + const replay = { ...assertion('s-old'), state: 's-new' } + await assert.rejects(p.handleCallback({ query: replay, redirectUri: CALLBACK, state: 's-new' }), { reason: 'return_to' }) +}) + +test('check_authentication saying is_valid:false refuses the sign-in', async () => { + installSteam({ valid: false }) + const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' }) + await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' }) +}) + +test('Steam unreachable for verification is a refusal, never a pass', async () => { + installSteam({ verifyThrows: true }) + const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' }) + await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' }) + installSteam({ verifyStatus: 503 }) + await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' }) +}) + +test('a failed Web API lookup still signs in, with the bare id (D394)', async () => { + installSteam({ summaryStatus: 403 }) + const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' }) + const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }) + assert.equal(profile.subject, STEAM_ID) + assert.equal(profile.displayName, null) + assert.equal(profile.avatarUrl, null) +}) + +test('an avatar that is not on a Steam CDN is dropped', async () => { + installSteam({ summary: { steamid: STEAM_ID, personaname: 'X', avatarfull: 'https://evil.example/a.jpg' } }) + const profile = await new SteamProvider({ clientSecret: 'K' }).handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }) + assert.equal(profile.displayName, 'X') + assert.equal(profile.avatarUrl, null) +}) + +// ── Registry and Admin → Auth (D394) ──────────────────────────────────────── + +test('Steam is a built-in that needs only a Web API key', () => { + assert.ok(registry.isBuiltin('steam')) + assert.deepEqual(registry.validateConfig({ kind: 'steam' }), { valid: false, missing: ['api_key'] }) + assert.deepEqual(registry.validateConfig({ kind: 'steam', client_secret_enc: 'e' }), { valid: true, missing: [] }) + assert.equal(registry.neverProvisions('steam'), true) + assert.equal(registry.neverProvisions('google'), false) +}) + +function mockRes() { + return { + statusCode: 200, redirectedTo: null, cookies: {}, cleared: [], + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, + redirect(u) { this.redirectedTo = u; return this }, + cookie(n, v) { this.cookies[n] = v; return this }, + clearCookie(n) { this.cleared.push(n); return this }, + } +} + +test('enabling Steam with no key is refused with a sentence; with one it saves', async () => { + let saved = null + authProviders.get = async () => null + authProviders.save = async (id, fields) => { saved = { id, ...fields }; return { id, kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' } } + activity.log = async () => {} + + const refused = mockRes() + await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, refused) + assert.equal(refused.statusCode, 400) + assert.match(refused.body.message, /Steam Web API key/) + assert.equal(saved, null) + + const ok = mockRes() + await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true, secret: 'WEBAPIKEY', clientId: 'ignored' }, user: { username: 'admin' } }, ok) + assert.equal(ok.statusCode, 200) + assert.equal(saved.kind, 'steam') + assert.equal(saved.secret, 'WEBAPIKEY') + assert.equal(saved.clientId, undefined, 'Steam keeps no client id') + assert.equal(ok.body.hasSecret, true) +}) + +test('re-enabling Steam with a key already stored needs no new one', async () => { + authProviders.get = async () => ({ id: 'steam', kind: 'steam', client_secret_enc: 'e' }) + authProviders.save = async () => ({ id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' }) + activity.log = async () => {} + const res = mockRes() + await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, res) + assert.equal(res.statusCode, 200) +}) + +// ── The callback through the real controller ──────────────────────────────── + +let logged +let linkArgs +let refreshed +let created +let hooks +beforeEach(() => { + logged = [] + linkArgs = null + refreshed = null + created = null + hooks = { onIdentityLinking: [], onIdentityLinked: [] } + activity.log = async (evt) => { logged.push(evt) } + authProviders.getWithSecret = async () => ({ ...STEAM_ROW }) + registry.instantiate = (row) => new SteamProvider({ ...row, clientSecret: row.client_secret }) + userIdentities.findByProviderSubject = async () => null + userIdentities.link = async (args) => { linkArgs = args; return 1 } + userIdentities.refreshProfile = async (id, p) => { refreshed = { id, ...p }; return 1 } + users.getById = async (id) => ({ id, username: 'alice', role: 'player', status: 'active' }) + users.recordLogin = async () => {} + users.createUser = async (args) => { created = args; return { id: 99, ...args } } + settings.getRegistrationMode = async () => 'both' // SSO sign-up ON: Steam must still refuse + loader.identityHooks = (name) => hooks[name] + installSteam() +}) + +function steamReq(tx, overrides = {}) { + return { + params: { provider: 'steam' }, + cookies: { [ssoState.TX_COOKIE]: tx.txToken }, + query: assertion(tx.nonce, overrides), + ip: '127.0.0.1', protocol: 'https', get: () => 'site.example', headers: {}, + } +} + +test('a linked Steam account signs in through the one session service, and its name is refreshed', async () => { + userIdentities.findByProviderSubject = async (provider, subject) => + (provider === 'steam' && subject === STEAM_ID ? { id: 3, user_id: 7, provider: 'steam' } : null) + const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set') + assert.equal(res.redirectedTo, '/account') + assert.equal(logged.at(-1).action, 'auth.sso.login') + assert.deepEqual(refreshed, { id: 3, displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg' }) +}) + +test('an unlinked Steam id signs nobody in and creates nobody, even with SSO sign-up on (D395)', async () => { + const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.equal(res.redirectedTo, '/account/login?sso_error=not_linked') + assert.equal(created, null) + assert.equal(linkArgs, null) + assert.equal(res.cookies[token.COOKIE_NAME], undefined) +}) + +test('the same refusal holds on the mobile bridge (D395)', async () => { + mobileBridge.getSession = async () => ({ + session_id: 'm1', status: 'pending', redirect_uri: 'runicgateway://sso', state: 'app-state', + expires_at: new Date(Date.now() + 60_000).toISOString(), + }) + const tx = ssoState.createTx({ provider: 'steam', mode: 'mobile', mobileSessionId: 'm1' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.equal(res.redirectedTo, 'runicgateway://sso?error=not_linked&state=app-state') + assert.equal(created, null) +}) + +test('a forged callback (Steam says is_valid:false) is a refused sign-in, not a server error', async () => { + installSteam({ valid: false }) + userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7 }) + const tx = ssoState.createTx({ provider: 'steam', mode: 'login' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state') + assert.equal(res.cookies[token.COOKIE_NAME], undefined) +}) + +test('cancelling on steamcommunity.com reads as denied', async () => { + const tx = ssoState.createTx({ provider: 'steam', mode: 'login' }) + const res = mockRes() + await ssoCtrl.callback({ ...steamReq(tx), query: { state: tx.nonce, 'openid.mode': 'cancel' } }, res) + assert.equal(res.redirectedTo, '/admin/login?sso_error=denied') + assert.equal(steam.calls.length, 0) +}) + +test('a callback with a state that is not the tx is refused before Steam is asked', async () => { + const tx = ssoState.createTx({ provider: 'steam', mode: 'login' }) + const res = mockRes() + const req = steamReq(tx) + req.query.state = 'tampered' + await ssoCtrl.callback(req, res) + assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state') + assert.equal(steam.calls.length, 0) +}) + +// ── Linking, and the module hooks (MODULE_API 1.17.0) ─────────────────────── + +test('linking Steam stores the id with its name and avatar, then tells the modules once (D392)', async () => { + const told = [] + hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }] + const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.deepEqual(linkArgs, { + userId: 5, provider: 'steam', subject: STEAM_ID, email: null, + displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg', + }) + assert.equal(res.redirectedTo, '/account?linked=steam') + assert.deepEqual(told, [{ userId: 5, provider: 'steam', subject: STEAM_ID, displayName: 'CoolGuy99' }]) + assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session') +}) + +test('a module refusing a link writes nothing and the player reads "in use" (D396)', async () => { + const told = [] + hooks.onIdentityLinking = [{ id: 'rust', hook: async () => ({ ok: false, reason: 'in_use', message: 'held by bob' }) }] + hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }] + const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.equal(res.redirectedTo, '/account?link_error=in_use') + assert.equal(linkArgs, null) + assert.deepEqual(told, []) + assert.equal(logged.at(-1).action, 'auth.sso.link.refused') + assert.doesNotMatch(res.redirectedTo, /bob/, 'a module\'s own words never reach the URL') +}) + +test('a module that throws or stalls refuses the link (fails closed)', async () => { + hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { throw new Error('db down') } }] + const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.equal(res.redirectedTo, '/account?link_error=refused') + assert.equal(linkArgs, null) +}) + +test('a linked hook that fails never undoes the link', async () => { + hooks.onIdentityLinked = [{ id: 'rust', hook: async () => { throw new Error('boom') } }] + const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.equal(res.redirectedTo, '/account?linked=steam') + assert.ok(linkArgs) +}) + +test('linking a Steam account already yours refreshes it and asks no module', async () => { + let asked = false + hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { asked = true; return { ok: true } } }] + userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 5, provider: 'steam' }) + const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.equal(res.redirectedTo, '/account?linked=steam') + assert.equal(asked, false) + assert.equal(linkArgs, null) + assert.equal(refreshed.id, 3) +}) + +test('checkLink stops at the first refusal and hands a hook a frozen copy', async () => { + let seen + hooks.onIdentityLinking = [ + { id: 'a', hook: async (link) => { seen = link; return { ok: true } } }, + { id: 'b', hook: async () => ({ ok: false, reason: 'anything else' }) }, + { id: 'c', hook: async () => { throw new Error('never reached') } }, + ] + const answer = await identityHooks.checkLink({ userId: 1, provider: 'steam', subject: STEAM_ID, extra: 'x' }) + assert.deepEqual(answer, { ok: false, reason: 'refused', module: 'b' }) + assert.ok(Object.isFrozen(seen)) + assert.deepEqual(Object.keys(seen), ['userId', 'provider', 'subject']) +}) + +test('with no module hooks a link is checked exactly as before', async () => { + assert.deepEqual(await identityHooks.checkLink({ userId: 1, provider: 'google', subject: 's' }), { ok: true }) +}) + +// ── Unlinking one of several (D393 leaves module links alone) ─────────────── + +test('unlink with ?id= removes that one identity only', async () => { + let args + userIdentities.unlink = async (...a) => { args = a; return 1 } + const res = mockRes() + await accountCtrl.unlinkIdentity({ params: { provider: 'steam' }, query: { id: '12' }, user: { id: 5 } }, res) + assert.deepEqual(args, [5, 'steam', 12]) + assert.deepEqual(res.body, { unlinked: true }) +}) + +test('unlink without an id removes every identity of the provider, as before', async () => { + let args + userIdentities.unlink = async (...a) => { args = a; return 2 } + const res = mockRes() + await accountCtrl.unlinkIdentity({ params: { provider: 'google' }, query: {}, user: { id: 5 } }, res) + assert.deepEqual(args, [5, 'google', null]) +}) -- 2.49.1 From 02897030e598e72e278b57ccd7e1e4ae5233bdf2 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 19 Oct 2026 20:33:11 -0500 Subject: [PATCH 2/2] test(auth): a linked Steam account signs in through the mobile bridge Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- server/test/steamSignin.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/test/steamSignin.test.js b/server/test/steamSignin.test.js index 86b5979..8b07904 100644 --- a/server/test/steamSignin.test.js +++ b/server/test/steamSignin.test.js @@ -306,6 +306,20 @@ test('the same refusal holds on the mobile bridge (D395)', async () => { assert.equal(created, null) }) +test('a linked Steam account signs in to the app through the mobile bridge (M9)', async () => { + userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7, provider: 'steam' }) + mobileBridge.getSession = async () => ({ + session_id: 'm2', status: 'pending', redirect_uri: 'runicgateway://sso', state: 'app-state', + expires_at: new Date(Date.now() + 60_000).toISOString(), + }) + mobileBridge.issueAuthCode = async () => ({ code: 'one-time-code' }) + const tx = ssoState.createTx({ provider: 'steam', mode: 'mobile', mobileSessionId: 'm2' }) + const res = mockRes() + await ssoCtrl.callback(steamReq(tx), res) + assert.equal(res.redirectedTo, 'runicgateway://sso?code=one-time-code&state=app-state') + assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'the app gets a code, never a cookie') +}) + test('a forged callback (Steam says is_valid:false) is a refused sign-in, not a server error', async () => { installSteam({ valid: false }) userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7 }) -- 2.49.1