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
173 lines
7.2 KiB
JavaScript
173 lines
7.2 KiB
JavaScript
// 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
|