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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.' })

View File

@@ -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" } } } } */

View File

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

View File

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

View File

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

View File

@@ -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/<SteamID64>.' }
/* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
ctrl.callback,
)