"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>
205 lines
9.5 KiB
JavaScript
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 }
|