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,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
||||
const SSO_ERRORS = {
|
||||
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
||||
denied: 'Sign-in was cancelled.',
|
||||
unavailable: 'That sign-in method is not available right now.',
|
||||
bad_state: 'Your sign-in session expired. Please try again.',
|
||||
error: 'Could not complete sign-in. Please try again.',
|
||||
}
|
||||
|
||||
const BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
@@ -36,11 +47,36 @@ export default function AdminLogin() {
|
||||
const [challenge, setChallenge] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
|
||||
// SSO providers to offer (empty if none configured) + any error the callback
|
||||
// bounced us back with (?sso_error=...).
|
||||
const [providers, setProviders] = useState([])
|
||||
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||
|
||||
// Already signed in → go straight to the panel.
|
||||
useEffect(() => {
|
||||
if (user) navigate(dest, { replace: true })
|
||||
}, [user, dest, navigate])
|
||||
|
||||
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
||||
// still works with password login and simply shows no provider buttons.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api
|
||||
.authProviders()
|
||||
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||
.catch(() => active && setProviders([]))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Full-page redirect into the provider's OAuth flow, preserving the intended
|
||||
// destination so the callback can return the user there.
|
||||
function startSso(provider) {
|
||||
const q = dest && dest !== '/admin' ? `?returnTo=${encodeURIComponent(dest)}` : ''
|
||||
window.location.assign(provider.loginUrl + q)
|
||||
}
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -174,9 +210,9 @@ export default function AdminLogin() {
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
{error}
|
||||
{(error || (stage === 'creds' && ssoError)) && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
|
||||
{error || ssoError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -188,6 +224,45 @@ export default function AdminLogin() {
|
||||
>
|
||||
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
{/* SSO providers — only on the credentials step, only if any are enabled. */}
|
||||
{stage === 'creds' && providers.length > 0 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => startSso(p)}
|
||||
className="btn"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
padding: 11,
|
||||
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>
|
||||
Continue with {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
Protected area — not indexed. Sessions expire after 1 day.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user