Add session abstraction, mobile bearer auth, and pluggable SSO

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>
This commit is contained in:
2026-07-03 10:31:29 -05:00
parent 8fa34ca68e
commit 31b31c3a17
46 changed files with 3169 additions and 177 deletions

View File

@@ -1,12 +1,7 @@
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const {
signToken,
setAuthCookie,
clearAuthCookie,
signTotpChallenge,
verifyTotpChallenge,
} = require('../../../utils/auth')
const { setAuthCookie, clearAuthCookie } = require('../../../auth/token')
const sessionService = require('../../../auth/session.service')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
@@ -26,15 +21,17 @@ function needsTotp(user) {
return Boolean(user && user.totp_enabled)
}
// Issue the real session: sign the JWT, set the cookie, clear the IP's failure
// backoff, and record the login.
async function issueSession(req, res, user) {
// Issue the real session: create the session token via the session service, set
// the cookie, clear the IP's failure backoff, and record the login. authMethod
// records how this session was authenticated ('local' password, or 'totp' after
// the second factor) — carried in the session token for downstream visibility.
async function issueSession(req, res, user, authMethod = 'local') {
loginProtection.recordSuccess(req.ip)
await users.recordLogin(user.id)
const token = signToken(user)
const { token } = sessionService.createSession(user, authMethod)
setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' })
log.info('login success', { username: user.username, id: user.id, ip: req.ip })
log.info('login success', { username: user.username, id: user.id, ip: req.ip, authMethod })
return res.json({ user: { id: user.id, username: user.username, role: user.role } })
}
@@ -64,12 +61,12 @@ async function login(req, res) {
// hand back a short-lived, signed "password verified" challenge and require
// the code. If TOTP is off, log them straight in.
if (needsTotp(user)) {
const challenge = signTotpChallenge(user)
const challenge = sessionService.createPartialSession(user)
log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip })
return res.json({ totpRequired: true, challenge })
}
return issueSession(req, res, user)
return issueSession(req, res, user, 'local')
} catch (err) {
log.error('login error', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -80,7 +77,7 @@ async function login(req, res) {
// session. A wrong code counts as a failed attempt (backoff + bot score).
async function loginTotp(req, res) {
const { challenge, code } = req.body
const decoded = verifyTotpChallenge(challenge)
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
if (!decoded) {
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
}
@@ -92,7 +89,7 @@ async function loginTotp(req, res) {
log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip })
return res.status(401).json({ message: 'Invalid verification code.' })
}
return issueSession(req, res, user)
return issueSession(req, res, user, 'totp')
} catch (err) {
log.error('loginTotp error', err)
return res.status(500).json({ message: 'Internal Server Error' })

View File

@@ -6,9 +6,18 @@ const { isLoggedIn } = require('../../../utils/auth')
const { loginLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate')
const mobileRouter = require('./mobile.routes')
const ssoRouter = require('./sso.routes')
const authRouter = express.Router()
// Native/Android bearer-token auth. Additive alongside the web cookie flow below.
authRouter.use('/mobile', mobileRouter)
// SSO discovery + OAuth redirect flow (/auth/providers, /auth/sso/:provider/*).
// Additive; the web cookie + TOTP flow below is unchanged.
authRouter.use(ssoRouter)
// Login protection order (cheapest rejection first):
// backoffGuard → per-IP exponential lockout on repeated failures
// slowLogin → progressive per-request delay within the window

View File

@@ -0,0 +1,143 @@
// ── Mobile (Android) bearer-token auth ─────────────────────────────────────
//
// Purely additive alongside the web cookie flow. Native clients POST credentials
// here and receive a short-lived access token (a normal session JWT, validated
// on every route by the shared requireAuth middleware) plus a long-lived,
// server-stored, revocable refresh token. This controller reuses the exact same
// brute-force defenses as web login (bot scoring + login backoff), and handles
// TOTP in a single stateless request: if 2FA is on and no/invalid code is given,
// it replies { totpRequired: true } and the app retries with the code.
//
// It does NOT touch the web login/loginTotp handlers or the TOTP staged-challenge
// flow — those are unchanged.
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
const sessionService = require('../../../auth/session.service')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
const log = require('../../../utils/logger')('auth-mobile')
// Same generic failure text as web — never reveals which credential was wrong.
const GENERIC_FAIL = { message: 'Incorrect username or password.' }
// Shape returned to the client on a successful login/refresh. Access + refresh
// tokens, the access lifetime, and the safe (secret-stripped) user.
function tokenResponse(out, user) {
return {
accessToken: out.accessToken,
refreshToken: out.refreshToken,
expiresIn: out.expiresIn,
user: { id: user.id, username: user.username, role: user.role },
}
}
// Persist a freshly minted refresh token (by hash) and record the login. Shared
// by login and refresh so the storage/side-effect logic lives in one place.
async function persistAndFinish(req, user, out, action) {
await mobileSessions.store({
userId: user.id,
tokenHash: out.refreshHash,
deviceHash: out.deviceHash,
userAgent: out.userAgent,
expiresAt: out.refreshExpiresAt,
})
await users.recordLogin(user.id)
await activity.log({ req, userId: user.id, action })
}
// POST /auth/mobile/login { username, password, code? }
async function login(req, res) {
const { username, password, code } = req.body
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
if (!ok) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('mobile login failed', { username, ip: req.ip })
return res.status(401).json(GENERIC_FAIL)
}
// Second factor, single-request style: if 2FA is enabled, a valid code must
// accompany this request. Missing or wrong → tell the app to prompt + retry.
// A wrong code is a real failed attempt (scored + backed off like web).
if (user.totp_enabled) {
if (!code || !totp.verifyCode(user.totp_secret, code)) {
if (code) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('mobile TOTP verify failed', { id: user.id, ip: req.ip })
}
return res.status(401).json({ totpRequired: true, message: 'A verification code is required.' })
}
}
loginProtection.recordSuccess(req.ip)
const meta = sessionService.sessionMeta(req)
const out = sessionService.createMobileSession(user, meta)
await persistAndFinish(req, user, out, 'auth.mobile.login')
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
return res.json(tokenResponse(out, user))
} catch (err) {
log.error('mobile login error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/mobile/refresh { refreshToken }
// Validates the presented refresh token, rotates it (revoke old + issue new),
// and returns a fresh access + refresh pair. Rotation means a stolen-and-used
// refresh token is single-use: the legitimate client's next refresh fails and
// surfaces the compromise.
async function refresh(req, res) {
const { refreshToken } = req.body
try {
const hash = sessionService.hashRefreshToken(refreshToken)
const row = await mobileSessions.findValidByHash(hash)
if (!row) {
log.warn('mobile refresh rejected (unknown/expired/revoked)', { ip: req.ip })
return res.status(401).json({ message: 'Invalid or expired session. Please sign in again.' })
}
const user = await users.getById(row.user_id) // fresh row; 401 if user gone
if (!user) {
await mobileSessions.revokeByHash(hash)
return res.status(401).json({ message: 'Invalid or expired session. Please sign in again.' })
}
await mobileSessions.revokeByHash(hash) // rotate: old token is now dead
const meta = sessionService.sessionMeta(req)
const out = sessionService.refreshMobileSession(user, meta)
await persistAndFinish(req, user, out, 'auth.mobile.refresh')
log.info('mobile session refreshed', { id: user.id, ip: req.ip })
return res.json(tokenResponse(out, user))
} catch (err) {
log.error('mobile refresh error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/mobile/logout { refreshToken?, all? }
// Runs behind requireAuth (bearer), so req.user is the caller. Revokes the given
// refresh token, or every token for the user when { all: true }. Idempotent.
async function logout(req, res) {
const { refreshToken, all } = req.body
try {
if (all) {
const n = await mobileSessions.revokeAllForUser(req.user.id)
log.info('mobile logout (all devices)', { id: req.user.id, revoked: n })
} else if (refreshToken) {
await mobileSessions.revokeByHash(sessionService.hashRefreshToken(refreshToken))
}
await activity.log({ req, userId: req.user.id, action: 'auth.mobile.logout' })
return res.json({ message: 'Logged out.' })
} catch (err) {
log.error('mobile logout error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { login, refresh, logout }

View File

@@ -0,0 +1,48 @@
const express = require('express')
const { body } = require('express-validator')
const { login, refresh, logout } = require('./mobile.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const { loginLimiter, mobileRefreshLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate')
const mobileRouter = express.Router()
// 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.
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
// POST /auth/mobile/login — { username, password, code? }
mobileRouter.post(
'/login',
...loginGuards,
body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(),
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
validate,
login,
)
// POST /auth/mobile/refresh — { refreshToken }
mobileRouter.post(
'/refresh',
mobileRefreshLimiter,
body('refreshToken').isString().notEmpty(),
validate,
refresh,
)
// POST /auth/mobile/logout — { refreshToken?, all? } — requires a valid bearer.
mobileRouter.post(
'/logout',
requireAuth,
body('refreshToken').optional().isString(),
body('all').optional().isBoolean(),
validate,
logout,
)
module.exports = mobileRouter

View File

@@ -0,0 +1,176 @@
// ── 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 }

View File

@@ -0,0 +1,22 @@
const express = require('express')
const ctrl = require('./sso.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const { ssoStartLimiter } = require('../../../middleware/rateLimit')
const ssoRouter = express.Router()
// Public discovery — the login page reads this to render provider buttons.
ssoRouter.get('/providers', ctrl.listProviders)
// Begin login (public) — redirects to the IdP.
ssoRouter.get('/sso/:provider/start', ssoStartLimiter, ctrl.start)
// Begin account linking (must be signed in — the tx captures the acting user).
ssoRouter.get('/sso/:provider/link', requireAuth, ctrl.linkStart)
// OAuth redirect target — completes login or linking. Not behind requireAuth:
// the signed tx cookie authorizes link mode; login mode is link-only anyway.
ssoRouter.get('/sso/:provider/callback', ctrl.callback)
module.exports = ssoRouter