Files
website/server/src/router/v1/auth/mobileSso.controller.js
wtclaude 61f4591a6b 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>
2026-07-20 16:55:06 -05:00

152 lines
6.8 KiB
JavaScript

// ── Mobile SSO authorization bridge — start + exchange ─────────────────────
//
// The native app's "Sign in with Google/Discord" without shipping any OAuth
// secret. This controller owns the two app-facing endpoints; the SSO redirect
// mechanics and the callback branch live in sso.controller (reused, not
// duplicated), and the bridge state lives in the mobileAuthBridge model.
//
// GET /auth/mobile/sso/start → seed a bridge session, reuse the SSO redirect
// POST /auth/mobile/sso/exchange → code + PKCE verifier → mobile bearer tokens
//
// Two PKCE layers are in play (see docs BACKEND_DESIGN §4): Layer A (website↔IdP,
// handled entirely inside the reused SSO flow) and Layer B (app↔website, verified
// here at /exchange). Do not conflate them.
const crypto = require('crypto')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
const sessionService = require('../../../auth/session.service')
const ssoState = require('../../../auth/ssoState')
const ssoController = require('./sso.controller')
const log = require('../../../utils/logger')('auth-mobile-sso')
// Exact-match allowlist of app callback URIs. Default is the one fixed
// application-owned custom scheme; HTTPS App Link URIs can be appended per shard
// later (see docs/android/APP_LINKS.md). EXACT match only — never a prefix match
// (prefix matching on custom schemes is a known open-redirect vector on mobile).
const REDIRECT_ALLOWLIST = new Set(
(process.env.MOBILE_AUTH_REDIRECT_URIS || 'runicgateway://auth/callback')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
)
const PROVIDER_ID_RE = /^[a-z0-9-]+$/
// Append query params to an (already-allowlisted) app callback URI.
function appDeepLink(redirectUri, params) {
const sep = redirectUri.includes('?') ? '&' : '?'
const qs = Object.entries(params)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&')
return `${redirectUri}${sep}${qs}`
}
// Constant-time string compare (equal-length guard first).
function safeEqual(a, b) {
const bufA = Buffer.from(String(a))
const bufB = Buffer.from(String(b))
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB)
}
// GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri
// Opened by the app in a Custom Tab. Validates the request, seeds a bridge
// session carrying the app's PKCE challenge + state + callback, then reuses the
// existing SSO redirect (tagged mode:'mobile') to hand off to the IdP.
async function start(req, res) {
const { provider, code_challenge: codeChallenge, state, redirect_uri: redirectUri } = req.query
try {
// redirect_uri must be exactly one of the registered app callbacks. Validate
// it FIRST — everything else can only be surfaced to the app by redirecting
// to a trusted callback, so an untrusted one is a hard 400 (no redirect).
if (!REDIRECT_ALLOWLIST.has(redirectUri)) {
log.warn('mobile sso start: redirect_uri not in allowlist', { ip: req.ip })
return res.status(400).json({ message: 'Unrecognized redirect URI.' })
}
if (!PROVIDER_ID_RE.test(provider || '')) {
return res.redirect(appDeepLink(redirectUri, { error: 'invalid_provider', state }))
}
const { sessionId } = await mobileBridge.startSession({ provider, codeChallenge, redirectUri, state })
const ok = await ssoController.redirectToIdp(req, res, provider, {
mode: 'mobile',
mobileSessionId: sessionId,
})
if (!ok) {
log.warn('mobile sso start: provider unavailable', { provider })
return res.redirect(appDeepLink(redirectUri, { error: 'provider_unavailable', state }))
}
// redirectToIdp already issued the 302 on success.
} catch (err) {
log.error('mobile sso start', err)
// We validated redirect_uri above, so it's safe to bounce the error to the app.
return res.redirect(appDeepLink(redirectUri, { error: 'server_error', state }))
}
}
// POST /auth/mobile/sso/exchange { code, code_verifier }
// Trades the one-time authorization code (+ its PKCE verifier) for the SAME mobile
// access + refresh pair as /auth/mobile/login. Single-use, PKCE-bound.
async function exchange(req, res) {
const { code, code_verifier: codeVerifier } = req.body
try {
const codeRow = await mobileBridge.findRedeemableCode(code)
if (!codeRow) {
log.warn('mobile sso exchange: code unknown/expired/used', { ip: req.ip })
return res.status(401).json({ message: 'Invalid or expired authorization code.' })
}
const sess = await mobileBridge.getSession(codeRow.session_id)
if (!sess) {
return res.status(401).json({ message: 'Invalid or expired authorization code.' })
}
// PKCE Layer B: the app proves it holds the verifier for the challenge it
// registered at /start. Check BEFORE burning the code so a caller lacking the
// verifier (e.g. a callback interceptor) can't consume a legitimate code.
if (!safeEqual(ssoState.codeChallengeFor(codeVerifier || ''), sess.code_challenge)) {
log.warn('mobile sso exchange: PKCE verifier mismatch', { ip: req.ip })
return res.status(401).json({ message: 'PKCE verification failed.' })
}
// Atomic single-use gate: only the winner of a concurrent double-redeem
// proceeds; a losing/replayed attempt sees false here.
if (!(await mobileBridge.consumeCode(code))) {
log.warn('mobile sso exchange: code already used', { ip: req.ip })
return res.status(401).json({ message: 'Invalid or expired authorization code.' })
}
const user = await users.getById(codeRow.user_id) // fresh row; 401 if the account vanished
if (!user) {
return res.status(401).json({ message: 'Invalid or expired authorization code.' })
}
await mobileBridge.finishSession(codeRow.session_id)
const meta = sessionService.sessionMeta(req)
const out = sessionService.createMobileSession(user, meta)
await mobileSessions.store({
userId: user.id,
tokenHash: out.refreshHash,
deviceHash: out.deviceHash,
userAgent: out.userAgent,
expiresAt: out.refreshExpiresAt,
})
await activity.log({ req, userId: user.id, action: 'auth.mobile.login', detail: { sso: sess.provider } })
log.info('mobile sso exchange success', { id: user.id, provider: sess.provider, ip: req.ip })
return res.json({
accessToken: out.accessToken,
refreshToken: out.refreshToken,
expiresIn: out.expiresIn,
user: { id: user.id, username: user.username, role: user.role },
})
} catch (err) {
log.error('mobile sso exchange', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { start, exchange }