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

@@ -18,6 +18,7 @@ const userIdentities = require('../../../model/userIdentities/userIdentities.mod
const settings = require('../../../model/settings/settings.model')
const registry = require('../../../auth/providers/registry')
const sessionService = require('../../../auth/session.service')
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
const ssoState = require('../../../auth/ssoState')
const token = require('../../../auth/token')
const totp = require('../../../utils/totp')
@@ -96,6 +97,27 @@ async function listProviders(req, res) {
}
}
// Shared IdP redirect: validate the provider is usable, mint the SSO tx (carrying
// any extra `txData`, e.g. mode/linkUserId/returnTo, or the mobile bridge's
// mode:'mobile' + mobileSessionId), set the httpOnly tx cookie, and 302 to the
// provider authorize URL. Returns true on redirect; false means the provider is
// unavailable and the caller renders its own failure (web pages redirect to an
// error; the mobile bridge surfaces it to the app). Used by both web start
// (beginFlow) and the mobile bridge start (routes/mobileSso.controller).
async function redirectToIdp(req, res, providerId, txData = {}) {
const row = await authProviders.getWithSecret(providerId)
if (!row || !row.enabled || !registry.validateConfig(row).valid) return false
const provider = registry.instantiate(row)
const tx = ssoState.createTx({ provider: providerId, ...txData })
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
const url = provider.getAuthorizationUrl(tx.nonce, {
redirectUri: redirectUriFor(req, providerId),
codeChallenge: tx.codeChallenge,
})
res.redirect(url)
return true
}
// Shared start for both login and link. `mode` ∈ 'login' | 'link'. For link,
// requireAuth has already run so req.user is the account to attach the identity to.
async function beginFlow(req, res, mode) {
@@ -105,26 +127,17 @@ async function beginFlow(req, res, mode) {
const failUrl = mode === 'link' ? accountError('error', portal) : loginError('error', portal)
try {
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
const row = await authProviders.getWithSecret(providerId)
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
const ok = await redirectToIdp(req, res, providerId, {
mode,
linkUserId: mode === 'link' ? req.user.id : undefined,
returnTo: returnTo || undefined,
})
if (!ok) {
log.warn('sso start: provider unavailable', { provider: providerId, mode })
return res.redirect(
mode === 'link' ? accountError('unavailable', portal) : loginError('unavailable', portal),
)
}
const provider = registry.instantiate(row)
const tx = ssoState.createTx({
provider: providerId,
mode,
linkUserId: mode === 'link' ? req.user.id : undefined,
returnTo: returnTo || undefined,
})
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
const url = provider.getAuthorizationUrl(tx.nonce, {
redirectUri: redirectUriFor(req, providerId),
codeChallenge: tx.codeChallenge,
})
return res.redirect(url)
} catch (err) {
log.error('sso start', err)
return res.redirect(failUrl)
@@ -167,6 +180,7 @@ async function callback(req, res) {
codeVerifier: tx.verifier,
})
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile)
return finishLogin(req, res, providerId, row.kind, tx, profile)
} catch (err) {
log.error('sso callback', err)
@@ -266,6 +280,100 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
return res.redirect(sanitizeReturn(tx.returnTo) || homePath(portal))
}
// ── Mobile SSO bridge (mode 'mobile') ──────────────────────────────────────
// The mobile flow ends by handing the app a deep link carrying a one-time
// authorization code (never a token) + the app's original `state`. redirect_uri
// came from the exact-match allowlist at /start, so appending our params is safe.
function appDeepLink(redirectUri, params) {
const sep = redirectUri.includes('?') ? '&' : '?'
const qs = Object.entries(params)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&')
return `${redirectUri}${sep}${qs}`
}
const appError = (sess, code) => appDeepLink(sess.redirect_uri, { error: code, state: sess.state })
// Mint the one-time auth code for a resolved account and return the app success
// deep link (or null if the bridge session was no longer pending — e.g. expired
// or already used). Shared by the direct callback and the TOTP-completion path so
// the "issue code + record login + audit" logic lives in one place. Does not touch
// res, so either caller can 302 (callback) or JSON-wrap it (TOTP fetch).
async function mintMobileAuthLink(req, sess, user, providerId, viaTotp) {
const issued = await mobileBridge.issueAuthCode({ sessionId: sess.session_id, userId: user.id })
if (!issued) {
log.warn('mobile sso: auth code not issued (session not pending)', { provider: providerId, id: user.id })
return null
}
await users.recordLogin(user.id, req.ip)
await activity.log({
req,
userId: user.id,
action: 'auth.sso.login',
detail: { provider: providerId, mobile: true, totp: viaTotp || undefined },
})
log.info('mobile sso login success', { provider: providerId, id: user.id, ip: req.ip, totp: !!viaTotp })
return appDeepLink(sess.redirect_uri, { code: issued.code, state: sess.state })
}
// Mobile variant of finishLogin: identical account-resolution policy (link-only
// with opt-in provisioning, status gate, TOTP), but a success mints a one-time
// code and 302s to the app callback instead of setting a session cookie. A 2FA
// account is routed through the same web TOTP form (carrying the bridge session)
// and completes in finishSsoTotp — the second factor is never bypassed.
async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
const sess = await mobileBridge.getSession(tx.mobileSessionId)
if (!sess || sess.status !== 'pending' || new Date(sess.expires_at).getTime() <= Date.now()) {
log.warn('mobile sso callback: bridge session invalid/expired', { provider: providerId })
// Without a valid session we can't trust a redirect_uri — fail generically.
if (sess) return res.redirect(appError(sess, 'session_expired'))
return res
.status(400)
.json({ message: 'This sign-in session is invalid or has expired. Please try again from the app.' })
}
let user
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
if (identity) {
user = await users.getById(identity.user_id)
if (!user) return res.redirect(appError(sess, 'not_linked'))
} else {
const mode = await settings.getRegistrationMode()
if (mode !== 'sso' && mode !== 'both') {
log.warn('mobile sso login refused: no linked account', { provider: providerId })
return res.redirect(appError(sess, 'not_linked'))
}
user = await provisionSsoPlayer(req, providerId, profile)
if (!user) return res.redirect(appError(sess, 'error'))
}
if (user.status && user.status !== 'active') {
log.warn('mobile sso login refused: inactive account', { provider: providerId, id: user.id, status: user.status })
return res.redirect(appError(sess, 'disabled'))
}
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
if (needsTotp(user)) {
// Same second-factor gate as web: stage a signed pending-TOTP cookie (now
// carrying the bridge session) and route the Custom Tab through the player
// TOTP form. finishSsoTotp completes the mobile flow on a correct code.
const pending = ssoState.createTotpPending({
userId: user.id,
provider: providerId,
authMethod,
returnTo: '/account',
mobileSessionId: sess.session_id,
})
res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req))
log.info('mobile sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip })
return res.redirect(`${loginPath('account')}?sso_totp=1`)
}
const link = await mintMobileAuthLink(req, sess, user, providerId, false)
return res.redirect(link || appError(sess, 'error'))
}
// POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on.
// Reads the staged pending-TOTP cookie, verifies the authenticator code, then
// mints the full session. Mirrors auth.controller.loginTotp: a wrong code is a
@@ -293,9 +401,25 @@ async function finishSsoTotp(req, res) {
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
}
// Second factor satisfied — clear the staged cookie and issue the real session.
// Second factor satisfied — clear the staged cookie.
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
loginProtection.recordSuccess(req.ip)
// Mobile SSO bridge: instead of a web session, mint the one-time auth code and
// return a deep link for the app to redeem. The second factor is now complete,
// so the code is issued no earlier than an ordinary web session would be.
if (pending.mobileSessionId) {
const sess = await mobileBridge.getSession(pending.mobileSessionId)
if (!sess || sess.status !== 'pending' || new Date(sess.expires_at).getTime() <= Date.now()) {
return res.status(401).json({ message: 'Your sign-in session expired. Please sign in again from the app.' })
}
const link = await mintMobileAuthLink(req, sess, user, pending.provider, true)
if (!link) {
return res.status(409).json({ message: 'This sign-in session was already used. Please sign in again from the app.' })
}
return res.json({ redirect: link })
}
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
const { token: sessionToken } = sessionService.createSession(user, authMethod)
token.setAuthCookie(req, res, sessionToken)
@@ -330,4 +454,15 @@ async function finishLink(req, res, providerId, tx, profile) {
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
}
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink }
module.exports = {
listProviders,
start,
linkStart,
callback,
beginFlow,
redirectToIdp,
finishLogin,
finishMobileLogin,
finishSsoTotp,
finishLink,
}