Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).
Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
(create/validate/partial-TOTP/revoke), session.middleware.js
(attachSession/requireAuth/requireRole). utils/auth.js is now a thin
compat facade so existing imports are unchanged.
Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
long-lived refresh token, stored hashed and rotated on use, in a new
mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
TOTP. token.signToken gains a backward-compatible expiresIn option.
Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
identities are never auto-provisioned. Client secrets encrypted at rest
(AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
account linking (/admin/account/identities). New auth_providers +
user_identities tables.
Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
graceful with zero providers). New Authentication admin view
(Local/Google/Discord/Custom). Account page linked-accounts section.
Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
7.8 KiB
JavaScript
177 lines
7.8 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 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 }
|