"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>
88 lines
2.9 KiB
JavaScript
88 lines
2.9 KiB
JavaScript
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
|
import { api } from '../api/client.js'
|
|
|
|
const AuthContext = createContext(null)
|
|
|
|
export function AuthProvider({ children }) {
|
|
const [user, setUser] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const data = await api.me()
|
|
setUser(data.user)
|
|
} catch {
|
|
setUser(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
refresh()
|
|
}, [refresh])
|
|
|
|
// Step 1. Returns { user } on success, or { totpRequired, challenge } when the
|
|
// account has 2FA on (caller then calls loginTotp). `extra` carries honeypot.
|
|
const login = useCallback(async (username, password, extra) => {
|
|
const data = await api.login(username, password, extra)
|
|
if (data.user) setUser(data.user)
|
|
return data
|
|
}, [])
|
|
|
|
// Public self-registration (player). Creates the account, sets the session
|
|
// cookie, and returns { user }. `extra` carries the honeypot + optional email.
|
|
const register = useCallback(async (username, password, extra) => {
|
|
const data = await api.register(username, password, extra)
|
|
if (data.user) setUser(data.user)
|
|
return data
|
|
}, [])
|
|
|
|
// Step 2 for TOTP users: exchange the challenge + a second factor (TOTP code or a
|
|
// recovery code) for a real session. `extra` carries recoveryCode + the
|
|
// trustDevice/deviceName opt-in. Returns the full payload ({ user,
|
|
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
|
|
const loginTotp = useCallback(async (challenge, code, extra) => {
|
|
const data = await api.loginTotp(challenge, code, extra)
|
|
setUser(data.user)
|
|
return data
|
|
}, [])
|
|
|
|
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
|
|
// an httpOnly cookie, so only the code is sent. `extra` carries the trustDevice/
|
|
// deviceName opt-in. Returns the full payload ({ user, returnTo,
|
|
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
|
|
const ssoLoginTotp = useCallback(async (code, extra) => {
|
|
const data = await api.ssoLoginTotp(code, extra)
|
|
setUser(data.user)
|
|
return data
|
|
}, [])
|
|
|
|
const logout = useCallback(async () => {
|
|
try {
|
|
await api.logout()
|
|
} finally {
|
|
setUser(null)
|
|
}
|
|
}, [])
|
|
|
|
// Memoized so consumers don't re-render on every provider render (the callbacks
|
|
// are already stable via useCallback).
|
|
const value = useMemo(
|
|
() => ({ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }),
|
|
[user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh],
|
|
)
|
|
|
|
return (
|
|
<AuthContext.Provider value={value}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useAuth() {
|
|
const ctx = useContext(AuthContext)
|
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
|
return ctx
|
|
}
|