Files
website/server/src/model/mobileAuthBridge/mobileAuthBridge.db.js
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

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,
}