"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>
117 lines
4.6 KiB
JavaScript
117 lines
4.6 KiB
JavaScript
const { query } = require('../../utils/db')
|
|
|
|
// SQL for the mobile SSO authorization bridge (mobile_auth_sessions +
|
|
// mobile_auth_codes). The authorization code is stored as a sha256 hash only
|
|
// (hashing is done in the .model layer, mirroring mobileSessions); the PKCE
|
|
// code_challenge is a hash by construction and is stored as-supplied. Both
|
|
// tables self-prune on expiry (pruneExpired). See docs BACKEND_DESIGN §3/§4.
|
|
|
|
// ── mobile_auth_sessions ───────────────────────────────────────────────────
|
|
|
|
// Insert a pending bridge session. expiresAt is a JS Date (or ms epoch).
|
|
async function insertSession({ sessionId, provider, codeChallenge, redirectUri, state, expiresAt }) {
|
|
const res = await query(
|
|
`INSERT INTO mobile_auth_sessions (session_id, provider, code_challenge, redirect_uri, state, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[sessionId, provider, codeChallenge, redirectUri, state, new Date(expiresAt)],
|
|
)
|
|
return res.insertId
|
|
}
|
|
|
|
// Look up a bridge session by its opaque session_id. Returns the row or null.
|
|
async function getSession(sessionId) {
|
|
const rows = await query('SELECT * FROM mobile_auth_sessions WHERE session_id = ? LIMIT 1', [sessionId])
|
|
return rows[0] || null
|
|
}
|
|
|
|
// Mark a still-pending, unexpired session `completed` and attach the resolved
|
|
// user. Guards on status + expiry so a replayed callback can't re-complete a
|
|
// consumed/expired session. Returns rows changed (0 = not eligible).
|
|
async function completeSession(sessionId, userId) {
|
|
const res = await query(
|
|
`UPDATE mobile_auth_sessions
|
|
SET status = 'completed', user_id = ?
|
|
WHERE session_id = ? AND status = 'pending' AND expires_at > NOW()`,
|
|
[userId, sessionId],
|
|
)
|
|
return Number(res.affectedRows || 0)
|
|
}
|
|
|
|
// Record that the user asked to trust this device on the Custom Tab TOTP form.
|
|
// Guarded on status + expiry for the same reason completeSession is: a replayed
|
|
// TOTP post must not re-arm a session that has already been consumed. Stores a
|
|
// boolean only — the trust token is minted at /exchange and never lands here.
|
|
async function setTrustDevice(sessionId) {
|
|
const res = await query(
|
|
`UPDATE mobile_auth_sessions
|
|
SET trust_device = 1
|
|
WHERE session_id = ? AND status = 'pending' AND expires_at > NOW()`,
|
|
[sessionId],
|
|
)
|
|
return Number(res.affectedRows || 0)
|
|
}
|
|
|
|
// Mark a session `consumed` after a successful token exchange (stamps used_at).
|
|
async function consumeSession(sessionId) {
|
|
const res = await query(
|
|
"UPDATE mobile_auth_sessions SET status = 'consumed', used_at = NOW() WHERE session_id = ?",
|
|
[sessionId],
|
|
)
|
|
return Number(res.affectedRows || 0)
|
|
}
|
|
|
|
// ── mobile_auth_codes ──────────────────────────────────────────────────────
|
|
|
|
// Insert a freshly minted authorization code (by hash).
|
|
async function insertCode({ codeHash, userId, sessionId, expiresAt }) {
|
|
const res = await query(
|
|
`INSERT INTO mobile_auth_codes (code_hash, user_id, session_id, expires_at)
|
|
VALUES (?, ?, ?, ?)`,
|
|
[codeHash, userId, sessionId, new Date(expiresAt)],
|
|
)
|
|
return res.insertId
|
|
}
|
|
|
|
// Return an authorization-code row only if it is still redeemable: not used and
|
|
// not expired. Returns the row (incl. user_id, session_id) or null.
|
|
async function findValidCode(codeHash) {
|
|
const rows = await query(
|
|
`SELECT * FROM mobile_auth_codes
|
|
WHERE code_hash = ? AND used_at IS NULL AND expires_at > NOW()
|
|
LIMIT 1`,
|
|
[codeHash],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
// Atomically mark a code used (single-use gate). Only affects a not-yet-used row,
|
|
// so a concurrent double-redeem sees affectedRows = 0 on the loser. Returns rows
|
|
// changed.
|
|
async function markCodeUsed(codeHash) {
|
|
const res = await query(
|
|
'UPDATE mobile_auth_codes SET used_at = NOW() WHERE code_hash = ? AND used_at IS NULL',
|
|
[codeHash],
|
|
)
|
|
return Number(res.affectedRows || 0)
|
|
}
|
|
|
|
// Housekeeping: drop long-dead rows from both bridge tables (expired, or codes
|
|
// already used). Keeps the tables from growing without bound. Returns rows removed.
|
|
async function pruneExpired() {
|
|
const a = await query('DELETE FROM mobile_auth_codes WHERE expires_at < NOW() OR used_at IS NOT NULL')
|
|
const b = await query("DELETE FROM mobile_auth_sessions WHERE expires_at < NOW() OR status = 'consumed'")
|
|
return Number(a.affectedRows || 0) + Number(b.affectedRows || 0)
|
|
}
|
|
|
|
module.exports = {
|
|
insertSession,
|
|
getSession,
|
|
completeSession,
|
|
setTrustDevice,
|
|
consumeSession,
|
|
insertCode,
|
|
findValidCode,
|
|
markCodeUsed,
|
|
pruneExpired,
|
|
}
|