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:
@@ -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.
|
||||
|
||||
151
server/src/router/v1/auth/mobileSso.controller.js
Normal file
151
server/src/router/v1/auth/mobileSso.controller.js
Normal file
@@ -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 }
|
||||
55
server/src/router/v1/auth/mobileSso.routes.js
Normal file
55
server/src/router/v1/auth/mobileSso.routes.js
Normal file
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user