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