// ── 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 }