diff --git a/server/db/schema.sql b/server/db/schema.sql index 9edc0d9..d46e57b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -197,6 +197,41 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens ( INDEX idx_mrt_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Mobile SSO authorization bridge (M9). Two short-lived, self-pruning tables that +-- bridge a browser SSO redirect flow to the native app. They carry the app↔website +-- PKCE + CSRF state (a SECOND PKCE layer, distinct from the website↔IdP PKCE the +-- sso_tx cookie already carries) and the one-time code the app trades for bearer +-- tokens. No secret is stored in the clear: code_challenge is a hash by construction +-- and the authorization code is stored as a sha256 hash only (same pattern as +-- mobile_refresh_tokens / user_invites / password_resets). See docs BACKEND_DESIGN §3/§4. +CREATE TABLE IF NOT EXISTS mobile_auth_sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + session_id CHAR(36) NOT NULL UNIQUE, -- uuid; carried inside the signed sso_tx (mode 'mobile') + provider VARCHAR(40) NOT NULL, -- provider id, validated enabled at /start + code_challenge VARCHAR(255) NOT NULL, -- app-supplied PKCE S256 challenge (base64url) + redirect_uri VARCHAR(255) NOT NULL, -- app callback; EXACT-match against the allowlist + state VARCHAR(255) NOT NULL, -- app-generated opaque CSRF value, echoed to the app + status ENUM('pending','completed','consumed') NOT NULL DEFAULT 'pending', + user_id INT NULL, -- set once SSO resolves the account + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, -- ~10 min (one redirect round-trip incl. TOTP) + used_at DATETIME NULL, -- stamped at exchange + CONSTRAINT fk_mas_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_mas_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS mobile_auth_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + code_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque >=128-bit code + user_id INT NOT NULL, + session_id CHAR(36) NOT NULL, -- owning mobile_auth_sessions.session_id (ties code→PKCE challenge) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, -- very short (~5 min) + used_at DATETIME NULL, -- set on first successful exchange (single use) + CONSTRAINT fk_mac_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_mac_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted -- per session in createSession. A single logout adds this session's jti here; -- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at diff --git a/server/src/auth/ssoState.js b/server/src/auth/ssoState.js index 9bbf2d5..e4e5203 100644 --- a/server/src/auth/ssoState.js +++ b/server/src/auth/ssoState.js @@ -70,10 +70,15 @@ function verifyTx(txToken, stateNonce) { // session: `stage: 'totp'` makes session validation reject it (same marker the // local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and // scopes it to the SSO completion endpoint. -function createTotpPending({ userId, provider, authMethod, returnTo }) { +// +// `mobileSessionId` is present only for a mobile SSO bridge flow (mode 'mobile'): +// it threads the bridge session through the TOTP form so that, on a correct code, +// the completion mints an authorization code + deep-links back to the app instead +// of setting a web session cookie. Absent for ordinary web SSO. +function createTotpPending({ userId, provider, authMethod, returnTo, mobileSessionId }) { return token.signToken( { id: userId }, // subject only; identity is re-loaded fresh when the code is verified - { stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo }, + { stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo, mobileSessionId }, { expiresIn: TOTP_TTL }, ) } diff --git a/server/src/middleware/rateLimit.js b/server/src/middleware/rateLimit.js index fa00683..b1c71f3 100644 --- a/server/src/middleware/rateLimit.js +++ b/server/src/middleware/rateLimit.js @@ -2,13 +2,18 @@ const rateLimit = require('express-rate-limit') const log = require('../utils/logger')('ratelimit') -function makeLimiter({ windowMs, max, label, message }) { +function makeLimiter({ windowMs, max, label, message, keyGenerator, validate }) { return rateLimit({ windowMs, max, standardHeaders: true, legacyHeaders: false, message: { message }, + // Default key is the client IP; callers can widen it (e.g. IP + provider). + ...(keyGenerator ? { keyGenerator } : {}), + // Custom keyGenerators that fold in req.ip trip v7's IPv6 fallback validator; + // callers pass `validate` to scope that off just for their limiter. + ...(validate !== undefined ? { validate } : {}), handler: (req, res, next, options) => { log.warn(`${label} rate limit exceeded`, { ip: req.ip, path: req.originalUrl }) res.status(options.statusCode).json(options.message) @@ -71,6 +76,29 @@ const ssoStartLimiter = makeLimiter({ message: 'Too many sign-in attempts. Please try again later.', }) +// Mobile SSO bridge — throttle /start per IP AND per provider: each call spawns a +// mobile_auth_sessions row, so without a per-provider dimension /start is a cheap +// way to spam rows for one provider from many-but-few IPs. Generous for real users +// (a login is a handful of taps). `validate:{ip:false}` scopes off v7's IPv6 +// fallback check, which fires only because our key folds in req.ip. +const mobileSsoStartLimiter = makeLimiter({ + windowMs: 15 * 60 * 1000, + max: 20, + label: 'mobile-sso-start', + message: 'Too many sign-in attempts. Please try again later.', + keyGenerator: (req) => `${req.ip}:${req.query && req.query.provider ? req.query.provider : ''}`, + validate: { ip: false }, +}) + +// Mobile SSO bridge — throttle /exchange per IP. The code is single-use, PKCE-bound +// and short-lived, but cap redemption attempts anyway to blunt guessing. +const mobileSsoExchangeLimiter = makeLimiter({ + windowMs: 15 * 60 * 1000, + max: 30, + label: 'mobile-sso-exchange', + message: 'Too many attempts. Please try again later.', +}) + // Password-reset requests per IP. Each one can send email, so cap tighter than // login to blunt email-bombing and enumeration timing probes. The endpoint always // returns a generic success regardless of match, so honest users never see this. @@ -97,6 +125,8 @@ module.exports = { contactLimiter, mobileRefreshLimiter, ssoStartLimiter, + mobileSsoStartLimiter, + mobileSsoExchangeLimiter, passwordResetRequestLimiter, passwordResetConfirmLimiter, } diff --git a/server/src/model/mobileAuthBridge/mobileAuthBridge.db.js b/server/src/model/mobileAuthBridge/mobileAuthBridge.db.js new file mode 100644 index 0000000..2ca462e --- /dev/null +++ b/server/src/model/mobileAuthBridge/mobileAuthBridge.db.js @@ -0,0 +1,101 @@ +const { query } = require('../../utils/db') + +// SQL for the mobile SSO authorization bridge (mobile_auth_sessions + +// mobile_auth_codes). The authorization code is stored as a sha256 hash only +// (hashing is done in the .model layer, mirroring mobileSessions); the PKCE +// code_challenge is a hash by construction and is stored as-supplied. Both +// tables self-prune on expiry (pruneExpired). See docs BACKEND_DESIGN §3/§4. + +// ── mobile_auth_sessions ─────────────────────────────────────────────────── + +// Insert a pending bridge session. expiresAt is a JS Date (or ms epoch). +async function insertSession({ sessionId, provider, codeChallenge, redirectUri, state, expiresAt }) { + const res = await query( + `INSERT INTO mobile_auth_sessions (session_id, provider, code_challenge, redirect_uri, state, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [sessionId, provider, codeChallenge, redirectUri, state, new Date(expiresAt)], + ) + return res.insertId +} + +// Look up a bridge session by its opaque session_id. Returns the row or null. +async function getSession(sessionId) { + const rows = await query('SELECT * FROM mobile_auth_sessions WHERE session_id = ? LIMIT 1', [sessionId]) + return rows[0] || null +} + +// Mark a still-pending, unexpired session `completed` and attach the resolved +// user. Guards on status + expiry so a replayed callback can't re-complete a +// consumed/expired session. Returns rows changed (0 = not eligible). +async function completeSession(sessionId, userId) { + const res = await query( + `UPDATE mobile_auth_sessions + SET status = 'completed', user_id = ? + WHERE session_id = ? AND status = 'pending' AND expires_at > NOW()`, + [userId, sessionId], + ) + return Number(res.affectedRows || 0) +} + +// Mark a session `consumed` after a successful token exchange (stamps used_at). +async function consumeSession(sessionId) { + const res = await query( + "UPDATE mobile_auth_sessions SET status = 'consumed', used_at = NOW() WHERE session_id = ?", + [sessionId], + ) + return Number(res.affectedRows || 0) +} + +// ── mobile_auth_codes ────────────────────────────────────────────────────── + +// Insert a freshly minted authorization code (by hash). +async function insertCode({ codeHash, userId, sessionId, expiresAt }) { + const res = await query( + `INSERT INTO mobile_auth_codes (code_hash, user_id, session_id, expires_at) + VALUES (?, ?, ?, ?)`, + [codeHash, userId, sessionId, new Date(expiresAt)], + ) + return res.insertId +} + +// Return an authorization-code row only if it is still redeemable: not used and +// not expired. Returns the row (incl. user_id, session_id) or null. +async function findValidCode(codeHash) { + const rows = await query( + `SELECT * FROM mobile_auth_codes + WHERE code_hash = ? AND used_at IS NULL AND expires_at > NOW() + LIMIT 1`, + [codeHash], + ) + return rows[0] || null +} + +// Atomically mark a code used (single-use gate). Only affects a not-yet-used row, +// so a concurrent double-redeem sees affectedRows = 0 on the loser. Returns rows +// changed. +async function markCodeUsed(codeHash) { + const res = await query( + 'UPDATE mobile_auth_codes SET used_at = NOW() WHERE code_hash = ? AND used_at IS NULL', + [codeHash], + ) + return Number(res.affectedRows || 0) +} + +// Housekeeping: drop long-dead rows from both bridge tables (expired, or codes +// already used). Keeps the tables from growing without bound. Returns rows removed. +async function pruneExpired() { + const a = await query('DELETE FROM mobile_auth_codes WHERE expires_at < NOW() OR used_at IS NOT NULL') + const b = await query("DELETE FROM mobile_auth_sessions WHERE expires_at < NOW() OR status = 'consumed'") + return Number(a.affectedRows || 0) + Number(b.affectedRows || 0) +} + +module.exports = { + insertSession, + getSession, + completeSession, + consumeSession, + insertCode, + findValidCode, + markCodeUsed, + pruneExpired, +} diff --git a/server/src/model/mobileAuthBridge/mobileAuthBridge.model.js b/server/src/model/mobileAuthBridge/mobileAuthBridge.model.js new file mode 100644 index 0000000..136f914 --- /dev/null +++ b/server/src/model/mobileAuthBridge/mobileAuthBridge.model.js @@ -0,0 +1,102 @@ +// ── Mobile SSO authorization bridge — logic layer ────────────────────────── +// +// Bridges the browser SSO redirect flow to the native app. A `/start` seeds a +// `mobile_auth_sessions` row carrying the app-supplied PKCE challenge, the app's +// CSRF `state`, and the exact callback URI. The SSO callback (once the account is +// resolved) mints a single-use authorization CODE tied to that session; the app +// redeems the code + its PKCE verifier at `/exchange` for bearer tokens. +// +// Secrets: the raw authorization code is generated here and returned ONCE to the +// caller (to place in the redirect URL); only its sha256 hash is persisted — the +// raw code never touches the DB. The PKCE code_challenge is already a hash. + +const crypto = require('crypto') + +const db = require('./mobileAuthBridge.db') + +// TTLs. A start→callback→exchange round-trip is quick, but the callback may route +// through the TOTP form, so give the session a little room; the code itself is +// very short-lived. +const SESSION_TTL_MS = 10 * 60 * 1000 // 10 min — one redirect round-trip incl. TOTP +const CODE_TTL_MS = 5 * 60 * 1000 // 5 min — the app redeems immediately + +// sha256 hex of a raw token — the persisted representation of an authorization code. +function hashCode(raw) { + return crypto.createHash('sha256').update(String(raw)).digest('hex') +} + +// Create a pending bridge session. Returns { sessionId } (opaque uuid) which the +// caller embeds in the signed sso_tx so the callback can find this row. Best-effort +// prune keeps the table trimmed without a cron (same approach as revoked_sessions). +async function startSession({ provider, codeChallenge, redirectUri, state, now = Date.now() }) { + const sessionId = crypto.randomUUID() + await db.insertSession({ + sessionId, + provider, + codeChallenge, + redirectUri, + state, + expiresAt: new Date(now + SESSION_TTL_MS), + }) + db.pruneExpired().catch(() => {}) // opportunistic; never blocks the flow + return { sessionId } +} + +// Fetch a bridge session row by session_id (or null). +async function getSession(sessionId) { + if (!sessionId) return null + return db.getSession(sessionId) +} + +// Mint the one-time authorization code for a resolved account. Marks the owning +// session `completed`; returns { code } (the RAW code, to place in the redirect +// URL) or null if the session isn't pending/eligible. 256 bits of entropy — well +// above the ≥128-bit floor — url-safe and opaque. +async function issueAuthCode({ sessionId, userId, now = Date.now() }) { + const completed = await db.completeSession(sessionId, userId) + if (!completed) return null // not pending / expired / already used + const code = crypto.randomBytes(32).toString('base64url') + await db.insertCode({ + codeHash: hashCode(code), + userId, + sessionId, + expiresAt: new Date(now + CODE_TTL_MS), + }) + db.pruneExpired().catch(() => {}) + return { code } +} + +// Look up a redeemable code row (unused + unexpired) for a raw code, or null. +async function findRedeemableCode(rawCode) { + if (!rawCode) return null + return db.findValidCode(hashCode(rawCode)) +} + +// Atomically burn a code (single-use). Returns true iff THIS call was the one that +// marked it used — a concurrent double-redeem gets false on the loser. +async function consumeCode(rawCode) { + const changed = await db.markCodeUsed(hashCode(rawCode)) + return changed > 0 +} + +// Mark a session fully consumed after a successful exchange. +async function finishSession(sessionId) { + return db.consumeSession(sessionId) +} + +// Drop expired/used rows from both tables. +async function pruneExpired() { + return db.pruneExpired() +} + +module.exports = { + SESSION_TTL_MS, + CODE_TTL_MS, + startSession, + getSession, + issueAuthCode, + findRedeemableCode, + consumeCode, + finishSession, + pruneExpired, +} diff --git a/server/src/router/v1/auth/mobile.routes.js b/server/src/router/v1/auth/mobile.routes.js index 6b5a55e..d5459f0 100644 --- a/server/src/router/v1/auth/mobile.routes.js +++ b/server/src/router/v1/auth/mobile.routes.js @@ -6,9 +6,15 @@ const { requireAuth } = require('../../../auth/session.middleware') const { loginLimiter, mobileRefreshLimiter } = require('../../../middleware/rateLimit') const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection') const validate = require('../../../middleware/validate') +const mobileSsoRouter = require('./mobileSso.routes') const mobileRouter = express.Router() +// Native SSO authorization bridge (M9) — /auth/mobile/sso/{start,exchange}. +// Additive alongside the credential login below; reuses the website SSO flow and +// terminates in the same bearer tokens. +mobileRouter.use('/sso', mobileSsoRouter) + // Mobile login is a credential surface too, so it sits behind the SAME guards as // web login (cheapest rejection first): per-IP backoff → progressive slowdown → // hard rate cap. diff --git a/server/src/router/v1/auth/mobileSso.controller.js b/server/src/router/v1/auth/mobileSso.controller.js new file mode 100644 index 0000000..06f1a41 --- /dev/null +++ b/server/src/router/v1/auth/mobileSso.controller.js @@ -0,0 +1,151 @@ +// ── 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 } diff --git a/server/src/router/v1/auth/mobileSso.routes.js b/server/src/router/v1/auth/mobileSso.routes.js new file mode 100644 index 0000000..8f76ec0 --- /dev/null +++ b/server/src/router/v1/auth/mobileSso.routes.js @@ -0,0 +1,55 @@ +const express = require('express') +const { body, query } = require('express-validator') + +const { start, exchange } = require('./mobileSso.controller') +const { mobileSsoStartLimiter, mobileSsoExchangeLimiter } = require('../../../middleware/rateLimit') +const validate = require('../../../middleware/validate') + +// Mobile SSO authorization bridge (M9). Mounted at /auth/mobile/sso. Native +// "Sign in with Google/Discord" that reuses the website's SSO flow and terminates +// in the existing mobile bearer tokens — no OAuth secret ever ships in the app. +// Provider discovery reuses GET /auth/providers; refresh/logout reuse the existing +// /auth/mobile/{refresh,logout}. See docs BACKEND_DESIGN §4 + docs/android/PLAN.md §9. +const mobileSsoRouter = express.Router() + +// GET /auth/mobile/sso/start — opened by the app in a Custom Tab; 302s to the IdP. +mobileSsoRouter.get( + '/start', + // #swagger.tags = ['Auth · Mobile'] + // #swagger.summary = 'Begin native SSO login (redirect to the IdP)' + // #swagger.description = 'Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.' + // #swagger.parameters['provider'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'Provider id from GET /auth/providers (e.g. google, discord).' } + // #swagger.parameters['code_challenge'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'App-generated PKCE S256 challenge (base64url).' } + // #swagger.parameters['state'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'App-generated opaque CSRF value, echoed on the callback for the app to verify.' } + // #swagger.parameters['redirect_uri'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback).' } + /* #swagger.responses[302] = { description: 'Redirect to the identity provider (or back to the app callback on error)' } */ + /* #swagger.responses[400] = { description: 'Unrecognized redirect URI or validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + mobileSsoStartLimiter, + query('provider').isString().trim().isLength({ min: 1, max: 40 }), + query('code_challenge').isString().trim().isLength({ min: 20, max: 255 }), + query('state').isString().trim().isLength({ min: 8, max: 255 }), + query('redirect_uri').isString().trim().isLength({ min: 1, max: 255 }), + validate, + start, +) + +// POST /auth/mobile/sso/exchange — code + PKCE verifier → mobile bearer tokens. +mobileSsoRouter.post( + '/exchange', + // #swagger.tags = ['Auth · Mobile'] + // #swagger.summary = 'Exchange an SSO authorization code for mobile tokens' + // #swagger.description = 'Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.' + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileSsoExchangeRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Invalid/expired/used code or failed PKCE verification', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + mobileSsoExchangeLimiter, + body('code').isString().trim().isLength({ min: 20, max: 255 }), + body('code_verifier').isString().trim().isLength({ min: 20, max: 255 }), + validate, + exchange, +) + +module.exports = mobileSsoRouter diff --git a/server/src/router/v1/auth/sso.controller.js b/server/src/router/v1/auth/sso.controller.js index 6bc1509..3d3f1cf 100644 --- a/server/src/router/v1/auth/sso.controller.js +++ b/server/src/router/v1/auth/sso.controller.js @@ -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, +} diff --git a/server/src/server.js b/server/src/server.js index 28e95a2..f9890a3 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -13,6 +13,7 @@ const { ensureSchema, close } = require('./utils/db') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const settings = require('./model/settings/settings.model') const revokedSessions = require('./model/revokedSessions/revokedSessions.model') +const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model') const createLogger = require('./utils/logger') const { evaluateBotInternalKey } = require('./utils/botInternalKey') const brand = require('./config/brand') @@ -67,6 +68,15 @@ async function start() { log.warn('revoked-session prune failed', { error: err.message }) } + // Same treatment for the mobile SSO bridge tables (also pruned opportunistically + // on each bridge write). Boot-time sweep catches rows orphaned by a crash. + try { + const pruned = await mobileAuthBridge.pruneExpired() + if (pruned) log.info(`pruned ${pruned} expired mobile-auth-bridge row(s)`) + } catch (err) { + log.warn('mobile-auth-bridge prune failed', { error: err.message }) + } + const mode = await settings.get('site_mode') log.info(`site mode: ${String(mode || 'live').toUpperCase()}`) diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 19b90e4..e743f9a 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -894,6 +894,142 @@ } } }, + "/api/v1/auth/mobile/sso/start": { + "get": { + "tags": [ + "Auth · Mobile" + ], + "summary": "Begin native SSO login (redirect to the IdP)", + "description": "Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.", + "parameters": [ + { + "name": "provider", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Provider id from GET /auth/providers (e.g. google, discord)." + }, + { + "name": "code_challenge", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "App-generated PKCE S256 challenge (base64url)." + }, + { + "name": "state", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "App-generated opaque CSRF value, echoed on the callback for the app to verify." + }, + { + "name": "redirect_uri", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback)." + } + ], + "responses": { + "302": { + "description": "Redirect to the identity provider (or back to the app callback on error)" + }, + "400": { + "description": "Unrecognized redirect URI or validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts (rate limited)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/auth/mobile/sso/exchange": { + "post": { + "tags": [ + "Auth · Mobile" + ], + "summary": "Exchange an SSO authorization code for mobile tokens", + "description": "Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.", + "responses": { + "200": { + "description": "Access + refresh tokens", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileTokenResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Invalid/expired/used code or failed PKCE verification", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts (rate limited)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileSsoExchangeRequest" + } + } + } + } + } + }, "/api/v1/auth/providers": { "get": { "tags": [ @@ -1096,6 +1232,9 @@ "403": { "description": "Forbidden" }, + "409": { + "description": "Conflict" + }, "429": { "description": "Too many attempts (rate limited / backoff)", "content": { @@ -11115,6 +11254,56 @@ } } }, + "MobileSsoExchangeRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "code", + "code_verifier" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The single-use authorization code returned to the app callback." + } + } + }, + "code_verifier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The PKCE verifier for the challenge sent to /auth/mobile/sso/start." + } + } + } + } + } + } + }, "Message": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index c31ec07..870a2fd 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -185,6 +185,20 @@ const doc = { all: { type: 'boolean', description: 'Revoke every session for the user.', example: false }, }, }, + MobileSsoExchangeRequest: { + type: 'object', + required: ['code', 'code_verifier'], + properties: { + code: { + type: 'string', + description: 'The single-use authorization code returned to the app callback.', + }, + code_verifier: { + type: 'string', + description: 'The PKCE verifier for the challenge sent to /auth/mobile/sso/start.', + }, + }, + }, Message: { type: 'object', properties: { message: { type: 'string', example: 'Logged out.' } }, diff --git a/server/test/mobileAuthBridge.model.test.js b/server/test/mobileAuthBridge.model.test.js new file mode 100644 index 0000000..47dec8e --- /dev/null +++ b/server/test/mobileAuthBridge.model.test.js @@ -0,0 +1,83 @@ +// Pure-logic tests for the mobile SSO bridge MODEL. The .db layer is stubbed +// (plain object exports → mutable in-process), so these are DB-free and only +// exercise the model's gating, hashing, and code-generation logic. +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret' +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, after } = require('node:test') +const assert = require('node:assert/strict') +const crypto = require('crypto') + +const bridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model') +const bridgeDb = require('../src/model/mobileAuthBridge/mobileAuthBridge.db') +const db = require('../src/utils/db') + +after(() => db.close()) + +const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex') + +let calls +beforeEach(() => { + calls = { insertSession: [], insertCode: [], completeSession: [], markCodeUsed: [], findValidCode: [] } + bridgeDb.insertSession = async (a) => { calls.insertSession.push(a); return 1 } + bridgeDb.insertCode = async (a) => { calls.insertCode.push(a); return 1 } + bridgeDb.completeSession = async (sid, uid) => { calls.completeSession.push([sid, uid]); return 1 } + bridgeDb.consumeSession = async () => 1 + bridgeDb.getSession = async () => null + bridgeDb.findValidCode = async (h) => { calls.findValidCode.push(h); return { code_hash: h, user_id: 9, session_id: 's' } } + bridgeDb.markCodeUsed = async () => 1 + bridgeDb.pruneExpired = async () => 0 +}) + +test('startSession generates a uuid session_id and persists the row', async () => { + const { sessionId } = await bridge.startSession({ + provider: 'google', codeChallenge: 'chal', redirectUri: 'runicgateway://auth/callback', state: 'st', + }) + assert.match(sessionId, /^[0-9a-f-]{36}$/) + assert.equal(calls.insertSession.length, 1) + assert.equal(calls.insertSession[0].sessionId, sessionId) + assert.equal(calls.insertSession[0].provider, 'google') + assert.ok(calls.insertSession[0].expiresAt instanceof Date) + assert.ok(calls.insertSession[0].expiresAt.getTime() > Date.now()) +}) + +test('issueAuthCode: completes the session, stores only the code HASH, returns the raw code once', async () => { + const out = await bridge.issueAuthCode({ sessionId: 's-1', userId: 42 }) + assert.ok(out && typeof out.code === 'string') + // >=128-bit, url-safe, opaque. + assert.ok(out.code.length >= 22, 'at least 128 bits of base64url entropy') + assert.match(out.code, /^[A-Za-z0-9_-]+$/) + // Session was marked completed for THIS user. + assert.deepEqual(calls.completeSession[0], ['s-1', 42]) + // Only the sha256 hash of the code is persisted; the raw code never is. + assert.equal(calls.insertCode[0].codeHash, sha256(out.code)) + assert.equal(calls.insertCode[0].userId, 42) + assert.equal(calls.insertCode[0].sessionId, 's-1') +}) + +test('issueAuthCode returns null (mints no code) when the session is not pending/eligible', async () => { + bridgeDb.completeSession = async () => 0 // not pending / expired / already used + const out = await bridge.issueAuthCode({ sessionId: 's-1', userId: 42 }) + assert.equal(out, null) + assert.equal(calls.insertCode.length, 0, 'no code row when the session could not be completed') +}) + +test('findRedeemableCode looks up by the code HASH, not the raw code', async () => { + const row = await bridge.findRedeemableCode('raw-code-value') + assert.equal(calls.findValidCode[0], sha256('raw-code-value')) + assert.equal(row.user_id, 9) +}) + +test('consumeCode is single-use: true only when a row was actually flipped', async () => { + bridgeDb.markCodeUsed = async () => 1 + assert.equal(await bridge.consumeCode('c'), true) + bridgeDb.markCodeUsed = async () => 0 // already used / raced + assert.equal(await bridge.consumeCode('c'), false) +}) + +test('two issued codes are distinct (fresh entropy each time)', async () => { + const a = await bridge.issueAuthCode({ sessionId: 's', userId: 1 }) + const b = await bridge.issueAuthCode({ sessionId: 's', userId: 1 }) + assert.notEqual(a.code, b.code) +}) diff --git a/server/test/mobileSsoBridge.test.js b/server/test/mobileSsoBridge.test.js new file mode 100644 index 0000000..ff43fc5 --- /dev/null +++ b/server/test/mobileSsoBridge.test.js @@ -0,0 +1,264 @@ +// Mobile SSO authorization bridge — controller/integration tests. Models are +// stubbed (mutable object exports), so these are DB-free and exercise the bridge +// start/exchange handlers plus the mode:'mobile' branches added to sso.controller. +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret' +process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key' +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const mobileSso = require('../src/router/v1/auth/mobileSso.controller') +const ssoCtrl = require('../src/router/v1/auth/sso.controller') +const ssoState = require('../src/auth/ssoState') +const token = require('../src/auth/token') +const bridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model') +const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model') +const users = require('../src/model/users/users.model') +const activity = require('../src/model/activity/activity.model') +const authProviders = require('../src/model/authProviders/authProviders.model') +const userIdentities = require('../src/model/userIdentities/userIdentities.model') +const settings = require('../src/model/settings/settings.model') +const registry = require('../src/auth/providers/registry') +const totp = require('../src/utils/totp') +const db = require('../src/utils/db') + +after(() => db.close()) + +const CALLBACK = 'runicgateway://auth/callback' +const PROFILE = { subject: 'sub-1', email: 'alice@example.com', name: 'Alice' } +const SESSION = { + session_id: 'sess-1', + status: 'pending', + provider: 'google', + redirect_uri: CALLBACK, + state: 'st-abc', + code_challenge: ssoState.codeChallengeFor('verifier-xyz'), + expires_at: new Date(Date.now() + 5 * 60 * 1000), +} + +let logged +beforeEach(() => { + logged = [] + activity.log = async (e) => { logged.push(e) } + authProviders.getWithSecret = async () => ({ id: 'google', kind: 'google', enabled: 1, client_id: 'c', client_secret_enc: 'e' }) + registry.instantiate = () => ({ handleCallback: async () => ({ ...PROFILE }) }) + registry.validateConfig = () => ({ valid: true }) + userIdentities.findByProviderSubject = async () => ({ user_id: 7 }) + settings.getRegistrationMode = async () => 'disabled' + users.getById = async (id) => ({ id, username: 'alice', role: 'player' }) + users.recordLogin = async () => {} + mobileSessions.store = async () => 1 + bridge.getSession = async () => ({ ...SESSION }) + bridge.issueAuthCode = async () => ({ code: 'RAWCODE' }) + bridge.startSession = async () => ({ sessionId: 'sess-1' }) + bridge.findRedeemableCode = async () => ({ user_id: 7, session_id: 'sess-1' }) + bridge.consumeCode = async () => true + bridge.finishSession = async () => 1 +}) + +function mockRes() { + return { + statusCode: 200, redirectedTo: null, body: null, cookies: {}, cleared: [], + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, + redirect(u) { this.redirectedTo = u; return this }, + cookie(n, v) { this.cookies[n] = v; return this }, + clearCookie(n) { this.cleared.push(n); return this }, + } +} + +// ── /start ───────────────────────────────────────────────────────────────── + +test('start: an un-allowlisted redirect_uri is a hard 400 (no redirect, exact-match only)', async () => { + const res = mockRes() + await mobileSso.start( + { query: { provider: 'google', code_challenge: 'c', state: 's', redirect_uri: 'evil://auth/callback' }, ip: '1.2.3.4' }, + res, + ) + assert.equal(res.statusCode, 400) + assert.equal(res.redirectedTo, null) +}) + +test('start: a prefix of the allowlisted URI is rejected (not a prefix match)', async () => { + const res = mockRes() + await mobileSso.start( + { query: { provider: 'google', code_challenge: 'c', state: 's', redirect_uri: 'runicgateway://auth/callback.evil.com' }, ip: '1.2.3.4' }, + res, + ) + assert.equal(res.statusCode, 400) +}) + +test('start: seeds a bridge session and reuses the SSO redirect tagged mode:mobile', async () => { + let startArgs = null + let txData = null + bridge.startSession = async (a) => { startArgs = a; return { sessionId: 'sess-99' } } + const saved = ssoCtrl.redirectToIdp + ssoCtrl.redirectToIdp = async (req, res, provider, data) => { txData = { provider, ...data }; res.redirect('https://idp/authorize'); return true } + try { + const res = mockRes() + await mobileSso.start( + { query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: CALLBACK }, ip: '1.2.3.4' }, + res, + ) + assert.equal(res.redirectedTo, 'https://idp/authorize') + assert.equal(startArgs.redirectUri, CALLBACK) + assert.equal(startArgs.codeChallenge, 'chal') + assert.equal(txData.provider, 'google') + assert.equal(txData.mode, 'mobile') + assert.equal(txData.mobileSessionId, 'sess-99') + } finally { + ssoCtrl.redirectToIdp = saved + } +}) + +test('start: a disabled/unavailable provider surfaces the error to the app callback', async () => { + const saved = ssoCtrl.redirectToIdp + ssoCtrl.redirectToIdp = async () => false + try { + const res = mockRes() + await mobileSso.start( + { query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: CALLBACK }, ip: '1.2.3.4' }, + res, + ) + assert.equal(res.redirectedTo, `${CALLBACK}?error=provider_unavailable&state=st`) + } finally { + ssoCtrl.redirectToIdp = saved + } +}) + +// ── /exchange ──────────────────────────────────────────────────────────────── + +function exchangeReq(body) { + return { body, ip: '127.0.0.1', headers: { 'user-agent': 'Android' } } +} + +test('exchange: valid code + PKCE verifier → mobile bearer tokens (same shape as login)', async () => { + const res = mockRes() + await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res) + assert.equal(res.statusCode, 200) + assert.ok(res.body.accessToken, 'access token issued') + assert.ok(res.body.refreshToken, 'refresh token issued') + assert.equal(res.body.user.id, 7) + // The issued access token validates as a mobile session. + const s = require('../src/auth/session.service').validateBearerToken(res.body.accessToken) + assert.equal(s.authMethod, 'mobile') + assert.equal(logged.at(-1).action, 'auth.mobile.login') + assert.equal(logged.at(-1).detail.sso, 'google') +}) + +test('exchange: unknown/expired/used code → 401', async () => { + bridge.findRedeemableCode = async () => null + const res = mockRes() + await mobileSso.exchange(exchangeReq({ code: 'nope', code_verifier: 'verifier-xyz' }), res) + assert.equal(res.statusCode, 401) +}) + +test('exchange: wrong PKCE verifier → 401 and the code is NOT consumed', async () => { + let consumed = false + bridge.consumeCode = async () => { consumed = true; return true } + const res = mockRes() + await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'WRONG' }), res) + assert.equal(res.statusCode, 401) + assert.equal(consumed, false, 'a failed PKCE check must not burn a legitimate code') +}) + +test('exchange: a reused (already-consumed) code → 401 (single use)', async () => { + bridge.consumeCode = async () => false // lost the single-use race / replay + const res = mockRes() + await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res) + assert.equal(res.statusCode, 401) +}) + +test('exchange: vanished user → 401', async () => { + users.getById = async () => null + const res = mockRes() + await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res) + assert.equal(res.statusCode, 401) +}) + +// ── callback (mode:'mobile') via sso.controller ────────────────────────────── + +function callbackReq(tx) { + return { + params: { provider: 'google' }, + cookies: { [ssoState.TX_COOKIE]: tx.txToken }, + query: { state: tx.nonce, code: 'idp-code' }, + ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {}, + } +} + +test('callback (mobile): linked account → deep link with a one-time code + echoed state, NO cookie', async () => { + const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' }) + const res = mockRes() + await ssoCtrl.callback(callbackReq(tx), res) + assert.equal(res.redirectedTo, `${CALLBACK}?code=RAWCODE&state=st-abc`) + assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no web session cookie on a mobile flow') + assert.equal(logged.at(-1).action, 'auth.sso.login') + assert.equal(logged.at(-1).detail.mobile, true) +}) + +test('callback (mobile): unlinked account with registration closed → error deep link (link-only)', async () => { + userIdentities.findByProviderSubject = async () => null + const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' }) + const res = mockRes() + await ssoCtrl.callback(callbackReq(tx), res) + assert.equal(res.redirectedTo, `${CALLBACK}?error=not_linked&state=st-abc`) + assert.equal(logged.length, 0) +}) + +test('callback (mobile): a 2FA account is routed through the TOTP form, code NOT yet issued', async () => { + users.getById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1 }) + let issued = false + bridge.issueAuthCode = async () => { issued = true; return { code: 'RAWCODE' } } + const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' }) + const res = mockRes() + await ssoCtrl.callback(callbackReq(tx), res) + assert.equal(res.redirectedTo, '/account/login?sso_totp=1') + assert.ok(res.cookies[ssoState.TOTP_COOKIE], 'pending-TOTP cookie staged') + assert.equal(issued, false, 'no auth code before the second factor passes') + // The staged challenge carries the bridge session so completion can deep-link back. + const pending = ssoState.verifyTotpPending(res.cookies[ssoState.TOTP_COOKIE]) + assert.equal(pending.mobileSessionId, 'sess-1') +}) + +test('callback (mobile): an invalid/expired bridge session fails without leaking a redirect', async () => { + bridge.getSession = async () => null + const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'gone' }) + const res = mockRes() + await ssoCtrl.callback(callbackReq(tx), res) + assert.equal(res.statusCode, 400) + assert.equal(res.redirectedTo, null) +}) + +// ── finishSsoTotp (mode:'mobile') ──────────────────────────────────────────── + +function totpReq(pending, code) { + return { + cookies: pending ? { [ssoState.TOTP_COOKIE]: pending } : {}, + body: { code }, ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {}, + } +} + +test('finishSsoTotp (mobile): correct code → JSON { redirect } deep link, no cookie', async () => { + users.getRawById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1, totp_secret: 'S' }) + totp.verifyCode = () => true + const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', returnTo: '/account', mobileSessionId: 'sess-1' }) + const res = mockRes() + await ssoCtrl.finishSsoTotp(totpReq(pending, '123456'), res) + assert.equal(res.body.redirect, `${CALLBACK}?code=RAWCODE&state=st-abc`) + assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'mobile 2FA completion sets no web session cookie') + assert.ok(res.cleared.includes(ssoState.TOTP_COOKIE)) + assert.equal(logged.at(-1).detail.totp, true) +}) + +test('finishSsoTotp (mobile): expired bridge session → 401', async () => { + users.getRawById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1, totp_secret: 'S' }) + totp.verifyCode = () => true + bridge.getSession = async () => null + const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', mobileSessionId: 'gone' }) + const res = mockRes() + await ssoCtrl.finishSsoTotp(totpReq(pending, '123456'), res) + assert.equal(res.statusCode, 401) +})