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:
97
server/src/auth/identityHooks.js
Normal file
97
server/src/auth/identityHooks.js
Normal 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 }
|
||||
Reference in New Issue
Block a user