Files
website/server/src/router/v1/auth/mobileSso.controller.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

205 lines
9.5 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 settings = require('../../../model/settings/settings.model')
const sessionService = require('../../../auth/session.service')
const ssoState = require('../../../auth/ssoState')
const ssoController = require('./sso.controller')
const { establishTrust } = require('./trustDevice.helper')
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-]+$/
// This shard's own https App Link callback. Built from APP_BASE_URL (preferred, so
// it is never derived from an attacker-set Host header) or, failing that, the
// request's own origin. Path is the fixed /mobile/callback the app's autoVerify
// intent-filter is registered for.
function selfOriginCallback(req) {
const base = (process.env.APP_BASE_URL || '').trim().replace(/\/+$/, '')
const origin = base || `${req.protocol}://${req.get('host')}`
return `${origin}/mobile/callback`
}
// redirect_uri is valid if it is one of the statically-allowlisted app callbacks
// (default the custom scheme), OR — only when the admin has enabled App Links —
// this shard's own https://<host>/mobile/callback. EXACT match in both cases; the
// App Links entry is additive and never narrows the custom-scheme allowlist.
async function isAllowedRedirect(redirectUri, req) {
if (REDIRECT_ALLOWLIST.has(redirectUri)) return true
// App Links only ever add an https callback, so skip the settings lookup for
// anything that can't be one — custom-scheme rejections stay fast and DB-free.
if (typeof redirectUri === 'string' && redirectUri.startsWith('https://')) {
if (await settings.isMobileAppLinksEnabled()) {
return redirectUri === selfOriginCallback(req)
}
}
return false
}
// 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 (!(await isAllowedRedirect(redirectUri, req))) {
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,
deviceName: req.body.device_name || null,
userAgent: out.userAgent,
expiresAt: out.refreshExpiresAt,
})
await activity.log({ req, userId: user.id, action: 'auth.mobile.login', detail: { sso: sess.provider } })
// The user ticked "trust this device" on the Custom Tab TOTP form. That already
// set the browser's rg_trust cookie (which is what lets the NEXT Custom Tab SSO
// sign-in skip the code); mint the app its OWN trust token here so a native
// password login on the same device skips the code too. Minting at this point
// — an authenticated app→server call — is deliberate: the token reaches the app
// in a JSON body and never travels in the deep-link URL or sits in the bridge
// row. Best-effort: a device at the trust cap just gets no token, never a failed
// sign-in, so this can't turn a good login into an error.
let trustToken = null
if (sess.trust_device) {
try {
const trust = await establishTrust(req, user, {
platform: 'mobile',
deviceName: req.body.device_name || null,
})
if (trust.ok) trustToken = trust.trustToken
else if (trust.capReached) log.info('mobile sso: trust refused, device cap reached', { id: user.id })
} catch (err) {
log.error('mobile sso: could not establish trust (continuing, sign-in already succeeded)', err)
}
}
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 },
...(trustToken ? { trustToken } : {}),
})
} catch (err) {
log.error('mobile sso exchange', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { start, exchange }