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