// ── 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 registry = require('../../../auth/providers/registry') const sessionService = require('../../../auth/session.service') const ssoState = require('../../../auth/ssoState') const token = require('../../../auth/token') const log = require('../../../utils/logger')('sso') const PROVIDER_ID_RE = /^[a-z0-9-]+$/ // Redirect targets (front-end routes). Errors surface as a query param the login // / account pages can render. const loginError = (code) => `/admin/login?sso_error=${code}` const accountError = (code) => `/admin/account?link_error=${code}` // Only allow returning to an internal /admin path (prevents open redirect). function sanitizeReturn(returnTo) { if (typeof returnTo === 'string' && /^\/admin(?:[/?]|$)/.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 } } // 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 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 failUrl = mode === 'link' ? accountError('error') : loginError('error') 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) { log.warn('sso start: provider unavailable', { provider: providerId, mode }) return res.redirect(mode === 'link' ? accountError('unavailable') : loginError('unavailable')) } const provider = registry.instantiate(row) const tx = ssoState.createTx({ provider: providerId, mode, linkUserId: mode === 'link' ? req.user.id : undefined, returnTo: sanitizeReturn(req.query.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) } } 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')) } try { const row = await authProviders.getWithSecret(providerId) if (!row || !row.enabled || !registry.validateConfig(row).valid) { return res.redirect(loginError('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) return finishLogin(req, res, providerId, row.kind, tx, profile) } catch (err) { log.error('sso callback', err) return res.redirect(loginError('error')) } } // Link-only login: require an existing (provider, subject) identity → session. async function finishLogin(req, res, providerId, kind, tx, profile) { const identity = await userIdentities.findByProviderSubject(providerId, profile.subject) if (!identity) { log.warn('sso login refused: no linked account', { provider: providerId }) return res.redirect(loginError('not_linked')) } const user = await users.getById(identity.user_id) if (!user) return res.redirect(loginError('not_linked')) const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso' const { token: sessionToken } = sessionService.createSession(user, authMethod) token.setAuthCookie(req, res, sessionToken) await users.recordLogin(user.id) 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) || '/admin') } // 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 userId = tx.linkUserId if (!userId) return res.redirect(loginError('error')) const existing = await userIdentities.findByProviderSubject(providerId, profile.subject) if (existing && existing.user_id !== userId) { return res.redirect(accountError('in_use')) // that 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(`/admin/account?linked=${providerId}`) } module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishLink }