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