Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
583 lines
28 KiB
JavaScript
583 lines
28 KiB
JavaScript
// ── SSO (OAuth2 / OIDC) controller ─────────────────────────────────────────
|
|
//
|
|
// Drives the redirect flow for built-in (Google, Discord) and custom providers:
|
|
// GET /auth/providers → public discovery (enabled + valid providers)
|
|
// GET /auth/sso/:provider/start → begin login (redirect to the IdP)
|
|
// GET /auth/sso/:provider/link → begin account linking (requireAuth)
|
|
// GET /auth/sso/:provider/callback → exchange code, then log in OR link
|
|
//
|
|
// LINK-ONLY policy: a login succeeds only if the external identity is already
|
|
// linked to an internal account. Unknown identities are refused, never
|
|
// auto-provisioned. Every successful login goes through sessionService, so the
|
|
// resulting session is identical to a local login (same cookie, logging, RBAC).
|
|
|
|
const users = require('../../../model/users/users.model')
|
|
const activity = require('../../../model/activity/activity.model')
|
|
const authProviders = require('../../../model/authProviders/authProviders.model')
|
|
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
|
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')
|
|
const usernamePolicy = require('../../../auth/usernamePolicy')
|
|
const botScore = require('../../../middleware/botScore')
|
|
const loginProtection = require('../../../middleware/loginProtection')
|
|
const { needsTotp } = require('./auth.controller')
|
|
const { establishTrust } = require('./trustDevice.helper')
|
|
|
|
const log = require('../../../utils/logger')('sso')
|
|
|
|
const PROVIDER_ID_RE = /^[a-z0-9-]+$/
|
|
// How many username suffixes to try before giving up on auto-provision.
|
|
const PROVISION_MAX_TRIES = 25
|
|
|
|
// Which front-end area a flow belongs to, derived from its returnTo. Players
|
|
// drive SSO from /account*, staff from /admin*; defaults to admin. This is what
|
|
// makes error/TOTP/success redirects land the caller back in their own portal.
|
|
function portalFor(returnTo) {
|
|
return typeof returnTo === 'string' && /^\/account(?:[/?]|$)/.test(returnTo) ? 'account' : 'admin'
|
|
}
|
|
const loginPath = (portal) => (portal === 'account' ? '/account/login' : '/admin/login')
|
|
const accountPath = (portal) => (portal === 'account' ? '/account' : '/admin/account')
|
|
const homePath = (portal) => (portal === 'account' ? '/account' : '/admin')
|
|
|
|
// Redirect targets (front-end routes). Errors surface as a query param the login
|
|
// / account pages can render. Portal-aware so a player flow stays in /account*.
|
|
const loginError = (code, portal = 'admin') => `${loginPath(portal)}?sso_error=${code}`
|
|
const accountError = (code, portal = 'admin') => `${accountPath(portal)}?link_error=${code}`
|
|
|
|
// Only allow returning to an internal /admin or /account path (prevents open
|
|
// redirect). Both areas are first-party SPA routes.
|
|
function sanitizeReturn(returnTo) {
|
|
if (
|
|
typeof returnTo === 'string' &&
|
|
/^\/(admin|account)(?:[/?]|$)/.test(returnTo) &&
|
|
!returnTo.startsWith('//')
|
|
) {
|
|
return returnTo
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Public base URL used to build the OAuth redirect_uri. Prefer APP_BASE_URL;
|
|
// fall back to the request's own origin with a warning if it is unset.
|
|
function appBaseUrl(req) {
|
|
const configured = process.env.APP_BASE_URL
|
|
if (configured) return configured.replace(/\/+$/, '')
|
|
const derived = `${req.protocol}://${req.get('host')}`
|
|
log.warn('APP_BASE_URL not set — deriving redirect_uri from the request', { derived })
|
|
return derived
|
|
}
|
|
function redirectUriFor(req, providerId) {
|
|
return `${appBaseUrl(req)}/api/v1/auth/sso/${providerId}/callback`
|
|
}
|
|
|
|
// httpOnly cookie carrying the signed tx (nonce + PKCE verifier + mode). Reuse the
|
|
// app's standard cookie options (httpOnly, sameSite=lax, secure=auto) + a TTL.
|
|
function txCookieOptions(req) {
|
|
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
|
|
}
|
|
|
|
// httpOnly cookie carrying the staged pending-TOTP token for the second-factor
|
|
// step. Same standard options; TTL matches the token so a stale cookie can't
|
|
// outlive the challenge it holds.
|
|
function totpCookieOptions(req) {
|
|
return { ...token.cookieOptions(req), maxAge: 5 * 60 * 1000 }
|
|
}
|
|
|
|
// GET /auth/providers — public discovery. Never touches secrets.
|
|
async function listProviders(req, res) {
|
|
try {
|
|
return res.json(await registry.listEnabledValid())
|
|
} catch (err) {
|
|
log.error('listProviders', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
const providerId = req.params.provider
|
|
const returnTo = sanitizeReturn(req.query.returnTo)
|
|
const portal = portalFor(returnTo)
|
|
const failUrl = mode === 'link' ? accountError('error', portal) : loginError('error', portal)
|
|
try {
|
|
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
|
|
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),
|
|
)
|
|
}
|
|
} catch (err) {
|
|
log.error('sso start', err)
|
|
return res.redirect(failUrl)
|
|
}
|
|
}
|
|
|
|
const start = (req, res) => beginFlow(req, res, 'login')
|
|
const linkStart = (req, res) => beginFlow(req, res, 'link')
|
|
|
|
// GET /auth/sso/:provider/callback
|
|
async function callback(req, res) {
|
|
const providerId = req.params.provider
|
|
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE]
|
|
const { code, state, error: oauthError } = req.query
|
|
// The tx cookie is single-use — clear it no matter the outcome.
|
|
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
|
|
|
|
if (oauthError) {
|
|
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) })
|
|
return res.redirect(loginError('denied'))
|
|
}
|
|
const tx = ssoState.verifyTx(txToken, state)
|
|
if (!tx || tx.provider !== providerId || !code) {
|
|
log.warn('sso callback: bad state', { provider: providerId })
|
|
return res.redirect(loginError('bad_state'))
|
|
}
|
|
|
|
// tx is verified — steer failures back to the portal (and page) the flow began in.
|
|
const portal = portalFor(tx.returnTo)
|
|
const failFor = (code) => (tx.mode === 'link' ? accountError(code, portal) : loginError(code, portal))
|
|
try {
|
|
const row = await authProviders.getWithSecret(providerId)
|
|
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
|
|
return res.redirect(failFor('unavailable'))
|
|
}
|
|
const provider = registry.instantiate(row)
|
|
const profile = await provider.handleCallback({
|
|
code,
|
|
redirectUri: redirectUriFor(req, providerId),
|
|
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)
|
|
return res.redirect(failFor('error'))
|
|
}
|
|
}
|
|
|
|
// Auto-provision a `player` from an SSO profile when no identity is linked yet
|
|
// and registration allows SSO sign-up. Derives a unique username (reserved-name
|
|
// safe) with a bounded retry against the UNIQUE index, captures the provider
|
|
// email, links the identity, and audit-logs the provision.
|
|
//
|
|
// Returns { user } on success, or { error } naming why it failed. It used to
|
|
// return the user or a bare null, which was enough while username was the only
|
|
// unique index; since Phase 1b there are two ways to fail and they need different
|
|
// things said to the person in front of the browser.
|
|
async function provisionSsoPlayer(req, providerId, profile) {
|
|
const base = usernamePolicy.deriveUsernameBase(profile)
|
|
for (let attempt = 0; attempt < PROVISION_MAX_TRIES; attempt++) {
|
|
const candidate = usernamePolicy.candidateUsername(base, attempt)
|
|
try {
|
|
const user = await users.createUser({
|
|
username: candidate,
|
|
role: 'player',
|
|
email: profile.email || null,
|
|
// Honour what the IdP actually ASSERTED, not the mere presence of an
|
|
// address. The old `Boolean(profile.email)` marked every SSO address
|
|
// verified, which made email_verified too weak a signal to mean anything
|
|
// (§0.6 finding 3). An IdP that omits the claim leaves the address
|
|
// unverified and the user proves it through the ordinary flow.
|
|
//
|
|
// Forward-only, by decision: existing rows keep the verified flag they
|
|
// were given. Retroactively demoting live users is the G22 mistake — a
|
|
// safe default applied backwards to a running system without telling
|
|
// anyone.
|
|
emailVerified: profile.emailVerified === true,
|
|
})
|
|
await userIdentities.link({
|
|
userId: user.id,
|
|
provider: providerId,
|
|
subject: profile.subject,
|
|
email: profile.email,
|
|
})
|
|
await activity.log({ req, userId: user.id, action: 'auth.sso.provision', detail: { provider: providerId } })
|
|
log.info('sso player provisioned', { provider: providerId, id: user.id, username: user.username })
|
|
return { user }
|
|
} catch (err) {
|
|
// An EMAIL collision can never be cleared by trying another username, so
|
|
// retrying is not merely useless — it burns every candidate and returns
|
|
// null, and the log then blames usernames for a conflict that was never
|
|
// about them (§0.6 finding 2). Stop, and say which it was.
|
|
//
|
|
// This is not the enumeration surface the register form is: the caller has
|
|
// already authenticated with the IdP, and the address is one the IdP
|
|
// asserted for them. Naming the real reason here is what makes the failure
|
|
// diagnosable instead of opaque.
|
|
if (users.isDuplicateEmail(err)) {
|
|
log.warn('sso provision: address already held by another account', {
|
|
provider: providerId,
|
|
subject: profile.subject,
|
|
})
|
|
return { error: 'email_in_use' }
|
|
}
|
|
// Username collided with a concurrent/existing account — try the next
|
|
// suffix. Any other error is real; propagate it.
|
|
if (users.isDuplicateUsername(err)) continue
|
|
throw err
|
|
}
|
|
}
|
|
log.error('sso provision: exhausted username candidates', { provider: providerId, base })
|
|
return { error: 'error' }
|
|
}
|
|
|
|
// Trusted-device skip for the SSO paths — the exact analogue of the check in
|
|
// auth.controller.login, and the reason SSO used to demand a code on every single
|
|
// sign-in even from a browser the user had explicitly trusted.
|
|
//
|
|
// The first factor here is the IdP authentication that just succeeded, so skipping
|
|
// the SECOND factor on a device the user deliberately trusted is the same posture
|
|
// as the password path. The trust must belong to THIS user (a trust token is
|
|
// scoped to the account that minted it), and any store hiccup falls through to the
|
|
// normal TOTP challenge — fail closed to asking for the code.
|
|
async function trustedDeviceSkips(req, user, providerId) {
|
|
try {
|
|
const device = await sessionService.resolveTrustedDevice(req)
|
|
if (!device || device.user_id !== user.id) return false
|
|
await sessionService.honorTrustedDevice(device.id)
|
|
await activity.log({ req, userId: user.id, action: 'auth.login.trusted_device', detail: { provider: providerId, sso: true } })
|
|
log.info('sso login via trusted device (TOTP skipped)', { provider: providerId, id: user.id, ip: req.ip })
|
|
return true
|
|
} catch (err) {
|
|
log.error('sso trusted-device check failed; falling back to TOTP', err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// SSO login. Normally link-only: a login succeeds only if the external identity
|
|
// is already linked. The one setting-gated relaxation is auto-provisioning a
|
|
// player when player_registration ∈ {sso, both} (see provisionSsoPlayer).
|
|
async function finishLogin(req, res, providerId, kind, tx, profile) {
|
|
const portal = portalFor(tx.returnTo)
|
|
let user
|
|
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
|
if (identity) {
|
|
user = await users.getById(identity.user_id)
|
|
if (!user) return res.redirect(loginError('not_linked', portal))
|
|
} else {
|
|
// Unknown identity: auto-provision only if registration opts into SSO sign-up.
|
|
const mode = await settings.getRegistrationMode()
|
|
if (mode !== 'sso' && mode !== 'both') {
|
|
log.warn('sso login refused: no linked account', { provider: providerId })
|
|
return res.redirect(loginError('not_linked', portal))
|
|
}
|
|
const provisioned = await provisionSsoPlayer(req, providerId, profile)
|
|
if (provisioned.error) return res.redirect(loginError(provisioned.error, portal))
|
|
user = provisioned.user
|
|
}
|
|
|
|
// Status gate (parity with local login): a disabled/banned account can't
|
|
// complete SSO login either.
|
|
if (user.status && user.status !== 'active') {
|
|
log.warn('sso login refused: inactive account', { provider: providerId, id: user.id, status: user.status })
|
|
return res.redirect(loginError('disabled', portal))
|
|
}
|
|
|
|
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
|
|
|
|
// 2FA parity with the local login (auth.controller): if the account has TOTP
|
|
// enabled, an SSO sign-in must NOT bypass the second factor — unless this browser
|
|
// is a trusted device, which skips the second factor exactly as it does for a
|
|
// password login. Otherwise stage a signed, httpOnly challenge and route the
|
|
// browser through the TOTP form instead of minting a session here. See issue #31.
|
|
if (needsTotp(user) && !(await trustedDeviceSkips(req, user, providerId))) {
|
|
const pending = ssoState.createTotpPending({
|
|
userId: user.id,
|
|
provider: providerId,
|
|
authMethod,
|
|
returnTo: sanitizeReturn(tx.returnTo) || undefined,
|
|
})
|
|
res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req))
|
|
log.info('sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip })
|
|
return res.redirect(`${loginPath(portal)}?sso_totp=1`)
|
|
}
|
|
|
|
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
|
token.setAuthCookie(req, res, sessionToken)
|
|
await users.recordLogin(user.id, req.ip)
|
|
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: providerId } })
|
|
log.info('sso login success', { provider: providerId, id: user.id, ip: req.ip })
|
|
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.
|
|
// Validate the mobile bridge session. Returns the session, or sends the failure
|
|
// response (redirect when we still have a session for its redirect_uri, else a
|
|
// generic 400) and returns null so the caller stops.
|
|
async function requireValidBridgeSession(res, tx, providerId) {
|
|
const sess = await mobileBridge.getSession(tx.mobileSessionId)
|
|
const invalid = !sess || sess.status !== 'pending' || new Date(sess.expires_at).getTime() <= Date.now()
|
|
if (!invalid) return sess
|
|
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) {
|
|
res.redirect(appError(sess, 'session_expired'))
|
|
return null
|
|
}
|
|
res
|
|
.status(400)
|
|
.json({ message: 'This sign-in session is invalid or has expired. Please try again from the app.' })
|
|
return null
|
|
}
|
|
|
|
// Resolve the linked user for a mobile SSO login (link-only, with opt-in
|
|
// provisioning when registration mode allows it). On refusal, sends the redirect
|
|
// and returns null.
|
|
async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
|
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
|
if (identity) {
|
|
const user = await users.getById(identity.user_id)
|
|
if (!user) {
|
|
res.redirect(appError(sess, 'not_linked'))
|
|
return null
|
|
}
|
|
return user
|
|
}
|
|
const mode = await settings.getRegistrationMode()
|
|
if (mode !== 'sso' && mode !== 'both') {
|
|
log.warn('mobile sso login refused: no linked account', { provider: providerId })
|
|
res.redirect(appError(sess, 'not_linked'))
|
|
return null
|
|
}
|
|
const provisioned = await provisionSsoPlayer(req, providerId, profile)
|
|
if (provisioned.error) {
|
|
res.redirect(appError(sess, provisioned.error))
|
|
return null
|
|
}
|
|
return provisioned.user
|
|
}
|
|
|
|
async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
|
const sess = await requireValidBridgeSession(res, tx, providerId)
|
|
if (!sess) return
|
|
|
|
const user = await resolveMobileSsoUser(req, res, sess, providerId, profile)
|
|
if (!user) return
|
|
|
|
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'
|
|
|
|
// Same second-factor gate as web, including the trusted-device skip. This request
|
|
// is the IdP redirect landing in the app's Custom Tab, which shares the system
|
|
// browser's cookie jar — so an rg_trust cookie set by a previous SSO sign-in from
|
|
// this app IS presented here, and the app gets the same "don't ask me again"
|
|
// behaviour as the website without having to inject a header into a tab it does
|
|
// not control. (Putting the token in the start URL instead would leak a secret
|
|
// into query strings and logs.)
|
|
if (needsTotp(user) && !(await trustedDeviceSkips(req, user, providerId))) {
|
|
// 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
|
|
// failed attempt (backoff + bot score), and the response is JSON (the login page
|
|
// completes this step over fetch and then navigates to returnTo).
|
|
async function finishSsoTotp(req, res) {
|
|
const pending = ssoState.verifyTotpPending(req.cookies && req.cookies[ssoState.TOTP_COOKIE])
|
|
if (!pending) {
|
|
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
|
|
}
|
|
try {
|
|
const user = await users.getRawById(pending.id)
|
|
if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, req.body.code)) {
|
|
botScore.recordLoginFailure(req.ip)
|
|
loginProtection.recordFailure(req.ip)
|
|
log.warn('sso TOTP verify failed', { id: pending.id, ip: req.ip })
|
|
return res.status(401).json({ message: 'Invalid verification code.' })
|
|
}
|
|
|
|
// Correct second factor, but the account is disabled/banned since the flow
|
|
// started — refuse and clear the staged cookie.
|
|
if (user.status && user.status !== 'active') {
|
|
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
|
|
log.warn('sso TOTP refused: inactive account', { id: user.id, status: user.status })
|
|
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
|
|
}
|
|
|
|
// Second factor satisfied — clear the staged cookie.
|
|
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
|
|
loginProtection.recordSuccess(req.ip)
|
|
|
|
// Optionally remember this browser, exactly as the password path does. On the
|
|
// mobile flow this browser IS the Custom Tab, so the cookie set here is what
|
|
// lets the NEXT app sign-in skip the code.
|
|
let trustLimit = null
|
|
if (req.body.trustDevice) {
|
|
const result = await establishTrust(req, user, {
|
|
platform: 'web',
|
|
deviceName: req.body.deviceName || null,
|
|
})
|
|
if (result.ok) token.setTrustCookie(req, res, result.trustToken)
|
|
else if (result.capReached) trustLimit = result.devices
|
|
}
|
|
|
|
// 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.' })
|
|
}
|
|
// Record the user's choice on the bridge session (a boolean — never the token)
|
|
// so /auth/mobile/sso/exchange can mint the APP's own trust token and hand it
|
|
// back over that authenticated app→server call. The token therefore never
|
|
// travels in the deep-link URL.
|
|
if (req.body.trustDevice && !trustLimit) {
|
|
await mobileBridge.markTrustRequested(sess.session_id)
|
|
}
|
|
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, ...(trustLimit ? { trustLimitReached: true, devices: trustLimit } : {}) })
|
|
}
|
|
|
|
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
|
|
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
|
token.setAuthCookie(req, res, sessionToken)
|
|
await users.recordLogin(user.id, req.ip)
|
|
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: pending.provider, totp: true } })
|
|
log.info('sso login success (2fa)', { provider: pending.provider, id: user.id, ip: req.ip })
|
|
return res.json({
|
|
user: { id: user.id, username: user.username, role: user.role },
|
|
returnTo: sanitizeReturn(pending.returnTo) || homePath(portalFor(pending.returnTo)),
|
|
...(trustLimit ? { trustLimitReached: true, devices: trustLimit } : {}),
|
|
})
|
|
} catch (err) {
|
|
log.error('sso totp error', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Attach the external identity to the account that initiated linking (tx.linkUserId
|
|
// was captured behind requireAuth at /link start, so the signed tx authorizes it).
|
|
async function finishLink(req, res, providerId, tx, profile) {
|
|
const portal = portalFor(tx.returnTo)
|
|
const userId = tx.linkUserId
|
|
if (!userId) return res.redirect(loginError('error', portal))
|
|
const existing = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
|
if (existing && existing.user_id !== userId) {
|
|
return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
|
|
}
|
|
if (!existing) {
|
|
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
|
|
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
|
log.info('sso account linked', { provider: providerId, userId })
|
|
}
|
|
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
|
}
|
|
|
|
module.exports = {
|
|
// Exported for tests only. The behaviour that matters is a COUNT — on an email
|
|
// conflict it must stop rather than work through every username candidate — and
|
|
// that is not observable through the route handlers without stubbing most of the
|
|
// OAuth flow to watch a loop it never reaches.
|
|
provisionSsoPlayer,
|
|
listProviders,
|
|
start,
|
|
linkStart,
|
|
callback,
|
|
beginFlow,
|
|
redirectToIdp,
|
|
finishLogin,
|
|
finishMobileLogin,
|
|
finishSsoTotp,
|
|
finishLink,
|
|
}
|