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:
2026-10-19 14:02:49 -05:00
parent 6ba2ed90ed
commit 5e398d8acd
30 changed files with 1346 additions and 126 deletions

View File

@@ -133,8 +133,12 @@ export const api = {
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }), totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link. // Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
myIdentities: () => req('/auth/me/account/identities'), myIdentities: () => req('/auth/me/account/identities'),
unlinkIdentity: (provider) => // With an identity id, only that one goes (a person may hold several Steam accounts).
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }), unlinkIdentity: (provider, id) =>
req(
`/auth/me/account/identities/${encodeURIComponent(provider)}${id != null ? `?id=${encodeURIComponent(id)}` : ''}`,
{ method: 'DELETE' },
),
// Recovery (backup) codes. status → remaining count; generate → a fresh set, // Recovery (backup) codes. status → remaining count; generate → a fresh set,
// returned ONCE (password step-up for accounts that have a password). // returned ONCE (password step-up for accounts that have a password).
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'), recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),

View File

@@ -1,6 +1,6 @@
// Inline SVG brand icons for SSO providers. No binary assets — these scale // Inline SVG brand icons for SSO providers. No binary assets — these scale
// crisply at any size and keep their own brand colors. `icon` matches the // crisply at any size and keep their own brand colors. `icon` matches the
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2). // provider `kind` from the discovery endpoint ('google' | 'discord' | 'steam' | oidc/oauth2).
// Anything unknown falls back to a neutral key glyph in the current text color. // Anything unknown falls back to a neutral key glyph in the current text color.
function GoogleMark({ size }) { function GoogleMark({ size }) {
@@ -22,6 +22,16 @@ function DiscordMark({ size }) {
) )
} }
// Steam's mark in the current text color: its brand ink is near-black, which
// would vanish on the dark theme.
function SteamMark({ size }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
</svg>
)
}
function GenericMark({ size }) { function GenericMark({ size }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false"> <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
@@ -33,5 +43,6 @@ function GenericMark({ size }) {
export default function ProviderIcon({ icon, size = 18 }) { export default function ProviderIcon({ icon, size = 18 }) {
if (icon === 'google') return <GoogleMark size={size} /> if (icon === 'google') return <GoogleMark size={size} />
if (icon === 'discord') return <DiscordMark size={size} /> if (icon === 'discord') return <DiscordMark size={size} />
if (icon === 'steam') return <SteamMark size={size} />
return <GenericMark size={size} /> return <GenericMark size={size} />
} }

View File

@@ -0,0 +1,66 @@
import ProviderIcon from '../ProviderIcon.jsx'
// What the player's and the staff account pages share about a linked sign-in
// identity. Steam is the one provider that differs (STEAM_SIGNIN.md): a person may
// link several Steam accounts, each shows its persona name, avatar and SteamID64,
// and unlinking one removes only the sign-in — a game link made from it stays
// (D393).
// Providers a person may link more than one account of.
const MULTI_LINK = new Set(['steam'])
export function canLinkAgain(providerId) {
return MULTI_LINK.has(providerId)
}
export function identityIcon(provider) {
return provider === 'google' || provider === 'discord' || provider === 'steam' ? provider : 'oidc'
}
// The outcome of a link round trip, from the query string it returns with.
export function linkBanner(search) {
const q = new URLSearchParams(search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user on this site.' }
if (q.get('link_error') === 'refused') return { ok: false, text: 'That account cannot be linked right now. Please try again later.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
}
// Providers to offer a Link button for: every enabled one not yet linked, and a
// multi-link one (Steam) always.
export function linkableProviders(available, linked) {
const linkedIds = new Set(linked.map((i) => i.provider))
return available.filter((p) => !linkedIds.has(p.id) || canLinkAgain(p.id))
}
export function unlinkPrompt(identity, providerName) {
if (identity.provider === 'steam') {
const who = identity.display_name ? `${identity.display_name} (${identity.subject})` : identity.subject
return `Unlink the Steam account ${who}? You will no longer be able to sign in with it. A game link made with it stays until you remove it on that game's page.`
}
return `Unlink ${providerName} from your account?`
}
// The icon (or Steam avatar), the provider name, and what identifies the account.
export function IdentitySummary({ identity, providerName }) {
const isSteam = identity.provider === 'steam'
return (
<>
<span style={{ display: 'inline-flex', width: 20, height: 20, color: 'var(--head)', flex: '0 0 auto' }}>
{isSteam && identity.avatar_url ? (
<img src={identity.avatar_url} alt="" width={20} height={20} style={{ borderRadius: 4 }} referrerPolicy="no-referrer" />
) : (
<ProviderIcon icon={identityIcon(identity.provider)} size={20} />
)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem', overflowWrap: 'anywhere' }}>
{isSteam && identity.display_name ? `${providerName}: ${identity.display_name}` : providerName}
</div>
{isSteam && identity.subject && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.subject}</div>}
{!isSteam && identity.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.email}</div>}
</div>
</>
)
}

View File

@@ -11,6 +11,10 @@
// that the two files can drift, so a test asserts they agree // that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both. // both.
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
// Server-side; the client half is core's own account pages showing Steam. This
// file bumps for the reason at the top.
// 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a // 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a
// module's advice, shown by core's own event editor as warning findings. // module's advice, shown by core's own event editor as warning findings.
// Server-side; this file bumps for the reason at the top. // Server-side; this file bumps for the reason at the top.
@@ -94,4 +98,4 @@
// but the two halves state ONE version: a module declares a single coreApi range // but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server // and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question. // answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.16.0' export const MODULE_API_VERSION = '1.17.0'

View File

@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx' import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx' import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx' import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through // Link/unlink external SSO identities to this account. Linking redirects through
@@ -15,13 +16,7 @@ function LinkedAccounts() {
const [available, setAvailable] = useState([]) const [available, setAvailable] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const banner = (() => { const banner = linkBanner(window.location.search)
const q = new URLSearchParams(window.location.search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
})()
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -40,12 +35,11 @@ function LinkedAccounts() {
}, [load]) }, [load])
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1) const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
async function unlink(provider) { async function unlink(identity) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
try { try {
await api.unlinkIdentity(provider) await api.unlinkIdentity(identity.provider, identity.id)
await load() await load()
} catch (err) { } catch (err) {
setError(err.message || 'Could not unlink.') setError(err.message || 'Could not unlink.')
@@ -55,8 +49,7 @@ function LinkedAccounts() {
if (error) return <ErrorState message={error} /> if (error) return <ErrorState message={error} />
if (!linked) return null if (!linked) return null
const linkedIds = new Set(linked.map((i) => i.provider)) const linkable = linkableProviders(available, linked)
const linkable = available.filter((p) => !linkedIds.has(p.id))
return ( return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}> <div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
@@ -64,7 +57,7 @@ function LinkedAccounts() {
Linked accounts Linked accounts
</h2> </h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}> <p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in Link a Google, Discord, Steam, or other SSO account so you can sign in with it. SSO can only sign in
to an account it is linked to — linking here is what grants that access. to an account it is linked to — linking here is what grants that access.
</p> </p>
@@ -77,15 +70,9 @@ function LinkedAccounts() {
{linked.length > 0 && ( {linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{linked.map((i) => ( {linked.map((i) => (
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}> <div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ display: 'inline-flex', width: 20, height: 20 }}> <IdentitySummary identity={i} providerName={nameFor(i.provider)} />
<ProviderIcon icon={iconFor(i.provider)} size={20} /> <button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
</div>
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Unlink Unlink
</button> </button>
</div> </div>

View File

@@ -5,12 +5,14 @@ import { api } from '../../../api/client.js'
// Admin config for authentication providers. Local password + TOTP is always on // Admin config for authentication providers. Local password + TOTP is always on
// (informational tab). Google/Discord are built-ins with a fixed config surface // (informational tab). Google/Discord are built-ins with a fixed config surface
// (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor. // (Enabled + Client ID + Client Secret); Steam is a built-in with Enabled + a
// Steam Web API key. Custom providers use the full OIDC editor.
const TABS = [ const TABS = [
{ id: 'local', label: 'Local Accounts' }, { id: 'local', label: 'Local Accounts' },
{ id: 'google', label: 'Google' }, { id: 'google', label: 'Google' },
{ id: 'discord', label: 'Discord' }, { id: 'discord', label: 'Discord' },
{ id: 'steam', label: 'Steam' },
{ id: 'custom', label: 'Custom Providers' }, { id: 'custom', label: 'Custom Providers' },
] ]
@@ -141,6 +143,87 @@ function BuiltinForm({ provider, onSaved }) {
) )
} }
// ── Steam (OpenID 2.0) ─────────────────────────────────────────────────────
// No client id and no redirect URL to register: Steam's sign-in takes neither.
// What it needs is a Steam Web API key, without which it cannot be enabled
// (STEAM_SIGNIN.md D394) — the key is how a linked account shows its persona
// name and avatar. Steam never creates an account (D395): a player links Steam
// to the account they already have.
function SteamForm({ provider, onSaved }) {
const [enabled, setEnabled] = useState(provider.enabled)
const [apiKey, setApiKey] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function save() {
setBusy(true)
setMsg('')
setError('')
try {
const body = { enabled }
if (apiKey) body.secret = apiKey // only send a new key when entered
await api.admin.updateAuthProvider(provider.id, body)
setApiKey('')
setMsg('Saved.')
await onSaved()
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
return (
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ display: 'inline-flex', width: 26, height: 26, color: 'var(--head)' }}>
<ProviderIcon icon="steam" size={26} />
</span>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
{provider.name}
</h2>
</div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Players link their Steam account from their account page and can then sign in with it. Steam never
creates an account: an unlinked Steam account signs nobody in, whatever the registration setting.
</p>
<Toggle checked={enabled} onChange={setEnabled} label="Enable this sign-in method" />
<HealthWarning provider={provider} />
<label style={{ display: 'block' }}>
<span className="field-label">Steam Web API key (required)</span>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="input"
autoComplete="new-password"
placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Steam Web API key'}
/>
</label>
<p className="sans dim" style={{ margin: '-8px 0 0', fontSize: '0.82rem', lineHeight: 1.5 }}>
Get one at{' '}
<a href="https://steamcommunity.com/dev/apikey" target="_blank" rel="noreferrer" className="link-accent">
steamcommunity.com/dev/apikey
</a>{' '}
with this site&apos;s domain. It is used only to show a linked account&apos;s Steam name and avatar, and is
never sent to a browser. No redirect URL needs registering with Steam.
</p>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save changes'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}
// ── Local accounts (informational) ───────────────────────────────────────── // ── Local accounts (informational) ─────────────────────────────────────────
function LocalInfo() { function LocalInfo() {
return ( return (
@@ -376,6 +459,7 @@ export default function AuthProvidersAdmin() {
{tab === 'local' && <LocalInfo />} {tab === 'local' && <LocalInfo />}
{tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />} {tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />}
{tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />} {tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />}
{tab === 'steam' && byId('steam') && <SteamForm key="steam" provider={byId('steam')} onSaved={load} />}
{tab === 'custom' && <CustomProviders items={customs} onChanged={load} />} {tab === 'custom' && <CustomProviders items={customs} onChanged={load} />}
</section> </section>
) )

View File

@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx' import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx' import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx' import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
@@ -224,13 +225,7 @@ function LinkedAccounts() {
const [available, setAvailable] = useState([]) const [available, setAvailable] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const banner = (() => { const banner = linkBanner(window.location.search)
const q = new URLSearchParams(window.location.search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
})()
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -247,12 +242,11 @@ function LinkedAccounts() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1) const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
async function unlink(provider) { async function unlink(identity) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
try { try {
await api.unlinkIdentity(provider) await api.unlinkIdentity(identity.provider, identity.id)
await load() await load()
} catch (err) { } catch (err) {
setError(err.message || 'Could not unlink.') setError(err.message || 'Could not unlink.')
@@ -262,13 +256,12 @@ function LinkedAccounts() {
if (error) return <ErrorState message={error} /> if (error) return <ErrorState message={error} />
if (!linked) return null if (!linked) return null
const linkedIds = new Set(linked.map((i) => i.provider)) const linkable = linkableProviders(available, linked)
const linkable = available.filter((p) => !linkedIds.has(p.id))
return ( return (
<Section title="Linked accounts"> <Section title="Linked accounts">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}> <p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Link a Google, Discord, or other provider so you can sign in with it. Link a Google, Discord, Steam, or other provider so you can sign in with it.
</p> </p>
{banner && ( {banner && (
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p> <p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
@@ -276,15 +269,9 @@ function LinkedAccounts() {
{linked.length > 0 && ( {linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{linked.map((i) => ( {linked.map((i) => (
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}> <div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ display: 'inline-flex', width: 20, height: 20 }}> <IdentitySummary identity={i} providerName={nameFor(i.provider)} />
<ProviderIcon icon={iconFor(i.provider)} size={20} /> <button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
</div>
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
</div> </div>
))} ))}
</div> </div>

View File

@@ -179,7 +179,7 @@ CREATE TABLE IF NOT EXISTS activity_log (
-- custom (oidc/oauth2) providers. -- custom (oidc/oauth2) providers.
CREATE TABLE IF NOT EXISTS auth_providers ( CREATE TABLE IF NOT EXISTS auth_providers (
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
kind ENUM('google','discord','oidc','oauth2') NOT NULL, kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL,
name VARCHAR(80) NOT NULL, name VARCHAR(80) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0, enabled TINYINT(1) NOT NULL DEFAULT 0,
client_id VARCHAR(255) NULL, client_id VARCHAR(255) NULL,
@@ -203,6 +203,8 @@ CREATE TABLE IF NOT EXISTS user_identities (
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id) subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
email VARCHAR(255) NULL, email VARCHAR(255) NULL,
display_name VARCHAR(191) NULL, -- Steam persona name (D394); null for other providers
avatar_url VARCHAR(500) NULL, -- Steam avatar, a Steam CDN https URL; null for others
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_identity_provider_subject (provider, subject), UNIQUE KEY uq_identity_provider_subject (provider, subject),
@@ -2744,3 +2746,12 @@ CREATE TABLE IF NOT EXISTS event_trigger_starts (
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Sign in with Steam (STEAM_SIGNIN.md §4). Steam is a built-in provider beside
-- Google and Discord, so its row needs the kind; and a linked Steam account
-- shows its persona name and avatar (D394), kept on the identity and refreshed
-- at each Steam sign-in. Both additive. MODIFY has no IF NOT EXISTS form, but
-- re-declaring the same ENUM is an idempotent no-op.
ALTER TABLE auth_providers MODIFY COLUMN kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL;
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS display_name VARCHAR(191) NULL;
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500) NULL;

View File

@@ -1,6 +1,6 @@
{ {
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.16.0", "moduleApiVersion": "1.17.0",
"triggers": [ "triggers": [
{ {
"id": "event.phase.changed", "id": "event.phase.changed",

View File

@@ -1834,7 +1834,7 @@
{ {
"method": "DELETE", "method": "DELETE",
"path": "/api/v1/auth/me/account/identities/:provider", "path": "/api/v1/auth/me/account/identities/:provider",
"handlers": 3, "handlers": 4,
"gates": [ "gates": [
"noindex", "noindex",
"requireAuth", "requireAuth",

View File

@@ -0,0 +1,97 @@
// ── Modules and identity links (MODULE_API 1.17.0) ─────────────────────────
//
// Core owns linking an external sign-in identity to an account. Modules take part
// in exactly two places (STEAM_SIGNIN.md §4):
//
// checkLink() before the identity is written. Each module that registered
// `onIdentityLinking` may refuse. Rust uses it for D396: a Steam
// id that is already another website account's Rust link.
// notifyLinked() after it is written. Rust uses it for D392: the Steam account
// becomes a linked Rust player.
//
// A refusal can only stop a link. It runs inside the link flow of a user who is
// already signed in, so nothing here can mint a session or let through a link
// core would refuse.
//
// What a module says is never shown to the player: `reason: 'in_use'` reads as
// the same "already linked to another user" core says itself, and anything else
// as a plain refusal. A module's own words go to the log. Putting module text in
// a redirect URL would let anybody craft a link that prints a sentence of their
// choosing on our account page.
const loader = require('../modules/loader')
const log = require('../utils/logger')('sso')
const HOOK_TIMEOUT_MS = 5_000
function withTimeout(fn, ms) {
let timer
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms)
})
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer))
}
/**
* Ask every started module whether this link may be made.
*
* **Fails closed.** A module that throws or does not answer in time refuses the
* link: the hook exists to stop a link that must not happen, and a module that
* could not look is a module that could not say it was safe. The player is told
* to try again; a link is never the urgent half of anything.
*
* @param {{userId: number, provider: string, subject: string}} link
* @returns {Promise<{ok: true} | {ok: false, reason: 'in_use' | 'refused', module: string}>}
*/
async function checkLink(link) {
const frozen = Object.freeze({ userId: link.userId, provider: link.provider, subject: link.subject })
for (const { id, hook } of loader.identityHooks('onIdentityLinking')) {
let answer
try {
answer = await withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS)
} catch (err) {
log.error('identity link check failed; refusing the link', { module: id, provider: frozen.provider, message: err.message })
return { ok: false, reason: 'refused', module: id }
}
if (answer && answer.ok === false) {
const reason = answer.reason === 'in_use' ? 'in_use' : 'refused'
log.info('identity link refused by a module', {
module: id,
provider: frozen.provider,
userId: frozen.userId,
reason,
message: typeof answer.message === 'string' ? answer.message.slice(0, 200) : undefined,
})
return { ok: false, reason, module: id }
}
}
return { ok: true }
}
/**
* Tell every started module a link was written.
*
* Awaited (bounded) so the page the player lands on already shows what a module
* made of it — the Rust page listing the Steam account they just linked — but a
* failure never reaches the link: the identity is written, and that is core's
* answer. A module that missed it is logged.
*
* @param {{userId: number, provider: string, subject: string, displayName?: string|null}} link
*/
async function notifyLinked(link) {
const frozen = Object.freeze({
userId: link.userId,
provider: link.provider,
subject: link.subject,
displayName: link.displayName || null,
})
await Promise.all(
loader.identityHooks('onIdentityLinked').map(({ id, hook }) =>
withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS).catch((err) => {
log.error('identity linked hook failed', { module: id, provider: frozen.provider, message: err.message })
}),
),
)
}
module.exports = { checkLink, notifyLinked, HOOK_TIMEOUT_MS }

View File

@@ -12,6 +12,7 @@
const GoogleProvider = require('./google.provider') const GoogleProvider = require('./google.provider')
const DiscordProvider = require('./discord.provider') const DiscordProvider = require('./discord.provider')
const GenericOidcProvider = require('./genericOidc.provider') const GenericOidcProvider = require('./genericOidc.provider')
const SteamProvider = require('./steam.provider')
const authProviders = require('../../model/authProviders/authProviders.model') const authProviders = require('../../model/authProviders/authProviders.model')
// kind → provider class. // kind → provider class.
@@ -20,6 +21,7 @@ const KINDS = {
discord: DiscordProvider, discord: DiscordProvider,
oidc: GenericOidcProvider, oidc: GenericOidcProvider,
oauth2: GenericOidcProvider, oauth2: GenericOidcProvider,
steam: SteamProvider,
} }
// Built-in providers and their fixed display metadata. Endpoints are in the // Built-in providers and their fixed display metadata. Endpoints are in the
@@ -27,6 +29,9 @@ const KINDS = {
const BUILTINS = [ const BUILTINS = [
{ id: 'google', kind: 'google', name: 'Google', priority: 1 }, { id: 'google', kind: 'google', name: 'Google', priority: 1 },
{ id: 'discord', kind: 'discord', name: 'Discord', priority: 2 }, { id: 'discord', kind: 'discord', name: 'Discord', priority: 2 },
// OpenID 2.0, not OAuth2 (STEAM_SIGNIN.md §3): no client id, and the "secret"
// is the Steam Web API key it cannot be enabled without (D394).
{ id: 'steam', kind: 'steam', name: 'Steam', priority: 3 },
] ]
const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id)) const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id))
@@ -35,6 +40,18 @@ function isBuiltin(id) {
return BUILTIN_IDS.has(id) return BUILTIN_IDS.has(id)
} }
// Providers whose callback is OpenID 2.0 (`openid.*` fields) rather than an OAuth2
// `code`. Steam is the only one, and only as the built-in: a custom provider can
// only be oidc/oauth2, so the kind is known from the id before any row is read.
function isOpenId(id) {
return id === 'steam'
}
// Providers that never sign anybody up, whatever player_registration says (D395).
function neverProvisions(kind) {
return kind === 'steam'
}
// Instantiate a provider from a config row (secret already decrypted by the // Instantiate a provider from a config row (secret already decrypted by the
// model as `client_secret`). Returns null for an unknown kind. // model as `client_secret`). Returns null for an unknown kind.
function instantiate(row) { function instantiate(row) {
@@ -66,6 +83,11 @@ async function load(id) {
// custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }. // custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }.
function validateConfig(row) { function validateConfig(row) {
const missing = [] const missing = []
if (row.kind === 'steam') {
// No client id; the Web API key is the one thing it needs (D394).
if (!row.client_secret_enc && !row.client_secret) missing.push('api_key')
return { valid: missing.length === 0, missing }
}
if (!row.client_id) missing.push('client_id') if (!row.client_id) missing.push('client_id')
// A stored secret shows up as client_secret_enc on plain rows, or client_secret // A stored secret shows up as client_secret_enc on plain rows, or client_secret
// on decrypted rows — accept either as "has a secret". // on decrypted rows — accept either as "has a secret".
@@ -110,7 +132,7 @@ async function listEnabledValid() {
.map((p) => ({ .map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
icon: p.kind, // 'google' | 'discord' | 'oidc' | 'oauth2' icon: p.kind, // 'google' | 'discord' | 'steam' | 'oidc' | 'oauth2'
loginUrl: `/api/v1/auth/sso/${p.id}/start`, loginUrl: `/api/v1/auth/sso/${p.id}/start`,
priority: p.priority ?? 100, priority: p.priority ?? 100,
})) }))
@@ -120,6 +142,8 @@ module.exports = {
KINDS, KINDS,
BUILTINS, BUILTINS,
isBuiltin, isBuiltin,
isOpenId,
neverProvisions,
instantiate, instantiate,
load, load,
validateConfig, validateConfig,

View 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

View File

@@ -13,21 +13,45 @@ async function findByProviderSubject(provider, subject) {
// All identities linked to a given internal user (for the Account page). // All identities linked to a given internal user (for the Account page).
async function listForUser(userId) { async function listForUser(userId) {
return query( return query(
'SELECT id, provider, subject, email, created_at FROM user_identities WHERE user_id = ? ORDER BY provider', `SELECT id, provider, subject, email, display_name, avatar_url, created_at
FROM user_identities WHERE user_id = ? ORDER BY provider, created_at`,
[userId], [userId],
) )
} }
async function insert({ userId, provider, subject, email = null }) { async function insert({ userId, provider, subject, email = null, displayName = null, avatarUrl = null }) {
const res = await query( const res = await query(
'INSERT INTO user_identities (user_id, provider, subject, email) VALUES (?, ?, ?, ?)', `INSERT INTO user_identities (user_id, provider, subject, email, display_name, avatar_url)
[userId, provider, subject, email], VALUES (?, ?, ?, ?, ?, ?)`,
[userId, provider, subject, email, displayName, avatarUrl],
) )
return res.insertId return res.insertId
} }
// Remove a user's link to a provider. Returns rows deleted. // Refresh the name and avatar a provider reported at sign-in (Steam, D394). Only
async function deleteForUserProvider(userId, provider) { // what the provider sent is written: a lookup that failed leaves the last good
// values in place rather than blanking them.
async function updateProfile(id, { displayName, avatarUrl }) {
const sets = []
const vals = []
if (displayName) { sets.push('display_name = ?'); vals.push(displayName) }
if (avatarUrl) { sets.push('avatar_url = ?'); vals.push(avatarUrl) }
if (!sets.length) return 0
const res = await query(`UPDATE user_identities SET ${sets.join(', ')} WHERE id = ?`, [...vals, id])
return Number(res.affectedRows || 0)
}
// Remove a user's link to a provider — every identity of it, or, given an
// identity id, that one only (a user may hold several Steam accounts). Returns
// rows deleted.
async function deleteForUserProvider(userId, provider, identityId = null) {
if (identityId != null) {
const res = await query(
'DELETE FROM user_identities WHERE user_id = ? AND provider = ? AND id = ?',
[userId, provider, identityId],
)
return Number(res.affectedRows || 0)
}
const res = await query( const res = await query(
'DELETE FROM user_identities WHERE user_id = ? AND provider = ?', 'DELETE FROM user_identities WHERE user_id = ? AND provider = ?',
[userId, provider], [userId, provider],
@@ -35,4 +59,4 @@ async function deleteForUserProvider(userId, provider) {
return Number(res.affectedRows || 0) return Number(res.affectedRows || 0)
} }
module.exports = { findByProviderSubject, listForUser, insert, deleteForUserProvider } module.exports = { findByProviderSubject, listForUser, insert, updateProfile, deleteForUserProvider }

View File

@@ -15,13 +15,21 @@ async function listForUser(userId) {
// Link an external identity to an internal user. Returns the new row id. The // Link an external identity to an internal user. Returns the new row id. The
// (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB. // (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB.
async function link({ userId, provider, subject, email }) { // `displayName` / `avatarUrl` are set by providers that report them (Steam);
return db.insert({ userId, provider, subject, email }) // the others leave them null.
async function link({ userId, provider, subject, email, displayName, avatarUrl }) {
return db.insert({ userId, provider, subject, email, displayName, avatarUrl })
} }
// Unlink a provider from a user. Returns rows removed (0 if nothing was linked). // Refresh a linked identity's name and avatar from a sign-in's profile.
async function unlink(userId, provider) { async function refreshProfile(identityId, { displayName, avatarUrl } = {}) {
return db.deleteForUserProvider(userId, provider) return db.updateProfile(identityId, { displayName, avatarUrl })
} }
module.exports = { findByProviderSubject, listForUser, link, unlink } // Unlink a provider from a user: every identity of it, or the one `identityId`
// names. Returns rows removed (0 if nothing was linked).
async function unlink(userId, provider, identityId = null) {
return db.deleteForUserProvider(userId, provider, identityId)
}
module.exports = { findByProviderSubject, listForUser, link, refreshProfile, unlink }

View File

@@ -311,6 +311,26 @@ function buildCtx(id, moduleRoot) {
// exports creation, role changes and password handling, none of which is a // exports creation, role changes and password handling, none of which is a
// module's business. // module's business.
users: { getById: users.getById }, users: { getById: users.getById },
// API 1.17.0 (STEAM_SIGNIN.md §4). READ only, one provider at a time: what a
// user has linked, as core verified it — the Steam id a module can trust is
// the one Steam's own `check_authentication` proved, never one a player typed.
// Linking and unlinking stay core's (the hooks on `api` are how a module takes
// part), and the row id and email are core's business, so they are not here.
identities: {
list: async (userId, provider) => {
// eslint-disable-next-line global-require
const rows = await require('../model/userIdentities/userIdentities.model').listForUser(userId)
return rows
.filter((r) => r.provider === provider)
.map((r) => ({
provider: r.provider,
subject: r.subject,
displayName: r.display_name || null,
avatarUrl: r.avatar_url || null,
linkedAt: r.created_at,
}))
},
},
// Where this deployment is reachable, for a module that has to build an // Where this deployment is reachable, for a module that has to build an
// absolute link (an announcement carries one into a game window or a chat // absolute link (an announcement carries one into a game window or a chat
// message, where a relative path means nothing). §2.7 forbids a module // message, where a relative path means nothing). §2.7 forbids a module
@@ -472,6 +492,17 @@ function buildApi(record) {
// never calls them. // never calls them.
onBoot: hook('onBoot'), onBoot: hook('onBoot'),
onShutdown: hook('onShutdown'), onShutdown: hook('onShutdown'),
// API 1.17.0 (STEAM_SIGNIN.md §4). Core calling the module about an external
// identity a signed-in user is linking, dispatched from auth/identityHooks.js.
//
// `onIdentityLinking` answers before anything is written and can only REFUSE
// (D396: a Steam id already another account's Rust link). It runs inside the
// link flow of a user who is already signed in, so it has no path to a session
// and cannot let through a link core would refuse. `onIdentityLinked` hears a
// link after it is written. There is no unlink hook: D393 leaves a module
// nothing to undo when a sign-in identity goes.
onIdentityLinking: hook('onIdentityLinking'),
onIdentityLinked: hook('onIdentityLinked'),
} }
} }
@@ -794,7 +825,7 @@ function load(tierRouters) {
staged: registries.stage(id), staged: registries.stage(id),
tables: new Set(), tables: new Set(),
called: new Set(), called: new Set(),
hooks: { onBoot: null, onShutdown: null }, hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
client: null, client: null,
ctx: null, ctx: null,
state: 'installed', state: 'installed',
@@ -1022,6 +1053,24 @@ function shutdownHooks() {
.reverse() .reverse()
} }
/**
* The identity hooks of one name (`onIdentityLinking` | `onIdentityLinked`), in
* registration order (API 1.17.0).
*
* `started` only, the rule every dispatch here follows: a module that is
* disabled, or whose `onBoot` threw, is not serving and has no say in a link.
* Empty in a process that never loaded modules (the seed script, unit tests), so
* the link flow there behaves exactly as a bare core.
*
* @returns {{id: string, hook: Function}[]}
*/
function identityHooks(name) {
if (!loaded) return []
return [...modules.values()]
.filter((r) => r.state === 'started' && r.hooks[name])
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
}
/** /**
* One module's `onShutdown`, for stopping it on its own rather than at exit. * One module's `onShutdown`, for stopping it on its own rather than at exit.
* *
@@ -1147,6 +1196,7 @@ module.exports = {
fragments, fragments,
bootable, bootable,
shutdownHooks, shutdownHooks,
identityHooks,
stopHook, stopHook,
clientChunks, clientChunks,
clientEntryUrls, clientEntryUrls,

View File

@@ -9,6 +9,21 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version. // has nothing to say about a website module) and from any module's own version.
// 1.17.0 — a module may take part in linking an external sign-in identity
// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396).
// Three additions, each optional:
// • `ctx.identities.list(userId, provider)` — the identities a user has linked
// for one provider, as core verified them (a Steam id proved by Steam's own
// `check_authentication`), with the display name and avatar when there are;
// • `api.onIdentityLinking(fn)` — asked before a link is written, and able only
// to refuse it: `{ ok: false, reason }`, where `reason: 'in_use'` reads to the
// player as "already linked to another account" and anything else as a
// plain refusal. A module's own text is logged, never shown;
// • `api.onIdentityLinked(fn)` — told after a link is written.
// No unlink hook (D393). Additions only, so minor. Module-uo declares `^1.10.0`
// and uses none of them: with no hook registered a link is checked and written
// exactly as before.
//
// 1.16.0 — a dry run carries a module's advice: an action that answers // 1.16.0 — a dry run carries a module's advice: an action that answers
// `verify: true` with `{ ok: true, detail: { warnings: [...] } }` has each line // `verify: true` with `{ ok: true, detail: { warnings: [...] } }` has each line
// shown as a WARNING finding (code `advice`) that does not fail the dry run // shown as a WARNING finding (code `advice`) that does not fail the dry run
@@ -216,6 +231,6 @@
// an admin action a module performs belongs in core's one audit log, the // an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module // extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor. // reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.16.0' const MODULE_API_VERSION = '1.17.0'
module.exports = { MODULE_API_VERSION } module.exports = { MODULE_API_VERSION }

View File

@@ -1,6 +1,6 @@
// ── Admin: auth provider configuration ───────────────────────────────────── // ── 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, // 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 // and endpoints are fixed in code and cannot be edited or deleted. Custom
// (oidc/oauth2) providers are fully editable. // (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. // Built-ins: kind/name/priority are fixed; only enable + credentials change.
const meta = registry.BUILTINS.find((x) => x.id === id) 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 } 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 { } else {
if (!(await authProviders.get(id))) { if (!(await authProviders.get(id))) {
return res.status(404).json({ message: 'Provider not found.' }) return res.status(404).json({ message: 'Provider not found.' })

View File

@@ -59,11 +59,12 @@ providersRouter.put(
'/providers/:id', '/providers/:id',
// #swagger.tags = ['Admin · Auth Providers'] // #swagger.tags = ['Admin · Auth Providers']
// #swagger.summary = 'Update an SSO provider (admin only)' // #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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ /* #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[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[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[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" } } } } */ /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -339,20 +339,36 @@ async function totpDisable(req, res) {
async function listIdentities(req, res) { async function listIdentities(req, res) {
try { try {
const rows = await userIdentities.listForUser(req.user.id) 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) { } catch (err) {
log.error('listIdentities', err) log.error('listIdentities', err)
return res.status(500).json({ message: 'Internal Server Error' }) 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) { async function unlinkIdentity(req, res) {
const { provider } = req.params const { provider } = req.params
const identityId = req.query.id != null ? Number(req.query.id) : null
try { 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.' }) 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 }) log.info('sso identity unlinked', { provider, id: req.user.id })
return res.json({ unlinked: true }) return res.json({ unlinked: true })
} catch (err) { } catch (err) {

View File

@@ -18,7 +18,7 @@
// expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*. // expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*.
const express = require('express') const express = require('express')
const { body, param } = require('express-validator') const { body, param, query } = require('express-validator')
const account = require('./account.controller') const account = require('./account.controller')
const { requireAuth } = require('../../../auth/session.middleware') const { requireAuth } = require('../../../auth/session.middleware')
@@ -177,10 +177,14 @@ meRouter.delete(
// #swagger.tags = ['Auth · Me'] // #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Unlink an SSO identity (self)' // #swagger.summary = 'Unlink an SSO identity (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #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['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[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" } } } } */ /* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('provider').matches(/^[a-z0-9-]+$/), param('provider').matches(/^[a-z0-9-]+$/),
query('id').optional().isInt({ min: 1 }),
validate, validate,
account.unlinkIdentity, account.unlinkIdentity,
) )

View File

@@ -1,6 +1,8 @@
// ── SSO (OAuth2 / OIDC) controller ───────────────────────────────────────── // ── 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/providers → public discovery (enabled + valid providers)
// GET /auth/sso/:provider/start → begin login (redirect to the IdP) // GET /auth/sso/:provider/start → begin login (redirect to the IdP)
// GET /auth/sso/:provider/link → begin account linking (requireAuth) // 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 userIdentities = require('../../../model/userIdentities/userIdentities.model')
const settings = require('../../../model/settings/settings.model') const settings = require('../../../model/settings/settings.model')
const registry = require('../../../auth/providers/registry') 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 sessionService = require('../../../auth/session.service')
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model') const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
const ssoState = require('../../../auth/ssoState') const ssoState = require('../../../auth/ssoState')
@@ -148,20 +152,43 @@ async function beginFlow(req, res, mode) {
const start = (req, res) => beginFlow(req, res, 'login') const start = (req, res) => beginFlow(req, res, 'login')
const linkStart = (req, res) => beginFlow(req, res, 'link') 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 // GET /auth/sso/:provider/callback
async function callback(req, res) { async function callback(req, res) {
const providerId = req.params.provider const providerId = req.params.provider
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE] 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. // The tx cookie is single-use — clear it no matter the outcome.
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req)) res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
if (oauthError) { if (answer.denied) {
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) }) log.warn('sso callback: provider returned error', { provider: providerId, error: String(answer.error || 'cancel').slice(0, 60) })
return res.redirect(loginError('denied')) return res.redirect(loginError('denied'))
} }
const tx = ssoState.verifyTx(txToken, state) const tx = ssoState.verifyTx(txToken, answer.state)
if (!tx || tx.provider !== providerId || !code) { if (!tx || tx.provider !== providerId || !answer.present) {
log.warn('sso callback: bad state', { provider: providerId }) log.warn('sso callback: bad state', { provider: providerId })
return res.redirect(loginError('bad_state')) return res.redirect(loginError('bad_state'))
} }
@@ -175,20 +202,34 @@ async function callback(req, res) {
return res.redirect(failFor('unavailable')) return res.redirect(failFor('unavailable'))
} }
const provider = registry.instantiate(row) const provider = registry.instantiate(row)
const profile = await provider.handleCallback({ const profile = await provider.handleCallback(callbackParams(req, providerId, tx, answer))
code,
redirectUri: redirectUriFor(req, providerId),
codeVerifier: tx.verifier,
})
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile) if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile) if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile)
return finishLogin(req, res, providerId, row.kind, tx, profile) return finishLogin(req, res, providerId, row.kind, tx, profile)
} catch (err) { } 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) log.error('sso callback', err)
return res.redirect(failFor('error')) 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 // 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 // and registration allows SSO sign-up. Derives a unique username (reserved-name
// safe) with a bounded retry against the UNIQUE index, captures the provider // 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) { if (identity) {
user = await users.getById(identity.user_id) user = await users.getById(identity.user_id)
if (!user) return res.redirect(loginError('not_linked', portal)) if (!user) return res.redirect(loginError('not_linked', portal))
await refreshIdentityProfile(identity, profile)
} else { } 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() 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 }) log.warn('sso login refused: no linked account', { provider: providerId })
return res.redirect(loginError('not_linked', portal)) 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 // 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 // provisioning when registration mode allows it). On refusal, sends the redirect
// and returns null. // 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) const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
if (identity) { if (identity) {
const user = await users.getById(identity.user_id) 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')) res.redirect(appError(sess, 'not_linked'))
return null return null
} }
await refreshIdentityProfile(identity, profile)
return user return user
} }
const mode = await settings.getRegistrationMode() 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 }) log.warn('mobile sso login refused: no linked account', { provider: providerId })
res.redirect(appError(sess, 'not_linked')) res.redirect(appError(sess, 'not_linked'))
return null return null
@@ -425,7 +469,7 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
const sess = await requireValidBridgeSession(res, tx, providerId) const sess = await requireValidBridgeSession(res, tx, providerId)
if (!sess) return 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) return
if (user.status && user.status !== 'active') { if (user.status && user.status !== 'active') {
@@ -555,11 +599,38 @@ async function finishLink(req, res, providerId, tx, profile) {
if (existing && existing.user_id !== userId) { if (existing && existing.user_id !== userId) {
return res.redirect(accountError('in_use', portal)) // external identity belongs to another account return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
} }
if (!existing) { if (existing) {
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email }) // 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 } }) await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
log.info('sso account linked', { provider: providerId, userId }) 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}`) return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
} }

View File

@@ -52,10 +52,13 @@ ssoRouter.get(
'/sso/:provider/callback', '/sso/:provider/callback',
// #swagger.tags = ['Auth · SSO'] // #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'OAuth redirect target — completes login or linking' // #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.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).' } // #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.' } // #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).' } // #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' } */ /* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
ctrl.callback, ctrl.callback,
) )

View File

@@ -13089,7 +13089,7 @@
"Auth · Me" "Auth · Me"
], ],
"summary": "Unlink an SSO identity (self)", "summary": "Unlink an SSO identity (self)",
"description": "", "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).",
"parameters": [ "parameters": [
{ {
"name": "provider", "name": "provider",
@@ -13099,6 +13099,15 @@
"type": "string" "type": "string"
}, },
"description": "Provider id." "description": "Provider id."
},
{
"name": "id",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "One identity to unlink (its id from GET /auth/me/account/identities)."
} }
], ],
"responses": { "responses": {
@@ -13113,7 +13122,14 @@
} }
}, },
"400": { "400": {
"description": "Bad Request" "description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
}, },
"401": { "401": {
"description": "Unauthorized" "description": "Unauthorized"
@@ -15467,7 +15483,7 @@
"Auth · SSO" "Auth · SSO"
], ],
"summary": "OAuth redirect target — completes login or linking", "summary": "OAuth redirect target — completes login or linking",
"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).", "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).",
"parameters": [ "parameters": [
{ {
"name": "provider", "name": "provider",
@@ -15476,14 +15492,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Provider id (e.g. google, discord)." "description": "Provider id (e.g. google, discord, steam)."
},
{
"name": "error",
"in": "query",
"schema": {
"type": "string"
}
}, },
{ {
"name": "code", "name": "code",
@@ -15492,7 +15501,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "OAuth authorization code." "description": "OAuth authorization code (OAuth2/OIDC providers)."
}, },
{ {
"name": "state", "name": "state",
@@ -15501,7 +15510,50 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "OAuth state (matched against the tx cookie)." "description": "OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter."
},
{
"name": "error",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "OAuth error (the user declined, or the IdP refused)."
},
{
"name": "openid.mode",
"in": "query",
"required": false,
"schema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"id_res",
"cancel"
],
"items": {
"type": "string"
}
}
}
},
"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."
},
{
"name": "openid.claimed_id",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Steam only: https://steamcommunity.com/openid/id/<SteamID64>."
} }
], ],
"responses": { "responses": {
@@ -19827,7 +19879,7 @@
}, },
"description": { "description": {
"type": "string", "type": "string",
"example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')." "example": "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2')."
}, },
"example": { "example": {
"type": "string", "type": "string",
@@ -19908,6 +19960,7 @@
"example": [ "example": [
"google", "google",
"discord", "discord",
"steam",
"oidc", "oidc",
"oauth2" "oauth2"
], ],
@@ -19965,7 +20018,7 @@
}, },
"description": { "description": {
"type": "string", "type": "string",
"example": "Whether a client secret is stored (the secret itself is never returned)." "example": "Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without."
}, },
"example": { "example": {
"type": "boolean", "type": "boolean",
@@ -20047,7 +20100,7 @@
}, },
"description": { "description": {
"type": "string", "type": "string",
"example": "True for the fixed google/discord providers." "example": "True for the fixed google/discord/steam providers."
}, },
"example": { "example": {
"type": "boolean", "type": "boolean",
@@ -24740,6 +24793,23 @@
"properties": { "properties": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Identity id, for unlinking one of several (DELETE ...?id=)."
},
"example": {
"type": "number",
"example": 12
}
}
},
"provider": { "provider": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -24774,6 +24844,65 @@
} }
} }
}, },
"subject": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "The user’s own external id at the provider; for steam, the 17-digit SteamID64."
},
"example": {
"type": "string",
"example": "76561197960287930"
}
}
},
"display_name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Persona name the provider reported (steam only)."
},
"example": {
"type": "string",
"example": "CoolGuy99"
}
}
},
"avatar_url": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "uri"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Avatar on the provider’s CDN (steam only)."
}
}
},
"linked_at": { "linked_at": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -294,7 +294,7 @@ const doc = {
name: { type: 'string', example: 'Google' }, name: { type: 'string', example: 'Google' },
icon: { icon: {
type: 'string', type: 'string',
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').", description: "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2').",
example: 'google', example: 'google',
}, },
loginUrl: { loginUrl: {
@@ -313,17 +313,17 @@ const doc = {
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'string', example: 'okta' }, id: { type: 'string', example: 'okta' },
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' }, kind: { type: 'string', enum: ['google', 'discord', 'steam', 'oidc', 'oauth2'], example: 'oidc' },
name: { type: 'string', example: 'Okta' }, name: { type: 'string', example: 'Okta' },
enabled: { type: 'boolean', example: true }, enabled: { type: 'boolean', example: true },
clientId: { type: 'string' }, clientId: { type: 'string' },
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true }, hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without.', example: true },
authorizeUrl: { type: 'string', format: 'uri' }, authorizeUrl: { type: 'string', format: 'uri' },
tokenUrl: { type: 'string', format: 'uri' }, tokenUrl: { type: 'string', format: 'uri' },
userinfoUrl: { type: 'string', format: 'uri' }, userinfoUrl: { type: 'string', format: 'uri' },
scopes: { type: 'string', example: 'openid email profile' }, scopes: { type: 'string', example: 'openid email profile' },
priority: { type: 'integer', example: 10 }, priority: { type: 'integer', example: 10 },
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false }, builtin: { type: 'boolean', description: 'True for the fixed google/discord/steam providers.', example: false },
health: { $ref: '#/components/schemas/ProviderHealth' }, health: { $ref: '#/components/schemas/ProviderHealth' },
}, },
}, },
@@ -961,8 +961,12 @@ const doc = {
LinkedIdentity: { LinkedIdentity: {
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'integer', description: 'Identity id, for unlinking one of several (DELETE ...?id=).', example: 12 },
provider: { type: 'string', example: 'google' }, provider: { type: 'string', example: 'google' },
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' }, email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
subject: { type: 'string', description: 'The user’s own external id at the provider; for steam, the 17-digit SteamID64.', example: '76561197960287930' },
display_name: { type: 'string', nullable: true, description: 'Persona name the provider reported (steam only).', example: 'CoolGuy99' },
avatar_url: { type: 'string', format: 'uri', nullable: true, description: 'Avatar on the provider’s CDN (steam only).' },
linked_at: { type: 'string', format: 'date-time' }, linked_at: { type: 'string', format: 'date-time' },
}, },
}, },

View File

@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the // 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
// assertion that says it does. The number itself is pinned in version.js. // assertion that says it does. The number itself is pinned in version.js.
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION) assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
assert.equal(MODULE_API_VERSION, '1.16.0') assert.equal(MODULE_API_VERSION, '1.17.0')
}) })
test('a module registers actions, budgets, leases and option sources', () => { test('a module registers actions, budgets, leases and option sources', () => {

View File

@@ -519,8 +519,11 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
// throws until Phase 7 builds the channel — which is why it has to be in this // throws until Phase 7 builds the channel — which is why it has to be in this
// list now: a member of a declared version that were absent would make // list now: a member of a declared version that were absent would make
// MODULE_API_VERSION a lie, and this test is what says so. // MODULE_API_VERSION a lie, and this test is what says so.
// API 1.17.0 added `identities` (STEAM_SIGNIN.md §4): READ only, one provider
// at a time. Linking stays core's, and a module takes part through the two
// hooks on `api`, never by writing an identity itself.
assert.deepEqual(probe.keys, [ assert.deepEqual(probe.keys, [
'activity', 'auth', 'db', 'events', 'express', 'inbox', 'log', 'middleware', 'moduleId', 'paths', 'activity', 'auth', 'db', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator', 'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
]) ])
// is core's limiter FACTORY, not a limiter: a module states its own // is core's limiter FACTORY, not a limiter: a module states its own

View File

@@ -28,11 +28,11 @@ test('validateConfig: custom OIDC also needs the endpoint URLs', () => {
assert.deepEqual(registry.validateConfig(noUrls).missing, ['authorize_url', 'token_url', 'userinfo_url']) assert.deepEqual(registry.validateConfig(noUrls).missing, ['authorize_url', 'token_url', 'userinfo_url'])
}) })
test('listConfigured always includes both built-ins with health', async () => { test('listConfigured always includes every built-in with health', async () => {
authProvidersModel.list = async () => [] // no rows yet authProvidersModel.list = async () => [] // no rows yet
const out = await registry.listConfigured() const out = await registry.listConfigured()
const ids = out.map((p) => p.id) const ids = out.map((p) => p.id)
assert.deepEqual(ids, ['google', 'discord']) assert.deepEqual(ids, ['google', 'discord', 'steam'])
assert.equal(out[0].builtin, true) assert.equal(out[0].builtin, true)
assert.equal(out[0].enabled, 0) assert.equal(out[0].enabled, 0)
assert.equal(out[0].health.valid, false) // unconfigured assert.equal(out[0].health.valid, false) // unconfigured

View File

@@ -131,7 +131,7 @@ test('link mode → identity linked to the acting user, redirect to account', as
const res = mockRes() const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res) await ssoCtrl.callback(makeReq(tx), res)
assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com' }) assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com', displayName: null, avatarUrl: null })
assert.equal(res.redirectedTo, '/admin/account?linked=google') assert.equal(res.redirectedTo, '/admin/account?linked=google')
assert.equal(logged.at(-1).action, 'auth.sso.link') assert.equal(logged.at(-1).action, 'auth.sso.link')
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session') assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')

View File

@@ -0,0 +1,433 @@
// 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])
})