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

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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,
}