// ── 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 }