"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>
298 lines
12 KiB
JavaScript
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)',
|
|
}
|