Add session abstraction, mobile bearer auth, and pluggable SSO
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).
Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
(create/validate/partial-TOTP/revoke), session.middleware.js
(attachSession/requireAuth/requireRole). utils/auth.js is now a thin
compat facade so existing imports are unchanged.
Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
long-lived refresh token, stored hashed and rotated on use, in a new
mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
TOTP. token.signToken gains a backward-compatible expiresIn option.
Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
identities are never auto-provisioned. Client secrets encrypted at rest
(AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
account linking (/admin/account/identities). New auth_providers +
user_identities tables.
Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
graceful with zero providers). New Authentication admin view
(Local/Google/Discord/Custom). Account page linked-accounts section.
Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,121 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
// the provider's OAuth flow (/auth/sso/:id/link) and returns here with ?linked
|
||||
// or ?link_error. Only providers that are enabled + valid can be linked.
|
||||
function LinkedAccounts() {
|
||||
const [linked, setLinked] = useState(null)
|
||||
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 load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.admin.linkedIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
setAvailable(Array.isArray(avail) ? avail : [])
|
||||
} catch {
|
||||
setError('Could not load linked accounts.')
|
||||
}
|
||||
}, [])
|
||||
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
|
||||
try {
|
||||
await api.admin.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
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
|
||||
to an account it is linked to — linking here is what grants that access.
|
||||
</p>
|
||||
|
||||
{banner && (
|
||||
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>
|
||||
{banner.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linkable.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
|
||||
{linkable.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link`)}
|
||||
className="btn"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||
<ProviderIcon icon={p.icon} size={18} />
|
||||
</span>
|
||||
Link {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linked.length === 0 && linkable.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem' }}>
|
||||
No SSO providers are enabled. Configure them under <strong>Authentication</strong>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Self-service account security: enable / disable optional TOTP two-factor.
|
||||
export default function AccountAdmin() {
|
||||
const [account, setAccount] = useState(null)
|
||||
@@ -187,6 +301,8 @@ export default function AccountAdmin() {
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
|
||||
<LinkedAccounts />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user