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:
65
server/src/auth/providers/base.provider.js
Normal file
65
server/src/auth/providers/base.provider.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ── Auth provider contract (base) ──────────────────────────────────────────
|
||||
//
|
||||
// The abstract interface every auth provider implements. Concrete providers:
|
||||
// - local → username/password (LocalProvider, unchanged live flow)
|
||||
// - google, discord, generic OIDC → OAuth2Provider subclasses
|
||||
//
|
||||
// A provider config (a row from auth_providers, or a built-in default) looks like:
|
||||
// { id, kind, name, enabled, clientId, clientSecret,
|
||||
// authorizeUrl, tokenUrl, userinfoUrl, scopes, priority }
|
||||
//
|
||||
// Interface (per the Part 3 spec). OAuth providers implement the SSO-flow methods;
|
||||
// LocalProvider implements authenticate(). Anything not applicable stays a throw.
|
||||
|
||||
class BaseProvider {
|
||||
constructor(config = {}) {
|
||||
this.config = config
|
||||
this.id = config.id || config.kind || 'base'
|
||||
this.name = config.name || this.id
|
||||
this.kind = config.kind || 'base'
|
||||
this.type = this.kind // legacy alias
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return Boolean(this.config.enabled)
|
||||
}
|
||||
|
||||
// Direct-credential auth (local providers). Resolve to an internal user or null.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async authenticate(credentials) {
|
||||
throw new Error(`authenticate() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Begin an SSO redirect flow: the provider's authorization URL.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
getAuthorizationUrl(state, options) {
|
||||
throw new Error(`getAuthorizationUrl() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Complete an SSO redirect flow: exchange the callback code for a normalized
|
||||
// user profile ({ subject, email, name }).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async handleCallback(params) {
|
||||
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Fetch the raw external profile using an access token.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async getUserProfile(accessToken) {
|
||||
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Normalize a raw external profile to { subject, email, name }.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
mapUser(profile) {
|
||||
throw new Error(`mapUser() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Link an external identity to an internal user (shared by OAuth2Provider).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async linkAccount(user, profile) {
|
||||
throw new Error(`linkAccount() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseProvider
|
||||
30
server/src/auth/providers/discord.provider.js
Normal file
30
server/src/auth/providers/discord.provider.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Built-in Discord provider (OAuth2). Endpoints hardcoded — admins configure only
|
||||
// Enabled + Client ID + Client Secret. `identify` yields the stable user id;
|
||||
// `email` yields the address. Discord's id is the stable per-user subject.
|
||||
|
||||
const OAuth2Provider = require('./oauth2.provider')
|
||||
|
||||
class DiscordProvider extends OAuth2Provider {
|
||||
constructor(config = {}) {
|
||||
super({ kind: 'discord', name: 'Discord', ...config, id: config.id || 'discord' })
|
||||
}
|
||||
|
||||
authEndpoint() {
|
||||
return 'https://discord.com/oauth2/authorize'
|
||||
}
|
||||
tokenEndpoint() {
|
||||
return 'https://discord.com/api/oauth2/token'
|
||||
}
|
||||
userinfoEndpoint() {
|
||||
return 'https://discord.com/api/users/@me'
|
||||
}
|
||||
scopeString() {
|
||||
return 'identify email'
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
// global_name is the new display name; fall back to the legacy username.
|
||||
return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DiscordProvider
|
||||
38
server/src/auth/providers/genericOidc.provider.js
Normal file
38
server/src/auth/providers/genericOidc.provider.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Generic, fully-configurable OAuth2 / OIDC provider for custom IdPs (Authentik,
|
||||
// Keycloak, Okta, Azure AD, Zitadel, …). Unlike the built-ins, its endpoints and
|
||||
// scopes come from the stored config. Profile mapping follows OIDC conventions
|
||||
// with sensible fallbacks for plain OAuth2 userinfo shapes.
|
||||
|
||||
const OAuth2Provider = require('./oauth2.provider')
|
||||
|
||||
class GenericOidcProvider extends OAuth2Provider {
|
||||
constructor(config = {}) {
|
||||
super({ kind: config.kind || 'oidc', ...config })
|
||||
this.authorizeUrl = config.authorizeUrl ?? config.authorize_url ?? null
|
||||
this.tokenUrl = config.tokenUrl ?? config.token_url ?? null
|
||||
this.userinfoUrl = config.userinfoUrl ?? config.userinfo_url ?? null
|
||||
this.scopes = config.scopes || 'openid email profile'
|
||||
}
|
||||
|
||||
authEndpoint() {
|
||||
return this.authorizeUrl
|
||||
}
|
||||
tokenEndpoint() {
|
||||
return this.tokenUrl
|
||||
}
|
||||
userinfoEndpoint() {
|
||||
return this.userinfoUrl
|
||||
}
|
||||
scopeString() {
|
||||
return this.scopes
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
return {
|
||||
subject: p.sub || p.id || p.user_id || p.uid || null,
|
||||
email: p.email || null,
|
||||
name: p.name || p.preferred_username || p.username || p.email || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GenericOidcProvider
|
||||
34
server/src/auth/providers/google.provider.js
Normal file
34
server/src/auth/providers/google.provider.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Built-in Google provider (OAuth2 / OpenID Connect). Endpoints are hardcoded —
|
||||
// admins configure only Enabled + Client ID + Client Secret. Uses the OIDC
|
||||
// userinfo endpoint; `sub` is Google's stable per-user id.
|
||||
|
||||
const OAuth2Provider = require('./oauth2.provider')
|
||||
|
||||
class GoogleProvider extends OAuth2Provider {
|
||||
constructor(config = {}) {
|
||||
super({ kind: 'google', name: 'Google', ...config, id: config.id || 'google' })
|
||||
}
|
||||
|
||||
authEndpoint() {
|
||||
return 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
}
|
||||
tokenEndpoint() {
|
||||
return 'https://oauth2.googleapis.com/token'
|
||||
}
|
||||
userinfoEndpoint() {
|
||||
return 'https://openidconnect.googleapis.com/v1/userinfo'
|
||||
}
|
||||
scopeString() {
|
||||
return 'openid email profile'
|
||||
}
|
||||
authParams() {
|
||||
// Online access (no refresh token needed for login), and let the user pick
|
||||
// an account rather than silently reusing a signed-in one.
|
||||
return { access_type: 'online', prompt: 'select_account' }
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
return { subject: p.sub, email: p.email || null, name: p.name || p.email || null }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GoogleProvider
|
||||
27
server/src/auth/providers/local.provider.js
Normal file
27
server/src/auth/providers/local.provider.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// ── Local (username/password) provider ─────────────────────────────────────
|
||||
//
|
||||
// Reference implementation of the BaseProvider contract for local credential
|
||||
// auth. It delegates to the existing users model, mirroring what auth.controller
|
||||
// does today — but it is NOT wired into the live login flow. The controller
|
||||
// keeps its own login logic (honeypot, bot scoring, TOTP staging, backoff) so
|
||||
// this refactor changes no behavior. This exists so Part 3 can treat "local" as
|
||||
// just another provider alongside SSO, behind one uniform interface.
|
||||
|
||||
const BaseProvider = require('./base.provider')
|
||||
const users = require('../../model/users/users.model')
|
||||
|
||||
class LocalProvider extends BaseProvider {
|
||||
constructor(config = {}) {
|
||||
super({ name: 'local', type: 'local', enabled: true, ...config })
|
||||
}
|
||||
|
||||
// Verify username + password. Returns the raw user row on success, else null.
|
||||
// Callers layer their own throttling/scoring on top (as auth.controller does).
|
||||
async authenticate({ username, password } = {}) {
|
||||
const user = await users.getRawByUsername(username)
|
||||
const ok = user && (await users.validatePassword(user, password))
|
||||
return ok ? user : null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LocalProvider
|
||||
115
server/src/auth/providers/oauth2.provider.js
Normal file
115
server/src/auth/providers/oauth2.provider.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ── Shared OAuth2 / OIDC provider ──────────────────────────────────────────
|
||||
//
|
||||
// Implements the reusable authorization-code + PKCE flow so the concrete
|
||||
// providers (google, discord, generic OIDC) only supply their endpoints, scope,
|
||||
// and a normalizeProfile(). Uses Node's global fetch (no new dependency).
|
||||
//
|
||||
// Flow:
|
||||
// getAuthorizationUrl(state, { redirectUri, codeChallenge }) → redirect the browser
|
||||
// handleCallback({ code, redirectUri, codeVerifier })
|
||||
// → exchangeCode (POST token endpoint) → getUserProfile (GET userinfo)
|
||||
// → mapUser → { subject, email, name }
|
||||
|
||||
const BaseProvider = require('./base.provider')
|
||||
const userIdentities = require('../../model/userIdentities/userIdentities.model')
|
||||
const log = require('../../utils/logger')('sso')
|
||||
|
||||
class OAuth2Provider extends BaseProvider {
|
||||
constructor(config = {}) {
|
||||
super(config)
|
||||
this.clientId = config.clientId ?? config.client_id ?? null
|
||||
this.clientSecret = config.clientSecret ?? config.client_secret ?? null
|
||||
}
|
||||
|
||||
// ── Subclass hooks (endpoints / scope / profile mapping) ──────────────────
|
||||
authEndpoint() {
|
||||
throw new Error(`authEndpoint() not set for provider '${this.id}'`)
|
||||
}
|
||||
tokenEndpoint() {
|
||||
throw new Error(`tokenEndpoint() not set for provider '${this.id}'`)
|
||||
}
|
||||
userinfoEndpoint() {
|
||||
throw new Error(`userinfoEndpoint() not set for provider '${this.id}'`)
|
||||
}
|
||||
scopeString() {
|
||||
return 'openid email profile'
|
||||
}
|
||||
// Extra provider-specific authorize-URL params (e.g. Google's prompt).
|
||||
authParams() {
|
||||
return {}
|
||||
}
|
||||
// Map a raw profile → { subject, email, name }. Subclasses must implement.
|
||||
normalizeProfile(profile) {
|
||||
throw new Error(`normalizeProfile() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// ── Flow ──────────────────────────────────────────────────────────────────
|
||||
getAuthorizationUrl(state, { redirectUri, codeChallenge } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId || '',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: this.scopeString(),
|
||||
state,
|
||||
})
|
||||
if (codeChallenge) {
|
||||
params.set('code_challenge', codeChallenge)
|
||||
params.set('code_challenge_method', 'S256')
|
||||
}
|
||||
for (const [k, v] of Object.entries(this.authParams())) params.set(k, v)
|
||||
return `${this.authEndpoint()}?${params.toString()}`
|
||||
}
|
||||
|
||||
async handleCallback({ code, redirectUri, codeVerifier } = {}) {
|
||||
const tokenSet = await this.exchangeCode({ code, redirectUri, codeVerifier })
|
||||
const profile = await this.getUserProfile(tokenSet.access_token)
|
||||
return this.mapUser(profile)
|
||||
}
|
||||
|
||||
async exchangeCode({ code, redirectUri, codeVerifier }) {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: this.clientId || '',
|
||||
client_secret: this.clientSecret || '',
|
||||
})
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
||||
const res = await fetch(this.tokenEndpoint(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
log.warn('token exchange failed', { provider: this.id, status: res.status })
|
||||
throw new Error(`token exchange failed (${res.status}): ${detail.slice(0, 200)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async getUserProfile(accessToken) {
|
||||
const res = await fetch(this.userinfoEndpoint(), {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
log.warn('userinfo fetch failed', { provider: this.id, status: res.status })
|
||||
throw new Error(`userinfo failed (${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
mapUser(profile) {
|
||||
const mapped = this.normalizeProfile(profile)
|
||||
if (!mapped || !mapped.subject) throw new Error(`provider '${this.id}' returned no subject`)
|
||||
return mapped
|
||||
}
|
||||
|
||||
// Persist the external → internal user link. Shared by every OAuth provider.
|
||||
async linkAccount(user, profile) {
|
||||
const p = this.mapUser(profile)
|
||||
return userIdentities.link({ userId: user.id, provider: this.id, subject: p.subject, email: p.email })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth2Provider
|
||||
128
server/src/auth/providers/registry.js
Normal file
128
server/src/auth/providers/registry.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// ── Provider registry ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Turns stored auth_providers rows into live provider instances, and owns the
|
||||
// "which providers are usable" health logic. The authentication layer talks to
|
||||
// the registry, never to a specific provider class, so adding a provider is just
|
||||
// a new entry in KINDS.
|
||||
//
|
||||
// Built-ins (google, discord) always "exist" as defaults even before an admin
|
||||
// creates a row, so the admin UI can render their config form. A provider is only
|
||||
// shown to end users (login page) when it is enabled AND its config validates.
|
||||
|
||||
const GoogleProvider = require('./google.provider')
|
||||
const DiscordProvider = require('./discord.provider')
|
||||
const GenericOidcProvider = require('./genericOidc.provider')
|
||||
const authProviders = require('../../model/authProviders/authProviders.model')
|
||||
|
||||
// kind → provider class.
|
||||
const KINDS = {
|
||||
google: GoogleProvider,
|
||||
discord: DiscordProvider,
|
||||
oidc: GenericOidcProvider,
|
||||
oauth2: GenericOidcProvider,
|
||||
}
|
||||
|
||||
// Built-in providers and their fixed display metadata. Endpoints are in the
|
||||
// provider classes; only enabled/clientId/secret are admin-configurable.
|
||||
const BUILTINS = [
|
||||
{ id: 'google', kind: 'google', name: 'Google', priority: 1 },
|
||||
{ id: 'discord', kind: 'discord', name: 'Discord', priority: 2 },
|
||||
]
|
||||
|
||||
const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id))
|
||||
|
||||
function isBuiltin(id) {
|
||||
return BUILTIN_IDS.has(id)
|
||||
}
|
||||
|
||||
// Instantiate a provider from a config row (secret already decrypted by the
|
||||
// model as `client_secret`). Returns null for an unknown kind.
|
||||
function instantiate(row) {
|
||||
const Klass = KINDS[row.kind]
|
||||
if (!Klass) return null
|
||||
return new Klass({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
clientId: row.client_id,
|
||||
clientSecret: row.client_secret, // present only via getWithSecret
|
||||
authorizeUrl: row.authorize_url,
|
||||
tokenUrl: row.token_url,
|
||||
userinfoUrl: row.userinfo_url,
|
||||
scopes: row.scopes,
|
||||
priority: row.priority,
|
||||
})
|
||||
}
|
||||
|
||||
// Load a ready-to-use provider instance (secret decrypted) by id, or null.
|
||||
async function load(id) {
|
||||
const row = await authProviders.getWithSecret(id)
|
||||
if (!row) return null
|
||||
return instantiate(row)
|
||||
}
|
||||
|
||||
// Validate a config row's completeness. Built-ins need client_id + a secret;
|
||||
// custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }.
|
||||
function validateConfig(row) {
|
||||
const missing = []
|
||||
if (!row.client_id) missing.push('client_id')
|
||||
// A stored secret shows up as client_secret_enc on plain rows, or client_secret
|
||||
// on decrypted rows — accept either as "has a secret".
|
||||
if (!row.client_secret_enc && !row.client_secret) missing.push('client_secret')
|
||||
if (row.kind === 'oidc' || row.kind === 'oauth2') {
|
||||
if (!row.authorize_url) missing.push('authorize_url')
|
||||
if (!row.token_url) missing.push('token_url')
|
||||
if (!row.userinfo_url) missing.push('userinfo_url')
|
||||
}
|
||||
return { valid: missing.length === 0, missing }
|
||||
}
|
||||
|
||||
// All configured rows merged with built-in defaults (so google/discord always
|
||||
// appear for the admin UI even with no row yet). Each entry carries health.
|
||||
async function listConfigured() {
|
||||
const rows = await authProviders.list()
|
||||
const byId = new Map(rows.map((r) => [r.id, r]))
|
||||
const out = []
|
||||
// Built-ins first, in their fixed order.
|
||||
for (const b of BUILTINS) {
|
||||
const row = byId.get(b.id) || {
|
||||
id: b.id, kind: b.kind, name: b.name, enabled: 0,
|
||||
client_id: null, client_secret_enc: null, priority: b.priority,
|
||||
}
|
||||
byId.delete(b.id)
|
||||
out.push({ ...row, builtin: true, health: validateConfig(row) })
|
||||
}
|
||||
// Then any custom providers.
|
||||
for (const row of byId.values()) {
|
||||
out.push({ ...row, builtin: false, health: validateConfig(row) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Providers that should appear to end users: enabled AND valid. Shaped for the
|
||||
// public discovery endpoint and sorted by priority.
|
||||
async function listEnabledValid() {
|
||||
const configured = await listConfigured()
|
||||
return configured
|
||||
.filter((p) => p.enabled && validateConfig(p).valid)
|
||||
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: p.kind, // 'google' | 'discord' | 'oidc' | 'oauth2'
|
||||
loginUrl: `/api/v1/auth/sso/${p.id}/start`,
|
||||
priority: p.priority ?? 100,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
BUILTINS,
|
||||
isBuiltin,
|
||||
instantiate,
|
||||
load,
|
||||
validateConfig,
|
||||
listConfigured,
|
||||
listEnabledValid,
|
||||
}
|
||||
65
server/src/auth/session.middleware.js
Normal file
65
server/src/auth/session.middleware.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ── Session middleware ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Express middleware built on the session service. Three pieces:
|
||||
//
|
||||
// attachSession — best-effort: decorate the request with session info if a
|
||||
// valid token is present, but never reject. For routes that
|
||||
// behave differently for anon vs authed callers.
|
||||
// requireAuth — the gate for protected routes. Preserves the exact behavior
|
||||
// of the old isLoggedIn: re-validate the user against the DB on
|
||||
// every request so a demoted/deleted user loses access
|
||||
// immediately, and set req.user to the fresh DB row.
|
||||
// requireRole — role gate factory, unchanged from the original.
|
||||
|
||||
const sessionService = require('./session.service')
|
||||
const users = require('../model/users/users.model')
|
||||
const log = require('../utils/logger')('session')
|
||||
|
||||
// Best-effort: if the request carries a valid session token, attach the decoded
|
||||
// session (no DB hit), its auth method, and request metadata. Never rejects —
|
||||
// anonymous requests simply pass through with req.session undefined.
|
||||
function attachSession(req, res, next) {
|
||||
const session = sessionService.validateSession(req)
|
||||
if (session) {
|
||||
req.session = session
|
||||
req.authMethod = session.authMethod
|
||||
req.sessionMeta = sessionService.sessionMeta(req)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// Gate middleware for protected (admin) routes. Re-validates the token against
|
||||
// the database on every request so a demoted or deleted user loses access
|
||||
// immediately, instead of keeping their old role (or a working session) until
|
||||
// the JWT expires. req.user carries the fresh DB row, not the token payload.
|
||||
async function requireAuth(req, res, next) {
|
||||
const session = sessionService.validateSession(req)
|
||||
if (!session) return res.status(401).json({ message: 'Unauthorized' })
|
||||
try {
|
||||
const user = await users.getById(session.userId)
|
||||
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
||||
req.user = user
|
||||
req.session = session
|
||||
req.authMethod = session.authMethod
|
||||
return next()
|
||||
} catch (err) {
|
||||
log.error('requireAuth', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
|
||||
// first so req.user is populated. Use for admin-only endpoints (users, site
|
||||
// mode, settings) so a lower-privilege editor cannot reach them.
|
||||
function requireRole(...roles) {
|
||||
return (req, res, next) => {
|
||||
if (roles.includes(req.user?.role)) return next()
|
||||
return res.status(403).json({ message: 'Forbidden' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachSession,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
}
|
||||
218
server/src/auth/session.service.js
Normal file
218
server/src/auth/session.service.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// ── Session service ────────────────────────────────────────────────────────
|
||||
//
|
||||
// The single seam every caller goes through to issue and validate a session.
|
||||
// Today a "session" is a signed JWT (cookie for web, or a Bearer token), but
|
||||
// callers only ever see the abstract Session object below — never the raw token
|
||||
// shape. That indirection is what lets Part 2 (mobile bearer tokens) and Part 3
|
||||
// (SSO) add new `authMethod`s without touching controllers or middleware.
|
||||
//
|
||||
// A Session object:
|
||||
// {
|
||||
// sessionId, // stable id for this session (JWT jti)
|
||||
// userId, // the user's DB id
|
||||
// username,
|
||||
// role,
|
||||
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
|
||||
// createdAt, // ms epoch the token was issued (JWT iat)
|
||||
// lastSeenAt, // ms epoch this session was last validated
|
||||
// }
|
||||
//
|
||||
// NOTE: revocation/invalidation are stubs. JWTs are stateless, so there is no
|
||||
// server-side session store yet — these are documented hook points for a future
|
||||
// store (e.g. a denylist of jti, or mobile refresh-token records).
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const token = require('./token')
|
||||
const log = require('../utils/logger')('session')
|
||||
|
||||
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
|
||||
// bearer flow (Part 2); 'google'/'discord'/'oidc' are SSO providers and 'sso' is
|
||||
// the generic fallback label (Part 3). Sessions are tagged by how they were
|
||||
// authenticated without changing this module per provider.
|
||||
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
|
||||
|
||||
// Build a Session object from a decoded JWT payload. Returns null for anything
|
||||
// that is not a full session (e.g. a stage-tagged TOTP challenge token).
|
||||
function sessionFromDecoded(decoded, now = Date.now()) {
|
||||
if (!decoded || decoded.stage) return null
|
||||
return {
|
||||
sessionId: decoded.jti || null,
|
||||
userId: decoded.id,
|
||||
username: decoded.username,
|
||||
role: decoded.role,
|
||||
authMethod: decoded.authMethod || 'local',
|
||||
createdAt: decoded.iat ? decoded.iat * 1000 : null,
|
||||
lastSeenAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a real session for a fully-authenticated user. Signs a JWT carrying the
|
||||
// identity claims plus authMethod + a fresh session id (jti), and returns both
|
||||
// the raw token (the caller sets the cookie or returns it as a bearer token)
|
||||
// and the decoded Session object. Does NOT touch cookies or the DB — issuing the
|
||||
// cookie and recording the login stay in the controller so its bot-scoring /
|
||||
// backoff / activity-log orchestration is unchanged.
|
||||
function createSession(user, authMethod = 'local') {
|
||||
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
|
||||
const sessionId = crypto.randomUUID()
|
||||
const raw = token.signToken(user, { authMethod: method, jti: sessionId })
|
||||
const session = sessionFromDecoded(token.verifyToken(raw))
|
||||
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
|
||||
return { token: raw, session }
|
||||
}
|
||||
|
||||
// Issue the short-lived "password verified, awaiting TOTP" challenge. This is
|
||||
// deliberately NOT a session — validateSession rejects it — so a half-completed
|
||||
// login can never be presented as a full one.
|
||||
function createPartialSession(user) {
|
||||
log.info('partial (TOTP) session issued', { userId: user.id, username: user.username })
|
||||
return token.signTotpChallenge(user)
|
||||
}
|
||||
|
||||
// Complete the TOTP step: verify the challenge token and return the decoded
|
||||
// identity ({ id, stage }) so the caller can load the user and createSession().
|
||||
// Returns null for an expired/invalid/non-challenge token.
|
||||
function upgradeSessionAfterTotp(challengeToken) {
|
||||
const decoded = token.verifyTotpChallenge(challengeToken)
|
||||
if (!decoded) {
|
||||
log.warn('TOTP challenge rejected (expired or invalid)')
|
||||
return null
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Validate the session on an incoming request WITHOUT hitting the DB — pure
|
||||
// token verification + identity decode. Returns a Session object or null.
|
||||
// Stage-tagged tokens (the TOTP challenge) are explicitly not sessions.
|
||||
// DB re-validation of the user is a middleware concern (requireAuth), kept
|
||||
// separate so a demoted/deleted user still loses access on the next request.
|
||||
function validateSession(req, now = Date.now()) {
|
||||
const raw = token.extractToken(req)
|
||||
if (!raw) return null
|
||||
return sessionFromDecoded(token.verifyToken(raw), now)
|
||||
}
|
||||
|
||||
// Decode a raw token string into a Session object (or null). Used where the
|
||||
// token is already in hand rather than on a request.
|
||||
function decodeIdentity(rawToken, now = Date.now()) {
|
||||
if (!rawToken) return null
|
||||
return sessionFromDecoded(token.verifyToken(rawToken), now)
|
||||
}
|
||||
|
||||
// ── Mobile (bearer) sessions ───────────────────────────────────────────────
|
||||
// Native clients get a short-lived JWT access token (validated on every request
|
||||
// exactly like a cookie session) plus a long-lived opaque refresh token. The
|
||||
// refresh token is random and never a JWT: it is stored server-side by hash and
|
||||
// is the only revocable half, which is what makes mobile logout meaningful.
|
||||
//
|
||||
// These functions are intentionally pure — they mint and hash but do NOT touch
|
||||
// the database. The controller persists the returned refreshHash via the
|
||||
// mobileSessions model, keeping this module DB-free and unit-testable.
|
||||
|
||||
const MOBILE_ACCESS_TTL = process.env.MOBILE_ACCESS_TTL || '15m'
|
||||
const MOBILE_REFRESH_TTL_DAYS = Number(process.env.MOBILE_REFRESH_TTL_DAYS) || 30
|
||||
|
||||
// Hash a raw refresh token to the value stored in the DB. Exported so the
|
||||
// controller and model agree on the exact representation.
|
||||
function hashRefreshToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Mint a fresh access + refresh pair for a user. `now` is injectable for tests.
|
||||
function mintMobileTokens(user, meta = {}, now = Date.now()) {
|
||||
const sessionId = crypto.randomUUID()
|
||||
const accessToken = token.signToken(
|
||||
user,
|
||||
{ authMethod: 'mobile', jti: sessionId },
|
||||
{ expiresIn: MOBILE_ACCESS_TTL },
|
||||
)
|
||||
// 256 bits of entropy, url-safe. Opaque — carries no claims.
|
||||
const refreshToken = crypto.randomBytes(32).toString('base64url')
|
||||
const refreshExpiresAt = new Date(now + MOBILE_REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
refreshHash: hashRefreshToken(refreshToken),
|
||||
refreshExpiresAt,
|
||||
expiresIn: MOBILE_ACCESS_TTL,
|
||||
deviceHash: meta.deviceHash || null,
|
||||
userAgent: meta.userAgent || null,
|
||||
session: sessionFromDecoded(token.verifyToken(accessToken), now),
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a mobile session at login.
|
||||
function createMobileSession(user, meta = {}, now = Date.now()) {
|
||||
const out = mintMobileTokens(user, meta, now)
|
||||
log.info('mobile session created', { userId: user.id, username: user.username, sessionId: out.session.sessionId })
|
||||
return out
|
||||
}
|
||||
|
||||
// Rotate a mobile session on refresh — same shape as createMobileSession. The
|
||||
// caller is responsible for having validated + revoked the presented refresh
|
||||
// token before calling this (rotation), and for persisting the new refreshHash.
|
||||
function refreshMobileSession(user, meta = {}, now = Date.now()) {
|
||||
const out = mintMobileTokens(user, meta, now)
|
||||
log.info('mobile session refreshed', { userId: user.id, sessionId: out.session.sessionId })
|
||||
return out
|
||||
}
|
||||
|
||||
// Validate a raw bearer access token → Session object or null. Rejects
|
||||
// stage-tagged tokens (a TOTP challenge is not a bearer session).
|
||||
function validateBearerToken(rawToken, now = Date.now()) {
|
||||
if (!rawToken) return null
|
||||
return sessionFromDecoded(token.verifyToken(rawToken), now)
|
||||
}
|
||||
|
||||
// Optional per-session metadata derived from the request. Attached to the
|
||||
// session object by middleware for logging/auditing; NOT baked into the token
|
||||
// (keeps tokens small and avoids trusting client-supplied device data as a claim).
|
||||
function sessionMeta(req) {
|
||||
const ip = req.ip || null
|
||||
const userAgent = (req.headers && req.headers['user-agent']) || null
|
||||
const deviceHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${userAgent || ''}|${ip || ''}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
return { ip, userAgent, deviceHash }
|
||||
}
|
||||
|
||||
// ── Revocation / invalidation (stubs) ──────────────────────────────────────
|
||||
// JWTs are stateless: there is no store to revoke against yet. These are the
|
||||
// hook points a future session store (jti denylist, mobile refresh records)
|
||||
// will implement. They log and report success so callers can wire them in now.
|
||||
|
||||
function revokeSession(sessionId) {
|
||||
log.info('revokeSession (stub — no session store yet)', { sessionId })
|
||||
return true
|
||||
}
|
||||
|
||||
function invalidateSession(sessionId) {
|
||||
log.info('invalidateSession (stub — no session store yet)', { sessionId })
|
||||
return true
|
||||
}
|
||||
|
||||
function invalidateAllUserSessions(userId) {
|
||||
log.info('invalidateAllUserSessions (stub — no session store yet)', { userId })
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUTH_METHODS,
|
||||
createSession,
|
||||
createPartialSession,
|
||||
upgradeSessionAfterTotp,
|
||||
validateSession,
|
||||
decodeIdentity,
|
||||
sessionMeta,
|
||||
revokeSession,
|
||||
invalidateSession,
|
||||
invalidateAllUserSessions,
|
||||
// Mobile bearer sessions.
|
||||
createMobileSession,
|
||||
refreshMobileSession,
|
||||
validateBearerToken,
|
||||
hashRefreshToken,
|
||||
}
|
||||
59
server/src/auth/ssoState.js
Normal file
59
server/src/auth/ssoState.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ── SSO transaction state (CSRF + PKCE) ────────────────────────────────────
|
||||
//
|
||||
// An OAuth redirect flow spans two requests (start → callback) with a hop to the
|
||||
// IdP in between, so we must carry state across it safely:
|
||||
//
|
||||
// - CSRF: an attacker must not be able to forge a callback. We bind the flow to
|
||||
// the user's browser with a short-lived, signed, httpOnly cookie (sso_tx) and
|
||||
// put only an opaque `nonce` in the URL `state` param. The callback requires
|
||||
// state === cookie.nonce, so a callback not initiated by this browser fails.
|
||||
// - PKCE: the code_verifier is generated at start, kept ONLY in the httpOnly
|
||||
// cookie (never in the URL/logs), and sent to the token endpoint at callback.
|
||||
//
|
||||
// The cookie is a signed JWT (reusing the app's JWT signing) with a tight TTL, so
|
||||
// it cannot be tampered with and expires quickly if a flow is abandoned.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const token = require('./token')
|
||||
|
||||
const TX_COOKIE = 'sso_tx'
|
||||
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
|
||||
|
||||
// base64url of random bytes — used for the nonce and the PKCE verifier.
|
||||
function randomUrlSafe(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString('base64url')
|
||||
}
|
||||
|
||||
// PKCE S256 challenge for a given verifier.
|
||||
function codeChallengeFor(verifier) {
|
||||
return crypto.createHash('sha256').update(verifier).digest('base64url')
|
||||
}
|
||||
|
||||
// Create a transaction: returns { nonce, verifier, codeChallenge, txToken }.
|
||||
// `data` = { provider, mode ('login'|'link'), linkUserId?, returnTo? }.
|
||||
function createTx(data) {
|
||||
const nonce = randomUrlSafe(16)
|
||||
const verifier = randomUrlSafe(32)
|
||||
const codeChallenge = codeChallengeFor(verifier)
|
||||
const txToken = token.signToken(
|
||||
{ id: 'sso' }, // subject is irrelevant; this is a flow token, not a session
|
||||
{ nonce, verifier, ...data, kind: 'sso_tx' },
|
||||
{ expiresIn: TX_TTL },
|
||||
)
|
||||
return { nonce, verifier, codeChallenge, txToken }
|
||||
}
|
||||
|
||||
// Verify a tx cookie against the state param. Returns the tx payload
|
||||
// ({ nonce, verifier, provider, mode, ... }) or null if missing/expired/mismatched.
|
||||
function verifyTx(txToken, stateNonce) {
|
||||
if (!txToken || !stateNonce) return null
|
||||
const decoded = token.verifyToken(txToken)
|
||||
if (!decoded || decoded.kind !== 'sso_tx') return null
|
||||
// Constant-time compare so a mismatch can't be timed.
|
||||
const a = Buffer.from(String(decoded.nonce))
|
||||
const b = Buffer.from(String(stateNonce))
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
|
||||
return decoded
|
||||
}
|
||||
|
||||
module.exports = { TX_COOKIE, TX_TTL, createTx, verifyTx, codeChallengeFor, randomUrlSafe }
|
||||
131
server/src/auth/token.js
Normal file
131
server/src/auth/token.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user