Files
website/client/src/routes/player/PlayerLogin.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

298 lines
12 KiB
JavaScript

import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
// Friendly copy for the ?sso_error codes the SSO callback can bounce back with.
const SSO_ERRORS = {
not_linked:
'That account is not linked to a player. Enable SSO sign-up, or sign in with a password and link it under your 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.',
}
export default function PlayerLogin() {
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/player'
// A staff member who signs in here belongs in the admin shell, not the portal.
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : 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)
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)
// When trust was requested at login but the device cap is reached: show the
// revoke-to-continue modal, then navigate on resolve. `pendingDest` holds where
// to go once the prompt is dealt with.
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest }
const [providers, setProviders] = useState([])
const [canRegister, setCanRegister] = useState(false)
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.
useEffect(() => {
if (new URLSearchParams(location.search).get('sso_totp')) {
setStage('totp')
setSsoTotp(true)
}
}, [location.search])
// SSO providers (for buttons) + whether password registration is open.
useEffect(() => {
let active = true
api
.authProviders()
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
.catch(() => active && setProviders([]))
api
.publicSettings()
.then((s) => active && setCanRegister(Boolean(s?.registration?.password)))
.catch(() => {})
return () => {
active = false
}
}, [])
function startSso(provider) {
// Always return into the player portal so the callback lands on /account*.
const q = `?returnTo=${encodeURIComponent(dest.startsWith('/account') ? dest : '/account')}`
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) {
if (err.status === 403) setError('This account is not active. Contact an administrator.')
else 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 too. On the mobile bridge this page
// is running inside the app's Custom Tab, so the cookie set here is what
// lets the next app sign-in skip the code.
const data = await ssoLoginTotp(code.trim(), { trustDevice })
// Native SSO bridge (M9): a mobile 2FA completion returns an absolute
// deep link (e.g. runicgateway://…) to hand the app its one-time code.
// React Router can't navigate a custom scheme, so leave the SPA for it.
// This wins over the trust-cap prompt: the sign-in itself succeeded and the
// deep link is single-use, so stalling here to manage devices would strand
// the app. An over-cap user simply isn't trusted and can prune the list
// from Account → Trusted Devices.
if (data.redirect) {
window.location.href = data.redirect
return
}
const to = data.returnTo || '/account'
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)
// Trust was requested but the device cap is reached: the session is already
// issued, so prompt to revoke one before trusting, then navigate.
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 (
<PlayerShell
subtitle="Player sign-in"
footer={
<div style={{ margin: '16px 0 0', textAlign: 'center' }}>
<p className="sans" style={{ margin: 0, color: 'var(--dim)', fontSize: '0.84rem' }}>
<Link to="/account/forgot" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Forgot your password?
</Link>
</p>
{canRegister && (
<p className="sans" style={{ margin: '8px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
New here?{' '}
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Create an account
</Link>
</p>
)}
</div>
}
>
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
{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>
<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. Inside the app's Custom Tab
this is also what trusts the device for future native sign-ins. */}
<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>
{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={ssoBtnStyle}>
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
<ProviderIcon icon={p.icon} size={18} />
</span>
Continue with {p.name}
</button>
))}
</div>
</div>
)}
</form>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</PlayerShell>
)
}
const ssoBtnStyle = {
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)',
}