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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-19 14:02:49 -05:00
parent 6ba2ed90ed
commit 5e398d8acd
30 changed files with 1346 additions and 126 deletions

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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