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 }
|
||||
@@ -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,
|
||||
|
||||
172
server/src/auth/providers/steam.provider.js
Normal file
172
server/src/auth/providers/steam.provider.js
Normal 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
|
||||
Reference in New Issue
Block a user