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 } }),
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
myIdentities: () => req('/auth/me/account/identities'),
unlinkIdentity: (provider) =>
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
// With an identity id, only that one goes (a person may hold several Steam accounts).
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,
// returned ONCE (password step-up for accounts that have a password).
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
// 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.
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 }) {
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">
@@ -33,5 +43,6 @@ function GenericMark({ size }) {
export default function ProviderIcon({ icon, size = 18 }) {
if (icon === 'google') return <GoogleMark size={size} />
if (icon === 'discord') return <DiscordMark size={size} />
if (icon === 'steam') return <SteamMark 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
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// 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
// module's advice, shown by core's own event editor as warning findings.
// 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
// 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.
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 RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through
@@ -15,13 +16,7 @@ function LinkedAccounts() {
const [available, setAvailable] = useState([])
const [error, setError] = useState('')
const banner = (() => {
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 banner = linkBanner(window.location.search)
const load = useCallback(async () => {
try {
@@ -40,12 +35,11 @@ function LinkedAccounts() {
}, [load])
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) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
async function unlink(identity) {
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
try {
await api.unlinkIdentity(provider)
await api.unlinkIdentity(identity.provider, identity.id)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
@@ -55,8 +49,7 @@ function LinkedAccounts() {
if (error) return <ErrorState message={error} />
if (!linked) return null
const linkedIds = new Set(linked.map((i) => i.provider))
const linkable = available.filter((p) => !linkedIds.has(p.id))
const linkable = linkableProviders(available, linked)
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
@@ -64,7 +57,7 @@ function LinkedAccounts() {
Linked accounts
</h2>
<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.
</p>
@@ -77,15 +70,9 @@ function LinkedAccounts() {
{linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{linked.map((i) => (
<div key={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 }}>
<ProviderIcon icon={iconFor(i.provider)} size={20} />
</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' }}>
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Unlink
</button>
</div>

View File

@@ -5,12 +5,14 @@ import { api } from '../../../api/client.js'
// Admin config for authentication providers. Local password + TOTP is always on
// (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 = [
{ id: 'local', label: 'Local Accounts' },
{ id: 'google', label: 'Google' },
{ id: 'discord', label: 'Discord' },
{ id: 'steam', label: 'Steam' },
{ 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) ─────────────────────────────────────────
function LocalInfo() {
return (
@@ -376,6 +459,7 @@ export default function AuthProvidersAdmin() {
{tab === 'local' && <LocalInfo />}
{tab === 'google' && <BuiltinForm provider={byId('google')} 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} />}
</section>
)

View File

@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.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 { api } from '../../api/client.js'
@@ -224,13 +225,7 @@ function LinkedAccounts() {
const [available, setAvailable] = useState([])
const [error, setError] = useState('')
const banner = (() => {
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 banner = linkBanner(window.location.search)
const load = useCallback(async () => {
try {
@@ -247,12 +242,11 @@ function LinkedAccounts() {
useEffect(() => { load() }, [load])
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) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
async function unlink(identity) {
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
try {
await api.unlinkIdentity(provider)
await api.unlinkIdentity(identity.provider, identity.id)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
@@ -262,13 +256,12 @@ function LinkedAccounts() {
if (error) return <ErrorState message={error} />
if (!linked) return null
const linkedIds = new Set(linked.map((i) => i.provider))
const linkable = available.filter((p) => !linkedIds.has(p.id))
const linkable = linkableProviders(available, linked)
return (
<Section title="Linked accounts">
<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>
{banner && (
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
@@ -276,15 +269,9 @@ function LinkedAccounts() {
{linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{linked.map((i) => (
<div key={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 }}>
<ProviderIcon icon={iconFor(i.provider)} size={20} />
</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 key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
</div>
))}
</div>