Files
website/client/src/routes/admin/AdminLogin.jsx
wtclaude 620781b7bc
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m21s
feat(auth): honor and establish trusted devices on the SSO login paths
"Trust this device" did nothing for anyone who signs in with Google or Discord.
sso.controller went straight from needsTotp(user) to staging a pending-TOTP
challenge and never consulted resolveTrustedDevice, so an SSO user was asked for
a code on EVERY sign-in no matter how many times they had ticked the box — and
POST /auth/sso/totp accepted only `code`, so that step could not establish a
trust either. The password paths (web + native) were unaffected and already
worked; this closes the gap for SSO, on the website AND in the Android app.

Server:
- finishLogin and finishMobileLogin now run the same trusted-device check as
  auth.controller.login, via one shared helper: honor a trust that belongs to
  THIS user, stamp last_used_at, log auth.login.trusted_device. A store error
  falls through to the challenge — fail closed to asking for the code.
- POST /auth/sso/totp gains optional trustDevice + deviceName, sets the rg_trust
  cookie, and mirrors the password path's { trustLimitReached, devices } response
  at the cap (the sign-in still completes). Recovery codes stay password-only.

Android coverage, without leaking a secret into a URL:
- The app opens SSO in a Custom Tab, which shares the system browser's cookie
  jar, so the rg_trust cookie set on that TOTP form is presented back on the next
  app sign-in. That alone makes native SSO skip the code. Passing the app's token
  into the start URL was rejected — it would put a 256-bit secret in query
  strings, Referer headers and access logs.
- To also cover the app's NATIVE password login, ticking the box sets
  mobile_auth_sessions.trust_device (a boolean; never the token), and
  /auth/mobile/sso/exchange mints a platform:'mobile' trust and returns
  { trustToken }. Minting there keeps the raw token on an authenticated
  app→server call, out of the deep link and out of the bridge row. Best-effort:
  at the cap the response just omits it rather than failing a good sign-in.

Client: the trust checkbox is no longer hidden on the SSO second step, on both
the admin and player login screens. On the mobile bridge the deep-link redirect
takes priority over the cap prompt — the sign-in succeeded and the link is
single-use, so stalling there would strand the app.

Tests: 8 new cases in server/test/ssoTrustedDevice.test.js (verified to fail
against the pre-fix controller). Full suites green — server 445, client 43 —
and routes.manifest.json is a zero-line diff: no URL moved, only +2 handlers on
/auth/sso/totp in routes.guards.json for the two new validators. Swagger
regenerated. Verified live against the running server and real MariaDB: the TOTP
step issues rg_trust and persists the row, a subsequent SSO callback carrying it
skips the code, and an invalid trust is still challenged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:12 -05:00

357 lines
14 KiB
JavaScript

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 TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.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.',
disabled: 'This account is not active. Contact an administrator.',
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.',
}
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
// skip) so real users never fill it but naive scripted bots do. Name must match
// the server's HONEYPOT_FIELD ('company').
const honeypotStyle = {
position: 'absolute',
left: '-9999px',
top: 'auto',
width: '1px',
height: '1px',
opacity: 0,
pointerEvents: 'none',
}
export default function AdminLogin() {
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const { siteTitle, heroImage } = useSite()
const BG = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/admin'
// A player who signs in here belongs in the player portal, not the admin shell
// (the admin API 403s them anyway). Staff go to their intended admin dest.
const destFor = (u) => (u && u.role === 'player' ? '/account' : dest)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [company, setCompany] = useState('') // honeypot — must stay empty
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
const [trustDevice, setTrustDevice] = useState(false)
const [useRecovery, setUseRecovery] = useState(false)
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest } when the cap is hit
// 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 right home for the role.
useEffect(() => {
if (user) navigate(destFor(user), { replace: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, dest, navigate])
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
// before it will issue a session. Jump straight to the code step.
useEffect(() => {
if (new URLSearchParams(location.search).get('sso_totp')) {
setStage('totp')
setSsoTotp(true)
}
}, [location.search])
// 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('')
setBusy(true)
try {
const data = await login(username, password, { company })
if (data.totpRequired) {
setChallenge(data.challenge)
setStage('totp')
setBusy(false)
return
}
navigate(destFor(data.user), { replace: true })
} catch (err) {
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
setBusy(false)
}
}
async function onSubmitTotp(e) {
e.preventDefault()
setError('')
setBusy(true)
try {
if (ssoTotp) {
// Trust works on the SSO second factor exactly as it does on the password
// one — the IdP already proved the first factor.
const data = await ssoLoginTotp(code.trim(), { trustDevice })
const to = data.returnTo || '/admin'
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { replace: true })
} else {
const entered = code.trim()
const data = await loginTotp(challenge, useRecovery ? '' : entered, {
recoveryCode: useRecovery ? entered : undefined,
trustDevice,
})
const to = destFor(data.user)
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { replace: true })
}
} catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message)
const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
setBusy(false)
if (expired) {
setStage('creds')
setSsoTotp(false)
}
}
}
let submitLabel = 'Sign in'
if (busy) submitLabel = 'Signing in…'
else if (stage === 'totp') submitLabel = 'Verify'
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
padding: '40px 18px',
overflow: 'hidden',
backgroundColor: 'var(--bg-deep)',
backgroundImage: BG,
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'min(60vh, 520px)',
}}
>
<div style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 26 }}>
<div style={{ marginBottom: 14 }}>
<MoonDot size={15} glow={0.55} />
</div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
{siteTitle}
</h1>
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
Admin Panel
</p>
</div>
<form
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
style={{
border: '1px solid var(--line)',
borderRadius: 12,
padding: 28,
background: 'linear-gradient(180deg,rgba(25,34,49,0.92),rgba(20,26,33,0.92))',
backdropFilter: 'blur(6px)',
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
{stage === 'creds' ? (
<>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Username</span>
<input
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
/>
</label>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Password</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
/>
</label>
{/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */}
<div style={honeypotStyle} aria-hidden="true">
<label>
Company
<input
type="text"
name="company"
tabIndex={-1}
autoComplete="off"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
</label>
</div>
</>
) : (
<>
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
<input
type="text"
inputMode={useRecovery ? 'text' : 'numeric'}
autoComplete="one-time-code"
autoFocus
placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
</span>
</label>
{/* Offered on the SSO second factor too — the trust is on the device,
not on how the first factor was proved. */}
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
Trust this device for 30 days (skip the code next time)
</label>
{/* Recovery codes remain password-login only: the SSO second step
verifies an authenticator code against the staged challenge. */}
{!ssoTotp && (
<button
type="button"
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
className="sans"
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
)}
</>
)}
{(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>
)}
<button
type="submit"
disabled={busy}
className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
>
{submitLabel}
</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>
</form>
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
Back to site
</Link>
</p>
</div>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</main>
)
}