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,433 @@
// Sign in with Steam (docs/website/STEAM_SIGNIN.md): the OpenID 2.0 provider's
// checks (§3), the callback through the real SSO controller, D394 (key required),
// D395 (never signs up) and the module hooks behind D392/D396 (MODULE_API 1.17.0).
// Steam itself is a stubbed global fetch; no request leaves the process.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
process.env.APP_BASE_URL = 'https://site.example'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const SteamProvider = require('../src/auth/providers/steam.provider')
const registry = require('../src/auth/providers/registry')
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
const ssoState = require('../src/auth/ssoState')
const token = require('../src/auth/token')
const identityHooks = require('../src/auth/identityHooks')
const loader = require('../src/modules/loader')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const authProviders = require('../src/model/authProviders/authProviders.model')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const settings = require('../src/model/settings/settings.model')
const mobileBridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
const providersCtrl = require('../src/router/v1/admin/authProviders.controller')
const accountCtrl = require('../src/router/v1/auth/account.controller')
const db = require('../src/utils/db')
after(() => db.close())
const STEAM_ID = '76561197960287930'
const CALLBACK = 'https://site.example/api/v1/auth/sso/steam/callback'
const STEAM_ROW = { id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_id: null, client_secret_enc: 'enc', client_secret: 'WEBAPIKEY' }
// ── A stand-in for steamcommunity.com and api.steampowered.com ──────────────
const realFetch = global.fetch
let steam // { valid, verifyStatus, verifyThrows, summary, summaryStatus, calls }
function installSteam(overrides = {}) {
steam = {
valid: true,
verifyStatus: 200,
verifyThrows: false,
summary: { steamid: STEAM_ID, personaname: 'CoolGuy99', avatarfull: 'https://avatars.steamstatic.com/abc_full.jpg' },
summaryStatus: 200,
calls: [],
...overrides,
}
global.fetch = async (url, init = {}) => {
const href = String(url)
steam.calls.push({ href, init })
if (href === SteamProvider.OP_ENDPOINT) {
if (steam.verifyThrows) throw new Error('ECONNRESET')
const body = new URLSearchParams(init.body)
steam.lastVerifyBody = body
return {
ok: steam.verifyStatus === 200,
status: steam.verifyStatus,
text: async () => `ns:http://specs.openid.net/auth/2.0\nis_valid:${steam.valid}\n`,
}
}
if (href.startsWith('https://api.steampowered.com/')) {
return {
ok: steam.summaryStatus === 200,
status: steam.summaryStatus,
json: async () => ({ response: { players: steam.summary ? [steam.summary] : [] } }),
}
}
throw new Error(`unexpected fetch ${href}`)
}
}
afterEach(() => { global.fetch = realFetch })
// What Steam appends to our return_to after a successful sign-in.
function assertion(state, overrides = {}) {
const claimed = `https://steamcommunity.com/openid/id/${STEAM_ID}`
return {
state,
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.mode': 'id_res',
'openid.op_endpoint': SteamProvider.OP_ENDPOINT,
'openid.claimed_id': claimed,
'openid.identity': claimed,
'openid.return_to': SteamProvider.returnToFor(CALLBACK, state),
'openid.response_nonce': '2026-10-19T12:00:00Zabc',
'openid.assoc_handle': '1234567890',
'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
'openid.sig': 'c2lnbmF0dXJl',
...overrides,
}
}
// ── The provider (§3) ───────────────────────────────────────────────────────
test('the authorization URL is an OpenID 2.0 checkid_setup with our state inside return_to', () => {
const url = new URL(new SteamProvider({}).getAuthorizationUrl('nonce-1', { redirectUri: CALLBACK }))
assert.equal(url.origin + url.pathname, SteamProvider.OP_ENDPOINT)
assert.equal(url.searchParams.get('openid.mode'), 'checkid_setup')
assert.equal(url.searchParams.get('openid.return_to'), `${CALLBACK}?state=nonce-1`)
assert.equal(url.searchParams.get('openid.realm'), 'https://site.example')
assert.equal(url.searchParams.get('openid.claimed_id'), 'http://specs.openid.net/auth/2.0/identifier_select')
assert.equal(url.searchParams.get('client_id'), null, 'Steam has no client id')
})
test('parseClaimedId accepts only a 17-digit id on steamcommunity.com', () => {
assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}`), STEAM_ID)
assert.equal(SteamProvider.parseClaimedId('http://steamcommunity.com/openid/id/76561197960287930'), null)
assert.equal(SteamProvider.parseClaimedId('https://steamcommunity.com/openid/id/123'), null)
assert.equal(SteamProvider.parseClaimedId('https://evil.example/openid/id/76561197960287930'), null)
assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}/x`), null)
assert.equal(SteamProvider.parseClaimedId(undefined), null)
})
test('a valid assertion is verified with Steam and yields the id, persona name and avatar', async () => {
installSteam()
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
assert.deepEqual(profile, {
subject: STEAM_ID, email: null, emailVerified: false, name: 'CoolGuy99',
displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg',
})
// The verification echoed every openid field back with mode check_authentication,
// and did not forward our own `state`.
assert.equal(steam.lastVerifyBody.get('openid.mode'), 'check_authentication')
assert.equal(steam.lastVerifyBody.get('openid.sig'), 'c2lnbmF0dXJl')
assert.equal(steam.lastVerifyBody.get('state'), null)
})
for (const [label, overrides, state] of [
['a wrong op_endpoint', { 'openid.op_endpoint': 'https://evil.example/openid/login' }, 's1'],
['a return_to that is not ours', { 'openid.return_to': 'https://evil.example/cb?state=s1' }, 's1'],
['a claimed id that is not a Steam id', { 'openid.claimed_id': 'https://steamcommunity.com/openid/id/1', 'openid.identity': 'https://steamcommunity.com/openid/id/1' }, 's1'],
['an identity that differs from the claimed id', { 'openid.identity': 'https://steamcommunity.com/openid/id/76561197960287931' }, 's1'],
['mode cancel', { 'openid.mode': 'cancel' }, 's1'],
]) {
test(`${label} is refused before Steam is even asked`, async () => {
installSteam()
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
await assert.rejects(
p.handleCallback({ query: assertion(state, overrides), redirectUri: CALLBACK, state }),
SteamProvider.SteamAssertionError,
)
assert.equal(steam.calls.length, 0)
})
}
test('an assertion replayed into another sign-in fails: its return_to carries the old state', async () => {
installSteam()
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
// Captured from a sign-in whose state was s-old, replayed against a tx whose state is s-new.
const replay = { ...assertion('s-old'), state: 's-new' }
await assert.rejects(p.handleCallback({ query: replay, redirectUri: CALLBACK, state: 's-new' }), { reason: 'return_to' })
})
test('check_authentication saying is_valid:false refuses the sign-in', async () => {
installSteam({ valid: false })
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
})
test('Steam unreachable for verification is a refusal, never a pass', async () => {
installSteam({ verifyThrows: true })
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
installSteam({ verifyStatus: 503 })
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
})
test('a failed Web API lookup still signs in, with the bare id (D394)', async () => {
installSteam({ summaryStatus: 403 })
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
assert.equal(profile.subject, STEAM_ID)
assert.equal(profile.displayName, null)
assert.equal(profile.avatarUrl, null)
})
test('an avatar that is not on a Steam CDN is dropped', async () => {
installSteam({ summary: { steamid: STEAM_ID, personaname: 'X', avatarfull: 'https://evil.example/a.jpg' } })
const profile = await new SteamProvider({ clientSecret: 'K' }).handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
assert.equal(profile.displayName, 'X')
assert.equal(profile.avatarUrl, null)
})
// ── Registry and Admin → Auth (D394) ────────────────────────────────────────
test('Steam is a built-in that needs only a Web API key', () => {
assert.ok(registry.isBuiltin('steam'))
assert.deepEqual(registry.validateConfig({ kind: 'steam' }), { valid: false, missing: ['api_key'] })
assert.deepEqual(registry.validateConfig({ kind: 'steam', client_secret_enc: 'e' }), { valid: true, missing: [] })
assert.equal(registry.neverProvisions('steam'), true)
assert.equal(registry.neverProvisions('google'), false)
})
function mockRes() {
return {
statusCode: 200, redirectedTo: null, cookies: {}, cleared: [],
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
redirect(u) { this.redirectedTo = u; return this },
cookie(n, v) { this.cookies[n] = v; return this },
clearCookie(n) { this.cleared.push(n); return this },
}
}
test('enabling Steam with no key is refused with a sentence; with one it saves', async () => {
let saved = null
authProviders.get = async () => null
authProviders.save = async (id, fields) => { saved = { id, ...fields }; return { id, kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' } }
activity.log = async () => {}
const refused = mockRes()
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, refused)
assert.equal(refused.statusCode, 400)
assert.match(refused.body.message, /Steam Web API key/)
assert.equal(saved, null)
const ok = mockRes()
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true, secret: 'WEBAPIKEY', clientId: 'ignored' }, user: { username: 'admin' } }, ok)
assert.equal(ok.statusCode, 200)
assert.equal(saved.kind, 'steam')
assert.equal(saved.secret, 'WEBAPIKEY')
assert.equal(saved.clientId, undefined, 'Steam keeps no client id')
assert.equal(ok.body.hasSecret, true)
})
test('re-enabling Steam with a key already stored needs no new one', async () => {
authProviders.get = async () => ({ id: 'steam', kind: 'steam', client_secret_enc: 'e' })
authProviders.save = async () => ({ id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' })
activity.log = async () => {}
const res = mockRes()
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, res)
assert.equal(res.statusCode, 200)
})
// ── The callback through the real controller ────────────────────────────────
let logged
let linkArgs
let refreshed
let created
let hooks
beforeEach(() => {
logged = []
linkArgs = null
refreshed = null
created = null
hooks = { onIdentityLinking: [], onIdentityLinked: [] }
activity.log = async (evt) => { logged.push(evt) }
authProviders.getWithSecret = async () => ({ ...STEAM_ROW })
registry.instantiate = (row) => new SteamProvider({ ...row, clientSecret: row.client_secret })
userIdentities.findByProviderSubject = async () => null
userIdentities.link = async (args) => { linkArgs = args; return 1 }
userIdentities.refreshProfile = async (id, p) => { refreshed = { id, ...p }; return 1 }
users.getById = async (id) => ({ id, username: 'alice', role: 'player', status: 'active' })
users.recordLogin = async () => {}
users.createUser = async (args) => { created = args; return { id: 99, ...args } }
settings.getRegistrationMode = async () => 'both' // SSO sign-up ON: Steam must still refuse
loader.identityHooks = (name) => hooks[name]
installSteam()
})
function steamReq(tx, overrides = {}) {
return {
params: { provider: 'steam' },
cookies: { [ssoState.TX_COOKIE]: tx.txToken },
query: assertion(tx.nonce, overrides),
ip: '127.0.0.1', protocol: 'https', get: () => 'site.example', headers: {},
}
}
test('a linked Steam account signs in through the one session service, and its name is refreshed', async () => {
userIdentities.findByProviderSubject = async (provider, subject) =>
(provider === 'steam' && subject === STEAM_ID ? { id: 3, user_id: 7, provider: 'steam' } : null)
const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set')
assert.equal(res.redirectedTo, '/account')
assert.equal(logged.at(-1).action, 'auth.sso.login')
assert.deepEqual(refreshed, { id: 3, displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg' })
})
test('an unlinked Steam id signs nobody in and creates nobody, even with SSO sign-up on (D395)', async () => {
const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.equal(res.redirectedTo, '/account/login?sso_error=not_linked')
assert.equal(created, null)
assert.equal(linkArgs, null)
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
})
test('the same refusal holds on the mobile bridge (D395)', async () => {
mobileBridge.getSession = async () => ({
session_id: 'm1', status: 'pending', redirect_uri: 'runicgateway://sso', state: 'app-state',
expires_at: new Date(Date.now() + 60_000).toISOString(),
})
const tx = ssoState.createTx({ provider: 'steam', mode: 'mobile', mobileSessionId: 'm1' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.equal(res.redirectedTo, 'runicgateway://sso?error=not_linked&state=app-state')
assert.equal(created, null)
})
test('a forged callback (Steam says is_valid:false) is a refused sign-in, not a server error', async () => {
installSteam({ valid: false })
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7 })
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
})
test('cancelling on steamcommunity.com reads as denied', async () => {
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback({ ...steamReq(tx), query: { state: tx.nonce, 'openid.mode': 'cancel' } }, res)
assert.equal(res.redirectedTo, '/admin/login?sso_error=denied')
assert.equal(steam.calls.length, 0)
})
test('a callback with a state that is not the tx is refused before Steam is asked', async () => {
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
const res = mockRes()
const req = steamReq(tx)
req.query.state = 'tampered'
await ssoCtrl.callback(req, res)
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
assert.equal(steam.calls.length, 0)
})
// ── Linking, and the module hooks (MODULE_API 1.17.0) ───────────────────────
test('linking Steam stores the id with its name and avatar, then tells the modules once (D392)', async () => {
const told = []
hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }]
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.deepEqual(linkArgs, {
userId: 5, provider: 'steam', subject: STEAM_ID, email: null,
displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg',
})
assert.equal(res.redirectedTo, '/account?linked=steam')
assert.deepEqual(told, [{ userId: 5, provider: 'steam', subject: STEAM_ID, displayName: 'CoolGuy99' }])
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
})
test('a module refusing a link writes nothing and the player reads "in use" (D396)', async () => {
const told = []
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => ({ ok: false, reason: 'in_use', message: 'held by bob' }) }]
hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }]
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.equal(res.redirectedTo, '/account?link_error=in_use')
assert.equal(linkArgs, null)
assert.deepEqual(told, [])
assert.equal(logged.at(-1).action, 'auth.sso.link.refused')
assert.doesNotMatch(res.redirectedTo, /bob/, 'a module\'s own words never reach the URL')
})
test('a module that throws or stalls refuses the link (fails closed)', async () => {
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { throw new Error('db down') } }]
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.equal(res.redirectedTo, '/account?link_error=refused')
assert.equal(linkArgs, null)
})
test('a linked hook that fails never undoes the link', async () => {
hooks.onIdentityLinked = [{ id: 'rust', hook: async () => { throw new Error('boom') } }]
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.equal(res.redirectedTo, '/account?linked=steam')
assert.ok(linkArgs)
})
test('linking a Steam account already yours refreshes it and asks no module', async () => {
let asked = false
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { asked = true; return { ok: true } } }]
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 5, provider: 'steam' })
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
const res = mockRes()
await ssoCtrl.callback(steamReq(tx), res)
assert.equal(res.redirectedTo, '/account?linked=steam')
assert.equal(asked, false)
assert.equal(linkArgs, null)
assert.equal(refreshed.id, 3)
})
test('checkLink stops at the first refusal and hands a hook a frozen copy', async () => {
let seen
hooks.onIdentityLinking = [
{ id: 'a', hook: async (link) => { seen = link; return { ok: true } } },
{ id: 'b', hook: async () => ({ ok: false, reason: 'anything else' }) },
{ id: 'c', hook: async () => { throw new Error('never reached') } },
]
const answer = await identityHooks.checkLink({ userId: 1, provider: 'steam', subject: STEAM_ID, extra: 'x' })
assert.deepEqual(answer, { ok: false, reason: 'refused', module: 'b' })
assert.ok(Object.isFrozen(seen))
assert.deepEqual(Object.keys(seen), ['userId', 'provider', 'subject'])
})
test('with no module hooks a link is checked exactly as before', async () => {
assert.deepEqual(await identityHooks.checkLink({ userId: 1, provider: 'google', subject: 's' }), { ok: true })
})
// ── Unlinking one of several (D393 leaves module links alone) ───────────────
test('unlink with ?id= removes that one identity only', async () => {
let args
userIdentities.unlink = async (...a) => { args = a; return 1 }
const res = mockRes()
await accountCtrl.unlinkIdentity({ params: { provider: 'steam' }, query: { id: '12' }, user: { id: 5 } }, res)
assert.deepEqual(args, [5, 'steam', 12])
assert.deepEqual(res.body, { unlinked: true })
})
test('unlink without an id removes every identity of the provider, as before', async () => {
let args
userIdentities.unlink = async (...a) => { args = a; return 2 }
const res = mockRes()
await accountCtrl.unlinkIdentity({ params: { provider: 'google' }, query: {}, user: { id: 5 } }, res)
assert.deepEqual(args, [5, 'google', null])
})