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 }