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

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