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