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

131
server/src/auth/token.js Normal file
View File

@@ -0,0 +1,131 @@
// ── Low-level auth token primitives ───────────────────────────────────────
//
// JWT signing/verification, the staged TOTP challenge token, request token
// extraction, and cookie helpers. This module is intentionally the *bottom* of
// the auth stack: it depends only on jsonwebtoken + the logger, and knows
// nothing about sessions, providers, or the database. The session service and
// middleware build on top of it, and utils/auth.js re-exports it for backward
// compatibility. Keeping these primitives here (rather than in the session
// service) avoids a require cycle: session.service → token, never the reverse.
const jwt = require('jsonwebtoken')
require('dotenv').config()
const log = require('../utils/logger')('auth')
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
// Lifetime of the short-lived "password verified, awaiting TOTP" token.
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'
// Resolve the signing secret. Without one, jwt.sign/verify can't produce or
// validate a usable token, so every login is silently broken. Fail fast in
// production rather than booting into that state; in dev fall back to a known
// insecure secret so login still works locally (with a loud warning).
function resolveJwtSecret() {
const secret = process.env.JWT_SECRET
if (secret) return secret
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET must be set in production')
}
log.warn('JWT_SECRET is not set — using an insecure development fallback. Set JWT_SECRET in .env before deploying.')
return 'dev-insecure-jwt-secret-do-not-use-in-production'
}
const JWT_SECRET = resolveJwtSecret()
// Sign a session token. `extraClaims` lets the session service add fields
// (authMethod, jti) on top of the identity claims without this module needing
// to know what they mean. Extra claims are additive: an older verifier that
// only reads { id, username, role } ignores them, so tokens stay compatible.
// `options.expiresIn` overrides the default lifetime (used by short-lived mobile
// access tokens); omitting it keeps the historical JWT_EXPIRES_IN behavior.
function signToken(user, extraClaims = {}, { expiresIn = JWT_EXPIRES_IN } = {}) {
const payload = { id: user.id, username: user.username, role: user.role, ...extraClaims }
return jwt.sign(payload, JWT_SECRET, { expiresIn })
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch (err) {
return null
}
}
// Short-lived token issued after the password step for users with TOTP enabled.
// It is NOT a session: it carries stage:'totp' so session validation rejects it,
// and it is only accepted by verifyTotpChallenge to gate the second factor.
function signTotpChallenge(user) {
return jwt.sign({ id: user.id, stage: 'totp' }, JWT_SECRET, { expiresIn: TOTP_CHALLENGE_TTL })
}
function verifyTotpChallenge(token) {
const decoded = verifyToken(token)
if (!decoded || decoded.stage !== 'totp') return null
return decoded
}
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
function cookieMaxAge() {
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
if (!m) return 24 * 60 * 60 * 1000
const n = Number(m[1])
const unit = { d: 86400000, h: 3600000, m: 60000, s: 1000 }[m[2]]
return n * unit
}
/**
* Decide the cookie Secure flag. COOKIE_SECURE=auto (default) uses req.secure,
* which is true behind Pangolin (HTTPS, X-Forwarded-Proto) and false over plain
* HTTP on the LAN IP — so login works in both. Requires app.set('trust proxy').
*/
function cookieSecure(req) {
const mode = (process.env.COOKIE_SECURE || 'auto').toLowerCase()
if (mode === 'true') return true
if (mode === 'false') return false
return Boolean(req.secure)
}
function cookieOptions(req) {
return {
httpOnly: true,
sameSite: 'lax',
secure: cookieSecure(req),
path: '/',
}
}
function setAuthCookie(req, res, token) {
res.cookie(COOKIE_NAME, token, { ...cookieOptions(req), maxAge: cookieMaxAge() })
}
function clearAuthCookie(req, res) {
res.clearCookie(COOKIE_NAME, cookieOptions(req))
}
// Extract a token from the cookie or an Authorization: Bearer header. Supporting
// both here is what lets future bearer-token (mobile) clients reuse the exact
// same validation path as cookie-based web sessions.
function extractToken(req) {
if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME]
const header = req.headers && req.headers.authorization
if (header && header.startsWith('Bearer ')) return header.substring(7)
return null
}
module.exports = {
COOKIE_NAME,
JWT_EXPIRES_IN,
resolveJwtSecret,
signToken,
verifyToken,
signTotpChallenge,
verifyTotpChallenge,
cookieMaxAge,
cookieSecure,
cookieOptions,
setAuthCookie,
clearAuthCookie,
extractToken,
}