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:
@@ -1,6 +1,6 @@
|
||||
// ── Admin: auth provider configuration ─────────────────────────────────────
|
||||
//
|
||||
// CRUD for SSO providers. Built-ins (google, discord) are configured here too but
|
||||
// CRUD for SSO providers. Built-ins (google, discord, steam) are configured here too but
|
||||
// can only be enabled/disabled and given a client id/secret — their kind, name,
|
||||
// and endpoints are fixed in code and cannot be edited or deleted. Custom
|
||||
// (oidc/oauth2) providers are fully editable.
|
||||
@@ -80,6 +80,18 @@ async function update(req, res) {
|
||||
// Built-ins: kind/name/priority are fixed; only enable + credentials change.
|
||||
const meta = registry.BUILTINS.find((x) => x.id === id)
|
||||
fields = { kind: meta.kind, name: meta.name, priority: meta.priority, enabled: b.enabled, clientId: b.clientId, secret: b.secret }
|
||||
if (meta.kind === 'steam') {
|
||||
// Steam has no client id; its secret is the Steam Web API key, and it
|
||||
// cannot be enabled without one (D394) — refused here, with a sentence,
|
||||
// rather than saved as an "Incomplete" provider nobody can use.
|
||||
fields.clientId = undefined
|
||||
if (b.enabled === true && !b.secret) {
|
||||
const current = await authProviders.get(id)
|
||||
if (!current || !current.client_secret_enc) {
|
||||
return res.status(400).json({ message: 'Steam needs a Steam Web API key before it can be enabled.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!(await authProviders.get(id))) {
|
||||
return res.status(404).json({ message: 'Provider not found.' })
|
||||
|
||||
@@ -59,11 +59,12 @@ providersRouter.put(
|
||||
'/providers/:id',
|
||||
// #swagger.tags = ['Admin · Auth Providers']
|
||||
// #swagger.summary = 'Update an SSO provider (admin only)'
|
||||
// #swagger.description = 'Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or Steam enabled without a Steam Web API key', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -339,20 +339,36 @@ async function totpDisable(req, res) {
|
||||
async function listIdentities(req, res) {
|
||||
try {
|
||||
const rows = await userIdentities.listForUser(req.user.id)
|
||||
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
|
||||
// `id` lets one identity be unlinked when a provider has several (a player
|
||||
// may link more than one Steam account). `subject` is the user's OWN external
|
||||
// id — for Steam, the SteamID64 shown beside the persona name (D394).
|
||||
return res.json(
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
provider: r.provider,
|
||||
subject: r.subject,
|
||||
email: r.email,
|
||||
display_name: r.display_name || null,
|
||||
avatar_url: r.avatar_url || null,
|
||||
linked_at: r.created_at,
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('listIdentities', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a linked SSO identity from the current user's account.
|
||||
// Remove a linked SSO identity from the current user's account: every identity
|
||||
// of that provider, or with `?id=` the one identity named. Only the sign-in goes —
|
||||
// a module's own link made from it (Rust's, D392) is the module's to remove (D393).
|
||||
async function unlinkIdentity(req, res) {
|
||||
const { provider } = req.params
|
||||
const identityId = req.query.id != null ? Number(req.query.id) : null
|
||||
try {
|
||||
const removed = await userIdentities.unlink(req.user.id, provider)
|
||||
const removed = await userIdentities.unlink(req.user.id, provider, identityId)
|
||||
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider, ...(identityId != null ? { identityId } : {}) } })
|
||||
log.info('sso identity unlinked', { provider, id: req.user.id })
|
||||
return res.json({ unlinked: true })
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
const { body, param, query } = require('express-validator')
|
||||
|
||||
const account = require('./account.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
@@ -177,10 +177,14 @@ meRouter.delete(
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.description = 'Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
// #swagger.parameters['id'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'One identity to unlink (its id from GET /auth/me/account/identities).' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('provider').matches(/^[a-z0-9-]+$/),
|
||||
query('id').optional().isInt({ min: 1 }),
|
||||
validate,
|
||||
account.unlinkIdentity,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// ── SSO (OAuth2 / OIDC) controller ─────────────────────────────────────────
|
||||
//
|
||||
// Drives the redirect flow for built-in (Google, Discord) and custom providers:
|
||||
// Drives the redirect flow for built-in (Google, Discord, Steam) and custom
|
||||
// providers. Steam is OpenID 2.0 rather than OAuth2 (STEAM_SIGNIN.md §3); the
|
||||
// difference is confined to readCallback() and the provider class:
|
||||
// GET /auth/providers → public discovery (enabled + valid providers)
|
||||
// GET /auth/sso/:provider/start → begin login (redirect to the IdP)
|
||||
// GET /auth/sso/:provider/link → begin account linking (requireAuth)
|
||||
@@ -17,6 +19,8 @@ const authProviders = require('../../../model/authProviders/authProviders.model'
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const registry = require('../../../auth/providers/registry')
|
||||
const SteamProvider = require('../../../auth/providers/steam.provider')
|
||||
const identityHooks = require('../../../auth/identityHooks')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const ssoState = require('../../../auth/ssoState')
|
||||
@@ -148,20 +152,43 @@ async function beginFlow(req, res, mode) {
|
||||
const start = (req, res) => beginFlow(req, res, 'login')
|
||||
const linkStart = (req, res) => beginFlow(req, res, 'link')
|
||||
|
||||
// The callback's own answer from the IdP, read the way its protocol writes it.
|
||||
// OAuth2/OIDC sends `code` (or `error`); Steam's OpenID 2.0 sends `openid.*`
|
||||
// fields, with `openid.mode=cancel` when the person declined (STEAM_SIGNIN.md §3).
|
||||
// Our `state` arrives as a query parameter either way — for Steam because it
|
||||
// rides inside the `return_to` we sent.
|
||||
function readCallback(req, providerId) {
|
||||
const q = req.query || {}
|
||||
if (registry.isOpenId(providerId)) {
|
||||
const mode = typeof q['openid.mode'] === 'string' ? q['openid.mode'] : ''
|
||||
return { state: q.state, denied: mode === 'cancel' || mode === 'error', present: mode === 'id_res' }
|
||||
}
|
||||
return { state: q.state, denied: Boolean(q.error), present: Boolean(q.code), error: q.error }
|
||||
}
|
||||
|
||||
// What the provider's handleCallback needs. Steam takes the whole query, and its
|
||||
// `return_to` must match what we sent (the callback URL plus this very state)
|
||||
// byte for byte; OAuth2 takes the code and the PKCE verifier.
|
||||
function callbackParams(req, providerId, tx, answer) {
|
||||
const redirectUri = redirectUriFor(req, providerId)
|
||||
if (registry.isOpenId(providerId)) return { query: req.query, redirectUri, state: answer.state }
|
||||
return { code: req.query.code, redirectUri, codeVerifier: tx.verifier }
|
||||
}
|
||||
|
||||
// GET /auth/sso/:provider/callback
|
||||
async function callback(req, res) {
|
||||
const providerId = req.params.provider
|
||||
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE]
|
||||
const { code, state, error: oauthError } = req.query
|
||||
const answer = readCallback(req, providerId)
|
||||
// The tx cookie is single-use — clear it no matter the outcome.
|
||||
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
|
||||
|
||||
if (oauthError) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) })
|
||||
if (answer.denied) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(answer.error || 'cancel').slice(0, 60) })
|
||||
return res.redirect(loginError('denied'))
|
||||
}
|
||||
const tx = ssoState.verifyTx(txToken, state)
|
||||
if (!tx || tx.provider !== providerId || !code) {
|
||||
const tx = ssoState.verifyTx(txToken, answer.state)
|
||||
if (!tx || tx.provider !== providerId || !answer.present) {
|
||||
log.warn('sso callback: bad state', { provider: providerId })
|
||||
return res.redirect(loginError('bad_state'))
|
||||
}
|
||||
@@ -175,20 +202,34 @@ async function callback(req, res) {
|
||||
return res.redirect(failFor('unavailable'))
|
||||
}
|
||||
const provider = registry.instantiate(row)
|
||||
const profile = await provider.handleCallback({
|
||||
code,
|
||||
redirectUri: redirectUriFor(req, providerId),
|
||||
codeVerifier: tx.verifier,
|
||||
})
|
||||
const profile = await provider.handleCallback(callbackParams(req, providerId, tx, answer))
|
||||
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
|
||||
if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile)
|
||||
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
||||
} catch (err) {
|
||||
// A Steam assertion that failed one of the §3 checks is a refused sign-in, not
|
||||
// a server fault: it is what a replayed, forged or tampered callback looks like.
|
||||
if (err instanceof SteamProvider.SteamAssertionError) {
|
||||
log.warn('sso callback: steam assertion refused', { provider: providerId, reason: err.reason })
|
||||
return res.redirect(failFor('bad_state'))
|
||||
}
|
||||
log.error('sso callback', err)
|
||||
return res.redirect(failFor('error'))
|
||||
}
|
||||
}
|
||||
|
||||
// A profile's name and avatar written back onto an identity the user already
|
||||
// holds, when the provider reports them (Steam, at every sign-in). Decoration:
|
||||
// a failure here is logged and never stops the sign-in it rides on.
|
||||
async function refreshIdentityProfile(identity, profile) {
|
||||
if (!identity || !(profile.displayName || profile.avatarUrl)) return
|
||||
try {
|
||||
await userIdentities.refreshProfile(identity.id, { displayName: profile.displayName, avatarUrl: profile.avatarUrl })
|
||||
} catch (err) {
|
||||
log.warn('sso: could not refresh a linked identity profile', { provider: identity.provider, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-provision a `player` from an SSO profile when no identity is linked yet
|
||||
// and registration allows SSO sign-up. Derives a unique username (reserved-name
|
||||
// safe) with a bounded retry against the UNIQUE index, captures the provider
|
||||
@@ -288,10 +329,12 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
||||
if (identity) {
|
||||
user = await users.getById(identity.user_id)
|
||||
if (!user) return res.redirect(loginError('not_linked', portal))
|
||||
await refreshIdentityProfile(identity, profile)
|
||||
} else {
|
||||
// Unknown identity: auto-provision only if registration opts into SSO sign-up.
|
||||
// Unknown identity: auto-provision only if registration opts into SSO sign-up,
|
||||
// and never through a provider that signs nobody up (Steam, D395).
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
|
||||
log.warn('sso login refused: no linked account', { provider: providerId })
|
||||
return res.redirect(loginError('not_linked', portal))
|
||||
}
|
||||
@@ -397,7 +440,7 @@ async function requireValidBridgeSession(res, tx, providerId) {
|
||||
// Resolve the linked user for a mobile SSO login (link-only, with opt-in
|
||||
// provisioning when registration mode allows it). On refusal, sends the redirect
|
||||
// and returns null.
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, kind, profile) {
|
||||
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||
if (identity) {
|
||||
const user = await users.getById(identity.user_id)
|
||||
@@ -405,10 +448,11 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
}
|
||||
await refreshIdentityProfile(identity, profile)
|
||||
return user
|
||||
}
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
|
||||
log.warn('mobile sso login refused: no linked account', { provider: providerId })
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
@@ -425,7 +469,7 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
||||
const sess = await requireValidBridgeSession(res, tx, providerId)
|
||||
if (!sess) return
|
||||
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, profile)
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, kind, profile)
|
||||
if (!user) return
|
||||
|
||||
if (user.status && user.status !== 'active') {
|
||||
@@ -555,11 +599,38 @@ async function finishLink(req, res, providerId, tx, profile) {
|
||||
if (existing && existing.user_id !== userId) {
|
||||
return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
|
||||
}
|
||||
if (!existing) {
|
||||
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
if (existing) {
|
||||
// Already theirs: linking twice is not an error, and it is a fresh look at
|
||||
// the name and avatar.
|
||||
await refreshIdentityProfile(existing, profile)
|
||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||
}
|
||||
|
||||
// Modules may refuse before anything is written (API 1.17.0) — Rust, for a
|
||||
// Steam id that is already another account's Rust link (D396). Only ever a
|
||||
// refusal: this user is already signed in, and this decides a link, not a session.
|
||||
const check = await identityHooks.checkLink({ userId, provider: providerId, subject: profile.subject })
|
||||
if (!check.ok) {
|
||||
await activity.log({ req, userId, action: 'auth.sso.link.refused', detail: { provider: providerId, module: check.module, reason: check.reason } })
|
||||
return res.redirect(accountError(check.reason, portal))
|
||||
}
|
||||
|
||||
await userIdentities.link({
|
||||
userId,
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
email: profile.email,
|
||||
displayName: profile.displayName || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
})
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
await identityHooks.notifyLinked({
|
||||
userId,
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
displayName: profile.displayName || null,
|
||||
})
|
||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,13 @@ ssoRouter.get(
|
||||
'/sso/:provider/callback',
|
||||
// #swagger.tags = ['Auth · SSO']
|
||||
// #swagger.summary = 'OAuth redirect target — completes login or linking'
|
||||
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
|
||||
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code.' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie).' }
|
||||
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord, steam).' }
|
||||
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code (OAuth2/OIDC providers).' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter.' }
|
||||
// #swagger.parameters['error'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth error (the user declined, or the IdP refused).' }
|
||||
// #swagger.parameters['openid.mode'] = { in: 'query', required: false, schema: { type: 'string', enum: ['id_res', 'cancel'] }, description: 'Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify.' }
|
||||
// #swagger.parameters['openid.claimed_id'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Steam only: https://steamcommunity.com/openid/id/<SteamID64>.' }
|
||||
/* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
|
||||
ctrl.callback,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user