feat(auth): native SSO authorization bridge for the Android app

Add a Mobile SSO Authorization Bridge so the native app can "Sign in with
Google/Discord" without shipping any OAuth secret. It EXTENDS the existing
/auth/sso/* redirect flow (same PKCE-vs-IdP, link-only + opt-in provisioning,
TOTP gate) and terminates in the existing mobile bearer tokens — not a parallel
auth path.

- Schema: mobile_auth_sessions + mobile_auth_codes (short-lived, self-pruning;
  authorization code stored hash-only, PKCE challenge is a hash by construction).
- GET /auth/mobile/sso/start: validate provider enabled + redirect_uri by EXACT
  allowlist match (never prefix), seed a bridge session, reuse the SSO redirect
  tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow).
- SSO callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use,
  hashed, PKCE-bound code and redirect to the fixed app callback (code + echoed
  state, never a token) instead of setting a cookie. 2FA keeps full parity via
  the existing web TOTP form (now carrying the bridge session).
- POST /auth/mobile/sso/exchange: verify Layer-B PKCE (before burning the code),
  single-use consume, then issue the SAME pair as /auth/mobile/login.
- Discovery reuses GET /auth/providers; refresh/logout reuse /auth/mobile/*.
- Rate limits: /start per-IP+provider, /exchange per-IP. Boot-time +
  opportunistic prune of both tables (no cron, mirrors revoked_sessions).
- Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed
  runicgateway://auth/callback); App Link URIs can be appended per shard later.
- Swagger regenerated; 39 tests (model single-use/gating + full controller
  matrix: bad/expired/reused code, PKCE mismatch, disabled provider, redirect
  allowlist, TOTP-through-bridge). Full suite green (271).

Refs docs/website/BACKEND_DESIGN.md, docs/android/PLAN.md §9 (M9).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-20 16:55:06 -05:00
parent 31b72859ce
commit 61f4591a6b
14 changed files with 1200 additions and 20 deletions

View File

@@ -0,0 +1,101 @@
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)
}
// 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,
consumeSession,
insertCode,
findValidCode,
markCodeUsed,
pruneExpired,
}