// 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]) })