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:
@@ -23,6 +23,21 @@ JWT_EXPIRES_IN=1d
|
||||
COOKIE_SECURE=auto
|
||||
COOKIE_NAME=uomm_token
|
||||
|
||||
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
|
||||
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
|
||||
# insecure key is derived from JWT_SECRET if unset (with a warning).
|
||||
SECRET_ENC_KEY=dev-only-change-me-too
|
||||
|
||||
# Public base URL of this app, used to build the OAuth redirect_uri
|
||||
# (${APP_BASE_URL}/api/v1/auth/sso/:provider/callback). Set this in production so
|
||||
# the callback URL matches what you register with Google/Discord. If unset, it is
|
||||
# derived from the incoming request (fine for local dev).
|
||||
APP_BASE_URL=http://localhost:5173
|
||||
|
||||
# Short-lived mobile access token lifetime + refresh token lifetime (Part 2).
|
||||
MOBILE_ACCESS_TTL=15m
|
||||
MOBILE_REFRESH_TTL_DAYS=30
|
||||
|
||||
# Reverse-proxy trust. Request path: client -> Pangolin -> newt agent "ptero"
|
||||
# (separate VM) -> this app. ptero is the hop that connects to us, so pin
|
||||
# TRUST_PROXY to ptero's LAN IP: Express then honours X-Forwarded-For ONLY on
|
||||
|
||||
@@ -119,6 +119,63 @@ CREATE TABLE IF NOT EXISTS activity_log (
|
||||
INDEX idx_activity_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Pluggable SSO / OAuth2 provider configuration. Rows exist for the built-in
|
||||
-- providers ('google', 'discord') once an admin configures them, plus any custom
|
||||
-- OIDC/OAuth2 providers (id = a slug). Client secrets are stored ENCRYPTED
|
||||
-- (client_secret_enc) and are never returned to a client. Built-in providers
|
||||
-- hardcode their endpoint URLs in code; the *_url columns are used only by
|
||||
-- custom (oidc/oauth2) providers.
|
||||
CREATE TABLE IF NOT EXISTS auth_providers (
|
||||
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
|
||||
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
client_id VARCHAR(255) NULL,
|
||||
client_secret_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
|
||||
authorize_url VARCHAR(500) NULL, -- custom providers only
|
||||
token_url VARCHAR(500) NULL,
|
||||
userinfo_url VARCHAR(500) NULL,
|
||||
scopes VARCHAR(500) NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Account linking: maps an external SSO identity to an internal user. A login via
|
||||
-- SSO succeeds only if a matching (provider, subject) row exists (link-only —
|
||||
-- external identities are never auto-provisioned into accounts). UNIQUE(provider,
|
||||
-- subject) guarantees one external identity maps to exactly one internal user.
|
||||
CREATE TABLE IF NOT EXISTS user_identities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
|
||||
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
|
||||
email VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_identity_provider_subject (provider, subject),
|
||||
INDEX idx_identity_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Long-lived, revocable refresh tokens for mobile (Android) bearer-token auth.
|
||||
-- The opaque refresh token is never stored in the clear — only its sha256 hash —
|
||||
-- so a DB read does not leak usable tokens. Rows are rotated on every refresh
|
||||
-- (old row revoked, new row inserted) and revoked on logout. Web cookie sessions
|
||||
-- do NOT use this table; it is purely for the mobile bearer flow.
|
||||
CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
||||
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
||||
user_agent VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_mrt_user (user_id),
|
||||
INDEX idx_mrt_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -32,4 +32,23 @@ const contactLimiter = makeLimiter({
|
||||
message: 'Too many messages sent. Please try again later.',
|
||||
})
|
||||
|
||||
module.exports = { loginLimiter, contactLimiter }
|
||||
// Cap mobile refresh-token exchanges per IP. Legitimate apps refresh at most a
|
||||
// handful of times per window; a flood is either a bug or an attempt to brute
|
||||
// the refresh endpoint.
|
||||
const mobileRefreshLimiter = makeLimiter({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
label: 'mobile-refresh',
|
||||
message: 'Too many refresh attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// Throttle SSO redirect starts per IP — cheap to trigger, and a flood is either a
|
||||
// bug or an attempt to spin the OAuth flow. Generous enough for real users.
|
||||
const ssoStartLimiter = makeLimiter({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
label: 'sso-start',
|
||||
message: 'Too many sign-in attempts. Please try again later.',
|
||||
})
|
||||
|
||||
module.exports = { loginLimiter, contactLimiter, mobileRefreshLimiter, ssoStartLimiter }
|
||||
|
||||
36
server/src/model/authProviders/authProviders.db.js
Normal file
36
server/src/model/authProviders/authProviders.db.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, kind, name, enabled, client_id, client_secret_enc, authorize_url, token_url, userinfo_url, scopes, priority, created_at, updated_at'
|
||||
|
||||
async function list() {
|
||||
return query(`SELECT ${COLS} FROM auth_providers ORDER BY priority ASC, id ASC`)
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM auth_providers WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Upsert a provider row. `fields` are column values already prepared by the model
|
||||
// (secret pre-encrypted). Only the provided columns are written/updated.
|
||||
async function upsert(id, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const vals = cols.map((c) => fields[c])
|
||||
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = ['?', ...cols.map(() => '?')].join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO auth_providers (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[id, ...vals],
|
||||
)
|
||||
return get(id)
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const res = await query('DELETE FROM auth_providers WHERE id = ?', [id])
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { list, get, upsert, remove }
|
||||
50
server/src/model/authProviders/authProviders.model.js
Normal file
50
server/src/model/authProviders/authProviders.model.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Auth-provider config store. Thin logic layer over authProviders.db, mirroring
|
||||
// the users model split. Owns encryption of the client secret at the boundary so
|
||||
// the DB layer only ever sees ciphertext and callers only ever see the decrypted
|
||||
// secret when they explicitly ask (getWithSecret) — the plain list/get paths
|
||||
// never surface it.
|
||||
|
||||
const db = require('./authProviders.db')
|
||||
const secretBox = require('../../utils/secretBox')
|
||||
|
||||
// All configured provider rows (secret column left as ciphertext; callers that
|
||||
// need the secret use getWithSecret).
|
||||
async function list() {
|
||||
return db.list()
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
return db.get(id)
|
||||
}
|
||||
|
||||
// Provider row with the client secret decrypted (server-side only — used by the
|
||||
// registry at token-exchange time). Returns null if the provider does not exist.
|
||||
async function getWithSecret(id) {
|
||||
const row = await db.get(id)
|
||||
if (!row) return null
|
||||
return { ...row, client_secret: row.client_secret_enc ? secretBox.decrypt(row.client_secret_enc) : null }
|
||||
}
|
||||
|
||||
// Create/update a provider. `secret` (raw) is encrypted here; pass secret ===
|
||||
// undefined to leave an existing secret untouched, or '' to keep it unchanged as
|
||||
// well (blank means "no change" from the admin UI). Returns the stored row.
|
||||
async function save(id, { kind, name, enabled, clientId, secret, authorizeUrl, tokenUrl, userinfoUrl, scopes, priority }) {
|
||||
const fields = {}
|
||||
if (kind !== undefined) fields.kind = kind
|
||||
if (name !== undefined) fields.name = name
|
||||
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||
if (clientId !== undefined) fields.client_id = clientId
|
||||
if (secret) fields.client_secret_enc = secretBox.encrypt(secret) // only when a new secret is given
|
||||
if (authorizeUrl !== undefined) fields.authorize_url = authorizeUrl
|
||||
if (tokenUrl !== undefined) fields.token_url = tokenUrl
|
||||
if (userinfoUrl !== undefined) fields.userinfo_url = userinfoUrl
|
||||
if (scopes !== undefined) fields.scopes = scopes
|
||||
if (priority !== undefined) fields.priority = priority
|
||||
return db.upsert(id, fields)
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
return db.remove(id)
|
||||
}
|
||||
|
||||
module.exports = { list, get, getWithSecret, save, remove }
|
||||
62
server/src/model/mobileSessions/mobileSessions.db.js
Normal file
62
server/src/model/mobileSessions/mobileSessions.db.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for the mobile_refresh_tokens table. Tokens are stored only as sha256
|
||||
// hashes (token_hash); the raw refresh token never touches the database.
|
||||
|
||||
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch).
|
||||
async function insert({ userId, tokenHash, deviceHash = null, userAgent = null, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, user_agent, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[userId, tokenHash, deviceHash, userAgent, new Date(expiresAt)],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Look up a token by hash only if it is still usable: not revoked and not past
|
||||
// its expiry. Returns the row (incl. user_id) or null.
|
||||
async function findValidByHash(tokenHash) {
|
||||
const rows = await query(
|
||||
`SELECT * FROM mobile_refresh_tokens
|
||||
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[tokenHash],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Mark a single token revoked (idempotent — only affects a not-yet-revoked row).
|
||||
// Returns the number of rows changed.
|
||||
async function revokeByHash(tokenHash) {
|
||||
const res = await query(
|
||||
'UPDATE mobile_refresh_tokens SET revoked_at = NOW() WHERE token_hash = ? AND revoked_at IS NULL',
|
||||
[tokenHash],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Revoke every active token for a user (logout-everywhere). Returns rows changed.
|
||||
async function revokeAllForUser(userId) {
|
||||
const res = await query(
|
||||
'UPDATE mobile_refresh_tokens SET revoked_at = NOW() WHERE user_id = ? AND revoked_at IS NULL',
|
||||
[userId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Housekeeping: delete rows that are long dead (expired or revoked). Keeps the
|
||||
// table from growing without bound. Returns rows removed.
|
||||
async function pruneExpired() {
|
||||
const res = await query(
|
||||
'DELETE FROM mobile_refresh_tokens WHERE expires_at < NOW() OR revoked_at IS NOT NULL',
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insert,
|
||||
findValidByHash,
|
||||
revokeByHash,
|
||||
revokeAllForUser,
|
||||
pruneExpired,
|
||||
}
|
||||
40
server/src/model/mobileSessions/mobileSessions.model.js
Normal file
40
server/src/model/mobileSessions/mobileSessions.model.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Mobile refresh-token store. Thin logic layer over mobileSessions.db — mirrors
|
||||
// the users model split (.db = SQL, .model = the API the rest of the app calls).
|
||||
// The refresh token itself is opaque and lives client-side; only its hash is
|
||||
// persisted (hashing is done by the session service so caller + store agree).
|
||||
|
||||
const db = require('./mobileSessions.db')
|
||||
|
||||
// Persist a newly issued refresh token (by hash). Returns the row id.
|
||||
async function store({ userId, tokenHash, deviceHash, userAgent, expiresAt }) {
|
||||
return db.insert({ userId, tokenHash, deviceHash, userAgent, expiresAt })
|
||||
}
|
||||
|
||||
// Return the stored row for a still-valid (unrevoked, unexpired) token, else null.
|
||||
async function findValidByHash(tokenHash) {
|
||||
return db.findValidByHash(tokenHash)
|
||||
}
|
||||
|
||||
// Revoke one refresh token (logout / rotation). Returns rows changed (0 if it was
|
||||
// already gone/revoked — callers treat this idempotently).
|
||||
async function revokeByHash(tokenHash) {
|
||||
return db.revokeByHash(tokenHash)
|
||||
}
|
||||
|
||||
// Revoke all of a user's refresh tokens (logout everywhere).
|
||||
async function revokeAllForUser(userId) {
|
||||
return db.revokeAllForUser(userId)
|
||||
}
|
||||
|
||||
// Drop expired/revoked rows.
|
||||
async function pruneExpired() {
|
||||
return db.pruneExpired()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
store,
|
||||
findValidByHash,
|
||||
revokeByHash,
|
||||
revokeAllForUser,
|
||||
pruneExpired,
|
||||
}
|
||||
38
server/src/model/userIdentities/userIdentities.db.js
Normal file
38
server/src/model/userIdentities/userIdentities.db.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// Find the identity row for an external (provider, subject) pair. This is the
|
||||
// link-only login lookup: no row → no account → login refused.
|
||||
async function findByProviderSubject(provider, subject) {
|
||||
const rows = await query(
|
||||
'SELECT * FROM user_identities WHERE provider = ? AND subject = ? LIMIT 1',
|
||||
[provider, subject],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// All identities linked to a given internal user (for the Account page).
|
||||
async function listForUser(userId) {
|
||||
return query(
|
||||
'SELECT id, provider, subject, email, created_at FROM user_identities WHERE user_id = ? ORDER BY provider',
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function insert({ userId, provider, subject, email = null }) {
|
||||
const res = await query(
|
||||
'INSERT INTO user_identities (user_id, provider, subject, email) VALUES (?, ?, ?, ?)',
|
||||
[userId, provider, subject, email],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Remove a user's link to a provider. Returns rows deleted.
|
||||
async function deleteForUserProvider(userId, provider) {
|
||||
const res = await query(
|
||||
'DELETE FROM user_identities WHERE user_id = ? AND provider = ?',
|
||||
[userId, provider],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, insert, deleteForUserProvider }
|
||||
27
server/src/model/userIdentities/userIdentities.model.js
Normal file
27
server/src/model/userIdentities/userIdentities.model.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Account-linking store: maps external SSO identities to internal users. Thin
|
||||
// logic layer over userIdentities.db (mirrors the users model split).
|
||||
|
||||
const db = require('./userIdentities.db')
|
||||
|
||||
// The link-only login lookup. Returns the identity row (with user_id) or null.
|
||||
async function findByProviderSubject(provider, subject) {
|
||||
return db.findByProviderSubject(provider, subject)
|
||||
}
|
||||
|
||||
// Identities linked to a user (Account page).
|
||||
async function listForUser(userId) {
|
||||
return db.listForUser(userId)
|
||||
}
|
||||
|
||||
// Link an external identity to an internal user. Returns the new row id. The
|
||||
// (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB.
|
||||
async function link({ userId, provider, subject, email }) {
|
||||
return db.insert({ userId, provider, subject, email })
|
||||
}
|
||||
|
||||
// Unlink a provider from a user. Returns rows removed (0 if nothing was linked).
|
||||
async function unlink(userId, provider) {
|
||||
return db.deleteForUserProvider(userId, provider)
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, link, unlink }
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const totp = require('../../../utils/totp')
|
||||
|
||||
const log = require('../../../utils/logger')('account')
|
||||
@@ -81,4 +82,32 @@ async function totpDisable(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getAccount, totpSetup, totpEnable, totpDisable }
|
||||
// ── Linked SSO identities (self-service) ──────────────────────────────────
|
||||
// List the external accounts (Google/Discord/…) linked to the current user.
|
||||
// Linking itself happens via the SSO redirect flow (/auth/sso/:provider/link).
|
||||
async function listIdentities(req, res) {
|
||||
try {
|
||||
const rows = await userIdentities.listForUser(req.user.id)
|
||||
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
|
||||
} catch (err) {
|
||||
log.error('listIdentities', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a linked SSO identity from the current user's account.
|
||||
async function unlinkIdentity(req, res) {
|
||||
const { provider } = req.params
|
||||
try {
|
||||
const removed = await userIdentities.unlink(req.user.id, provider)
|
||||
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
|
||||
log.info('sso identity unlinked', { provider, id: req.user.id })
|
||||
return res.json({ unlinked: true })
|
||||
} catch (err) {
|
||||
log.error('unlinkIdentity', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getAccount, totpSetup, totpEnable, totpDisable, listIdentities, unlinkIdentity }
|
||||
|
||||
@@ -8,6 +8,7 @@ const { body, param } = require('express-validator')
|
||||
const ctrl = require('./admin.controller')
|
||||
const account = require('./account.controller')
|
||||
const botActivity = require('./botActivity.controller')
|
||||
const authProviders = require('./authProviders.controller')
|
||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -38,6 +39,15 @@ adminRouter.post(
|
||||
account.totpDisable,
|
||||
)
|
||||
|
||||
// Linked SSO identities (self-service — any logged-in role manages their own).
|
||||
adminRouter.get('/account/identities', account.listIdentities)
|
||||
adminRouter.delete(
|
||||
'/account/identities/:provider',
|
||||
param('provider').matches(/^[a-z0-9-]+$/),
|
||||
validate,
|
||||
account.unlinkIdentity,
|
||||
)
|
||||
|
||||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||
const UPLOAD_DIR =
|
||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
||||
@@ -191,6 +201,49 @@ adminRouter.post(
|
||||
botActivity.unbanIp,
|
||||
)
|
||||
|
||||
// ── Authentication providers / SSO (admin only) ───────────────────────
|
||||
adminRouter.get('/auth/providers', adminOnly, authProviders.list)
|
||||
adminRouter.post(
|
||||
'/auth/providers',
|
||||
adminOnly,
|
||||
body('id').matches(/^[a-z0-9-]+$/),
|
||||
body('kind').isIn(['oidc', 'oauth2']),
|
||||
body('name').isString().trim().notEmpty().isLength({ max: 80 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
body('clientId').optional({ values: 'falsy' }).isString(),
|
||||
body('secret').optional({ values: 'falsy' }).isString(),
|
||||
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||||
body('priority').optional().isInt(),
|
||||
validate,
|
||||
authProviders.create,
|
||||
)
|
||||
adminRouter.put(
|
||||
'/auth/providers/:id',
|
||||
adminOnly,
|
||||
param('id').matches(/^[a-z0-9-]+$/),
|
||||
body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
body('clientId').optional({ values: 'falsy' }).isString(),
|
||||
body('secret').optional({ values: 'falsy' }).isString(),
|
||||
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||||
body('priority').optional().isInt(),
|
||||
validate,
|
||||
authProviders.update,
|
||||
)
|
||||
adminRouter.delete(
|
||||
'/auth/providers/:id',
|
||||
adminOnly,
|
||||
param('id').matches(/^[a-z0-9-]+$/),
|
||||
validate,
|
||||
authProviders.remove,
|
||||
)
|
||||
|
||||
// ── User management (admin only) ──────────────────────────────────────
|
||||
adminRouter.use('/users', adminOnly)
|
||||
adminRouter.get('/users', ctrl.listUsers)
|
||||
|
||||
121
server/src/router/v1/admin/authProviders.controller.js
Normal file
121
server/src/router/v1/admin/authProviders.controller.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// ── Admin: auth provider configuration ─────────────────────────────────────
|
||||
//
|
||||
// CRUD for SSO providers. Built-ins (google, discord) are configured here too but
|
||||
// can only be enabled/disabled and given a client id/secret — their kind, name,
|
||||
// and endpoints are fixed in code and cannot be edited or deleted. Custom
|
||||
// (oidc/oauth2) providers are fully editable.
|
||||
//
|
||||
// SECURITY: the client secret is write-only over this API. It is stored encrypted
|
||||
// and NEVER returned — responses expose only `hasSecret`. A blank `secret` on
|
||||
// update means "leave the existing secret unchanged".
|
||||
|
||||
const authProviders = require('../../../model/authProviders/authProviders.model')
|
||||
const registry = require('../../../auth/providers/registry')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
|
||||
// Shape a provider row for the admin UI — no secret material, ever.
|
||||
function toSafe(p) {
|
||||
return {
|
||||
id: p.id,
|
||||
kind: p.kind,
|
||||
name: p.name,
|
||||
enabled: Boolean(p.enabled),
|
||||
clientId: p.client_id || '',
|
||||
hasSecret: Boolean(p.client_secret_enc),
|
||||
authorizeUrl: p.authorize_url || '',
|
||||
tokenUrl: p.token_url || '',
|
||||
userinfoUrl: p.userinfo_url || '',
|
||||
scopes: p.scopes || '',
|
||||
priority: p.priority ?? 100,
|
||||
builtin: registry.isBuiltin(p.id),
|
||||
health: p.health || registry.validateConfig(p),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/auth/providers — all providers (built-ins always present) + health.
|
||||
async function list(req, res) {
|
||||
try {
|
||||
const rows = await registry.listConfigured()
|
||||
return res.json(rows.map(toSafe))
|
||||
} catch (err) {
|
||||
log.error('authProviders.list', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/auth/providers — create a custom (oidc/oauth2) provider.
|
||||
async function create(req, res) {
|
||||
const { id, kind, name, enabled, clientId, secret, authorizeUrl, tokenUrl, userinfoUrl, scopes, priority } = req.body
|
||||
try {
|
||||
if (registry.isBuiltin(id)) {
|
||||
return res.status(400).json({ message: 'Built-in provider — configure it via PUT, not create.' })
|
||||
}
|
||||
if (!['oidc', 'oauth2'].includes(kind)) {
|
||||
return res.status(400).json({ message: 'Custom providers must be of kind oidc or oauth2.' })
|
||||
}
|
||||
if (await authProviders.get(id)) {
|
||||
return res.status(409).json({ message: 'A provider with that id already exists.' })
|
||||
}
|
||||
const saved = await authProviders.save(id, {
|
||||
kind, name, enabled, clientId, secret, authorizeUrl, tokenUrl, userinfoUrl, scopes, priority,
|
||||
})
|
||||
await activity.log({ req, action: 'auth.provider.create', detail: { id } })
|
||||
log.info('auth provider created', { id, kind, by: req.user.username })
|
||||
return res.status(201).json(toSafe(saved))
|
||||
} catch (err) {
|
||||
log.error('authProviders.create', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/auth/providers/:id — update a built-in or custom provider.
|
||||
async function update(req, res) {
|
||||
const { id } = req.params
|
||||
const b = req.body
|
||||
try {
|
||||
let fields
|
||||
if (registry.isBuiltin(id)) {
|
||||
// Built-ins: kind/name/priority are fixed; only enable + credentials change.
|
||||
const meta = registry.BUILTINS.find((x) => x.id === id)
|
||||
fields = { kind: meta.kind, name: meta.name, priority: meta.priority, enabled: b.enabled, clientId: b.clientId, secret: b.secret }
|
||||
} else {
|
||||
if (!(await authProviders.get(id))) {
|
||||
return res.status(404).json({ message: 'Provider not found.' })
|
||||
}
|
||||
fields = {
|
||||
name: b.name, enabled: b.enabled, clientId: b.clientId, secret: b.secret,
|
||||
authorizeUrl: b.authorizeUrl, tokenUrl: b.tokenUrl, userinfoUrl: b.userinfoUrl,
|
||||
scopes: b.scopes, priority: b.priority,
|
||||
}
|
||||
}
|
||||
const saved = await authProviders.save(id, fields)
|
||||
await activity.log({ req, action: 'auth.provider.update', detail: { id } })
|
||||
log.info('auth provider updated', { id, by: req.user.username })
|
||||
return res.json(toSafe(saved))
|
||||
} catch (err) {
|
||||
log.error('authProviders.update', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/auth/providers/:id — custom providers only.
|
||||
async function remove(req, res) {
|
||||
const { id } = req.params
|
||||
try {
|
||||
if (registry.isBuiltin(id)) {
|
||||
return res.status(400).json({ message: 'Built-in providers cannot be deleted — disable them instead.' })
|
||||
}
|
||||
const n = await authProviders.remove(id)
|
||||
if (!n) return res.status(404).json({ message: 'Provider not found.' })
|
||||
await activity.log({ req, action: 'auth.provider.delete', detail: { id } })
|
||||
log.info('auth provider deleted', { id, by: req.user.username })
|
||||
return res.json({ deleted: true })
|
||||
} catch (err) {
|
||||
log.error('authProviders.remove', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { list, create, update, remove, toSafe }
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
143
server/src/router/v1/auth/mobile.controller.js
Normal file
143
server/src/router/v1/auth/mobile.controller.js
Normal 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 }
|
||||
48
server/src/router/v1/auth/mobile.routes.js
Normal file
48
server/src/router/v1/auth/mobile.routes.js
Normal 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
|
||||
176
server/src/router/v1/auth/sso.controller.js
Normal file
176
server/src/router/v1/auth/sso.controller.js
Normal 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 }
|
||||
22
server/src/router/v1/auth/sso.routes.js
Normal file
22
server/src/router/v1/auth/sso.routes.js
Normal 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
|
||||
@@ -1,150 +1,36 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
require('dotenv').config()
|
||||
// ── Auth compatibility facade ──────────────────────────────────────────────
|
||||
//
|
||||
// The auth logic now lives in server/src/auth/ (token primitives, the session
|
||||
// service, and session middleware). This module stays as a thin facade so every
|
||||
// existing import site (auth.routes, admin.routes, siteMode, auth.controller)
|
||||
// keeps working with the exact same names and behavior — nothing else in the
|
||||
// codebase needs to change. New code should prefer requiring ../auth/* directly.
|
||||
|
||||
const log = require('./logger')('auth')
|
||||
const users = require('../model/users/users.model')
|
||||
const token = require('../auth/token')
|
||||
const sessionService = require('../auth/session.service')
|
||||
const { requireAuth, requireRole } = require('../auth/session.middleware')
|
||||
|
||||
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()
|
||||
|
||||
function signToken(user) {
|
||||
const payload = { id: user.id, username: user.username, role: user.role }
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN })
|
||||
}
|
||||
|
||||
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 getUserFromRequest 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.
|
||||
function extractToken(req) {
|
||||
if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME]
|
||||
const header = req.headers.authorization
|
||||
if (header && header.startsWith('Bearer ')) return header.substring(7)
|
||||
return null
|
||||
}
|
||||
|
||||
// Returns the decoded user or null without rejecting the request. Stage-tagged
|
||||
// tokens (e.g. the TOTP challenge) are explicitly NOT sessions, so an attacker
|
||||
// can't present a half-authenticated challenge token as a full login.
|
||||
// Non-rejecting identity check. Returns the decoded token payload (with `.id`)
|
||||
// or null — same shape callers relied on (siteMode only truthiness-checks it).
|
||||
// Backed by the session service so there is a single validation path.
|
||||
function getUserFromRequest(req) {
|
||||
const token = extractToken(req)
|
||||
if (!token) return null
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded || decoded.stage) return null
|
||||
return decoded
|
||||
}
|
||||
|
||||
// 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 isLoggedIn(req, res, next) {
|
||||
const decoded = getUserFromRequest(req)
|
||||
if (!decoded) return res.status(401).json({ message: 'Unauthorized' })
|
||||
try {
|
||||
const user = await users.getById(decoded.id)
|
||||
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
||||
req.user = user
|
||||
return next()
|
||||
} catch (err) {
|
||||
log.error('isLoggedIn', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Gate middleware factory: allow only the listed roles. Assumes isLoggedIn 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' })
|
||||
}
|
||||
const session = sessionService.validateSession(req)
|
||||
if (!session) return null
|
||||
// Preserve the historical payload shape (id/username/role) for callers.
|
||||
return { id: session.userId, username: session.username, role: session.role }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
signToken,
|
||||
verifyToken,
|
||||
signTotpChallenge,
|
||||
verifyTotpChallenge,
|
||||
setAuthCookie,
|
||||
clearAuthCookie,
|
||||
COOKIE_NAME: token.COOKIE_NAME,
|
||||
// Token primitives (re-exported from auth/token.js).
|
||||
signToken: token.signToken,
|
||||
verifyToken: token.verifyToken,
|
||||
signTotpChallenge: token.signTotpChallenge,
|
||||
verifyTotpChallenge: token.verifyTotpChallenge,
|
||||
setAuthCookie: token.setAuthCookie,
|
||||
clearAuthCookie: token.clearAuthCookie,
|
||||
// Request helpers / middleware.
|
||||
getUserFromRequest,
|
||||
isLoggedIn,
|
||||
isLoggedIn: requireAuth, // old name → new middleware, identical behavior
|
||||
requireRole,
|
||||
}
|
||||
|
||||
55
server/src/utils/secretBox.js
Normal file
55
server/src/utils/secretBox.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// ── Secret-at-rest encryption (AES-256-GCM) ────────────────────────────────
|
||||
//
|
||||
// Used to encrypt OAuth client secrets before they are written to the DB, so a
|
||||
// database read alone does not yield usable provider credentials. Output format
|
||||
// is `iv:tag:ciphertext`, each part base64. GCM provides authenticated
|
||||
// encryption, so tampering is detected on decrypt.
|
||||
//
|
||||
// The key comes from SECRET_ENC_KEY (any string — it is hashed to 32 bytes). In
|
||||
// development, if unset, we derive a key from JWT_SECRET with a loud warning
|
||||
// (mirrors token.resolveJwtSecret) so local dev works; production must set a
|
||||
// dedicated key so rotating JWT_SECRET does not silently orphan stored secrets.
|
||||
|
||||
const crypto = require('crypto')
|
||||
require('dotenv').config()
|
||||
|
||||
const log = require('../utils/logger')('secretbox')
|
||||
|
||||
const ALGO = 'aes-256-gcm'
|
||||
|
||||
function resolveKey() {
|
||||
const explicit = process.env.SECRET_ENC_KEY
|
||||
if (explicit) return crypto.createHash('sha256').update(explicit).digest()
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SECRET_ENC_KEY must be set in production')
|
||||
}
|
||||
const jwt = process.env.JWT_SECRET || 'dev-insecure-jwt-secret-do-not-use-in-production'
|
||||
log.warn('SECRET_ENC_KEY is not set — deriving an insecure key from JWT_SECRET for development. Set SECRET_ENC_KEY before deploying.')
|
||||
return crypto.createHash('sha256').update(`secretbox:${jwt}`).digest()
|
||||
}
|
||||
|
||||
const KEY = resolveKey()
|
||||
|
||||
// Encrypt a UTF-8 string → "iv:tag:ct" (base64 parts). Returns null for empty input.
|
||||
function encrypt(plaintext) {
|
||||
if (plaintext == null || plaintext === '') return null
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGO, KEY, iv)
|
||||
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`
|
||||
}
|
||||
|
||||
// Decrypt a value produced by encrypt(). Returns null for null/blank input;
|
||||
// throws if the payload is malformed or fails authentication (tampered/wrong key).
|
||||
function decrypt(payload) {
|
||||
if (payload == null || payload === '') return null
|
||||
const parts = String(payload).split(':')
|
||||
if (parts.length !== 3) throw new Error('secretBox: malformed ciphertext')
|
||||
const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64'))
|
||||
const decipher = crypto.createDecipheriv(ALGO, KEY, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt }
|
||||
81
server/test/mobileSession.test.js
Normal file
81
server/test/mobileSession.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Set before requiring the auth layer (token.js reads JWT_SECRET at load) and
|
||||
// db.js (pulled in transitively; the pool builds at load). Closed DB port keeps
|
||||
// idle connections from holding the process open — these tests are DB-free and
|
||||
// only exercise the pure token/hash logic of the mobile session service.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const tokenLib = require('../src/auth/token')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const USER = { id: 42, username: 'mobileuser', role: 'admin' }
|
||||
|
||||
test('createMobileSession mints a bearer-validatable access token + opaque refresh token', () => {
|
||||
const out = sessionService.createMobileSession(USER, { deviceHash: 'abc', userAgent: 'Android' })
|
||||
|
||||
// Access token validates as a mobile session.
|
||||
const session = sessionService.validateBearerToken(out.accessToken)
|
||||
assert.ok(session)
|
||||
assert.equal(session.userId, USER.id)
|
||||
assert.equal(session.authMethod, 'mobile')
|
||||
assert.ok(session.sessionId)
|
||||
|
||||
// Refresh token is opaque (not a JWT) and its stored form is the sha256 hash.
|
||||
assert.equal(typeof out.refreshToken, 'string')
|
||||
assert.ok(out.refreshToken.length >= 40)
|
||||
assert.equal(out.refreshHash, sessionService.hashRefreshToken(out.refreshToken))
|
||||
assert.equal(sessionService.validateBearerToken(out.refreshToken), null, 'refresh token is not a bearer session')
|
||||
|
||||
// Metadata + a future expiry are carried through for the controller to persist.
|
||||
assert.equal(out.deviceHash, 'abc')
|
||||
assert.equal(out.userAgent, 'Android')
|
||||
assert.ok(out.refreshExpiresAt instanceof Date)
|
||||
assert.ok(out.refreshExpiresAt.getTime() > Date.now())
|
||||
})
|
||||
|
||||
test('access token is short-lived (mobile TTL, not the 1d web default)', () => {
|
||||
const { accessToken } = sessionService.createMobileSession(USER)
|
||||
const decoded = jwt.decode(accessToken)
|
||||
const lifetime = decoded.exp - decoded.iat
|
||||
// Default MOBILE_ACCESS_TTL is 15m — comfortably under the 1d web session.
|
||||
assert.ok(lifetime <= 15 * 60, `access token lifetime ${lifetime}s should be <= 15m`)
|
||||
})
|
||||
|
||||
test('refreshMobileSession issues a distinct new pair (rotation)', () => {
|
||||
const a = sessionService.createMobileSession(USER)
|
||||
const b = sessionService.refreshMobileSession(USER)
|
||||
assert.notEqual(a.refreshToken, b.refreshToken)
|
||||
assert.notEqual(a.refreshHash, b.refreshHash)
|
||||
})
|
||||
|
||||
test('hashRefreshToken is stable and deterministic', () => {
|
||||
assert.equal(sessionService.hashRefreshToken('token-xyz'), sessionService.hashRefreshToken('token-xyz'))
|
||||
assert.notEqual(sessionService.hashRefreshToken('a'), sessionService.hashRefreshToken('b'))
|
||||
// sha256 hex is 64 chars.
|
||||
assert.equal(sessionService.hashRefreshToken('anything').length, 64)
|
||||
})
|
||||
|
||||
test('validateBearerToken rejects a TOTP challenge and garbage', () => {
|
||||
const challenge = sessionService.createPartialSession(USER)
|
||||
assert.equal(sessionService.validateBearerToken(challenge), null)
|
||||
assert.equal(sessionService.validateBearerToken('not-a-jwt'), null)
|
||||
assert.equal(sessionService.validateBearerToken(null), null)
|
||||
})
|
||||
|
||||
test('token.signToken honors an expiresIn override, else uses the default', () => {
|
||||
const short = tokenLib.signToken(USER, {}, { expiresIn: '1s' })
|
||||
const shortDecoded = jwt.decode(short)
|
||||
assert.equal(shortDecoded.exp - shortDecoded.iat, 1)
|
||||
|
||||
// No option → historical default (JWT_EXPIRES_IN, 1d) unchanged.
|
||||
const dflt = jwt.decode(tokenLib.signToken(USER))
|
||||
assert.equal(dflt.exp - dflt.iat, 24 * 60 * 60)
|
||||
})
|
||||
92
server/test/providers.test.js
Normal file
92
server/test/providers.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const GoogleProvider = require('../src/auth/providers/google.provider')
|
||||
const DiscordProvider = require('../src/auth/providers/discord.provider')
|
||||
const GenericOidcProvider = require('../src/auth/providers/genericOidc.provider')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const realFetch = global.fetch
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch
|
||||
})
|
||||
|
||||
// Install a fetch stub that answers by URL substring.
|
||||
function mockFetch(routes) {
|
||||
global.fetch = async (url) => {
|
||||
for (const [needle, payload] of Object.entries(routes)) {
|
||||
if (String(url).includes(needle)) {
|
||||
return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) }
|
||||
}
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => 'not found' }
|
||||
}
|
||||
}
|
||||
|
||||
test('Google getAuthorizationUrl includes client_id, redirect_uri, scope, state, PKCE', () => {
|
||||
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
|
||||
const url = p.getAuthorizationUrl('the-state', { redirectUri: 'https://app/cb', codeChallenge: 'CHAL' })
|
||||
assert.ok(url.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
|
||||
const q = new URL(url).searchParams
|
||||
assert.equal(q.get('client_id'), 'gid')
|
||||
assert.equal(q.get('redirect_uri'), 'https://app/cb')
|
||||
assert.equal(q.get('response_type'), 'code')
|
||||
assert.equal(q.get('scope'), 'openid email profile')
|
||||
assert.equal(q.get('state'), 'the-state')
|
||||
assert.equal(q.get('code_challenge'), 'CHAL')
|
||||
assert.equal(q.get('code_challenge_method'), 'S256')
|
||||
})
|
||||
|
||||
test('Google handleCallback exchanges code and normalizes the profile', async () => {
|
||||
mockFetch({
|
||||
'oauth2.googleapis.com/token': { access_token: 'AT' },
|
||||
'openidconnect.googleapis.com/v1/userinfo': { sub: '11550', email: 'alice@example.com', name: 'Alice' },
|
||||
})
|
||||
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
|
||||
assert.deepEqual(profile, { subject: '11550', email: 'alice@example.com', name: 'Alice' })
|
||||
})
|
||||
|
||||
test('Discord authorize URL + profile mapping (global_name → name, id → subject)', async () => {
|
||||
const p = new DiscordProvider({ id: 'discord', clientId: 'did', clientSecret: 'dsecret' })
|
||||
const url = p.getAuthorizationUrl('s', { redirectUri: 'https://app/cb' })
|
||||
assert.ok(url.startsWith('https://discord.com/oauth2/authorize?'))
|
||||
assert.equal(new URL(url).searchParams.get('scope'), 'identify email')
|
||||
|
||||
mockFetch({
|
||||
'discord.com/api/oauth2/token': { access_token: 'AT' },
|
||||
'discord.com/api/users/@me': { id: '99', username: 'bob', global_name: 'Bob', email: 'bob@x.io' },
|
||||
})
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb' })
|
||||
assert.deepEqual(profile, { subject: '99', email: 'bob@x.io', name: 'Bob' })
|
||||
})
|
||||
|
||||
test('Generic OIDC provider uses configured endpoints and OIDC profile fields', async () => {
|
||||
const p = new GenericOidcProvider({
|
||||
id: 'authentik', kind: 'oidc', clientId: 'cid', clientSecret: 'csec',
|
||||
authorizeUrl: 'https://idp.example/authorize', tokenUrl: 'https://idp.example/token',
|
||||
userinfoUrl: 'https://idp.example/userinfo', scopes: 'openid email',
|
||||
})
|
||||
const url = p.getAuthorizationUrl('s', { redirectUri: 'https://app/cb', codeChallenge: 'CH' })
|
||||
assert.ok(url.startsWith('https://idp.example/authorize?'))
|
||||
assert.equal(new URL(url).searchParams.get('scope'), 'openid email')
|
||||
|
||||
mockFetch({
|
||||
'idp.example/token': { access_token: 'AT' },
|
||||
'idp.example/userinfo': { sub: 'abc', email: 'c@d.e', preferred_username: 'carol' },
|
||||
})
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
|
||||
assert.deepEqual(profile, { subject: 'abc', email: 'c@d.e', name: 'carol' })
|
||||
})
|
||||
|
||||
test('handleCallback throws when the token exchange fails', async () => {
|
||||
mockFetch({}) // everything 404s
|
||||
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
|
||||
await assert.rejects(() => p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' }))
|
||||
})
|
||||
53
server/test/registry.test.js
Normal file
53
server/test/registry.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registry = require('../src/auth/providers/registry')
|
||||
const authProvidersModel = require('../src/model/authProviders/authProviders.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
test('validateConfig: built-in needs client_id + secret', () => {
|
||||
assert.equal(registry.validateConfig({ kind: 'google', client_id: 'x', client_secret_enc: 'e' }).valid, true)
|
||||
assert.deepEqual(registry.validateConfig({ kind: 'google', client_id: 'x' }).missing, ['client_secret'])
|
||||
assert.deepEqual(registry.validateConfig({ kind: 'google' }).missing, ['client_id', 'client_secret'])
|
||||
})
|
||||
|
||||
test('validateConfig: custom OIDC also needs the endpoint URLs', () => {
|
||||
const complete = {
|
||||
kind: 'oidc', client_id: 'x', client_secret_enc: 'e',
|
||||
authorize_url: 'a', token_url: 't', userinfo_url: 'u',
|
||||
}
|
||||
assert.equal(registry.validateConfig(complete).valid, true)
|
||||
const noUrls = { kind: 'oidc', client_id: 'x', client_secret_enc: 'e' }
|
||||
assert.deepEqual(registry.validateConfig(noUrls).missing, ['authorize_url', 'token_url', 'userinfo_url'])
|
||||
})
|
||||
|
||||
test('listConfigured always includes both built-ins with health', async () => {
|
||||
authProvidersModel.list = async () => [] // no rows yet
|
||||
const out = await registry.listConfigured()
|
||||
const ids = out.map((p) => p.id)
|
||||
assert.deepEqual(ids, ['google', 'discord'])
|
||||
assert.equal(out[0].builtin, true)
|
||||
assert.equal(out[0].enabled, 0)
|
||||
assert.equal(out[0].health.valid, false) // unconfigured
|
||||
})
|
||||
|
||||
test('listEnabledValid returns only enabled+valid, shaped and sorted by priority', async () => {
|
||||
authProvidersModel.list = async () => [
|
||||
{ id: 'discord', kind: 'discord', name: 'Discord', enabled: 1, client_id: 'd', client_secret_enc: 'e', priority: 2 },
|
||||
{ id: 'google', kind: 'google', name: 'Google', enabled: 1, client_id: 'g', client_secret_enc: 'e', priority: 1 },
|
||||
{ id: 'brokenidp', kind: 'oidc', name: 'Broken', enabled: 1, client_id: 'x', client_secret_enc: 'e', priority: 0 }, // missing URLs → hidden
|
||||
{ id: 'authentik', kind: 'oidc', name: 'Authentik', enabled: 0, client_id: 'x', client_secret_enc: 'e', authorize_url: 'a', token_url: 't', userinfo_url: 'u', priority: 3 }, // disabled → hidden
|
||||
]
|
||||
const out = await registry.listEnabledValid()
|
||||
assert.deepEqual(out.map((p) => p.id), ['google', 'discord']) // sorted by priority, broken/disabled excluded
|
||||
assert.deepEqual(out[0], {
|
||||
id: 'google', name: 'Google', icon: 'google', loginUrl: '/api/v1/auth/sso/google/start', priority: 1,
|
||||
})
|
||||
})
|
||||
37
server/test/secretBox.test.js
Normal file
37
server/test/secretBox.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const secretBox = require('../src/utils/secretBox')
|
||||
|
||||
test('encrypt → decrypt round-trips a secret', () => {
|
||||
const plain = 'super-secret-oauth-client-secret'
|
||||
const enc = secretBox.encrypt(plain)
|
||||
assert.notEqual(enc, plain)
|
||||
assert.match(enc, /^[^:]+:[^:]+:[^:]+$/) // iv:tag:ct
|
||||
assert.equal(secretBox.decrypt(enc), plain)
|
||||
})
|
||||
|
||||
test('ciphertext differs each call (random IV) but both decrypt', () => {
|
||||
const a = secretBox.encrypt('x')
|
||||
const b = secretBox.encrypt('x')
|
||||
assert.notEqual(a, b)
|
||||
assert.equal(secretBox.decrypt(a), 'x')
|
||||
assert.equal(secretBox.decrypt(b), 'x')
|
||||
})
|
||||
|
||||
test('null/blank round-trips to null', () => {
|
||||
assert.equal(secretBox.encrypt(''), null)
|
||||
assert.equal(secretBox.encrypt(null), null)
|
||||
assert.equal(secretBox.decrypt(null), null)
|
||||
assert.equal(secretBox.decrypt(''), null)
|
||||
})
|
||||
|
||||
test('tampered ciphertext fails authentication', () => {
|
||||
const enc = secretBox.encrypt('secret')
|
||||
const [iv, tag, ct] = enc.split(':')
|
||||
const tampered = `${iv}:${tag}:${Buffer.from('garbage').toString('base64')}`
|
||||
assert.throws(() => secretBox.decrypt(tampered))
|
||||
assert.throws(() => secretBox.decrypt('only:two')) // malformed
|
||||
})
|
||||
124
server/test/session.test.js
Normal file
124
server/test/session.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// Set before requiring the auth layer (token.js reads JWT_SECRET at load) and
|
||||
// db.js (the users model, pulled in via the utils/auth facade, builds the pool
|
||||
// at load). Pointing the DB at a closed port stops idle connections from keeping
|
||||
// this process alive — none of these tests touch the database.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const authFacade = require('../src/utils/auth')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const USER = { id: 7, username: 'alice', role: 'admin' }
|
||||
|
||||
// Build a request double carrying a token, either as a cookie or a Bearer header.
|
||||
function reqWithCookie(token) {
|
||||
return { cookies: { [authFacade.COOKIE_NAME]: token }, headers: {} }
|
||||
}
|
||||
function reqWithBearer(token) {
|
||||
return { cookies: {}, headers: { authorization: `Bearer ${token}` } }
|
||||
}
|
||||
|
||||
test('createSession → validateSession round-trips a Session object', () => {
|
||||
const { token, session } = sessionService.createSession(USER, 'local')
|
||||
assert.equal(typeof token, 'string')
|
||||
|
||||
// The returned session object carries the canonical shape.
|
||||
assert.equal(session.userId, USER.id)
|
||||
assert.equal(session.username, USER.username)
|
||||
assert.equal(session.role, USER.role)
|
||||
assert.equal(session.authMethod, 'local')
|
||||
assert.ok(session.sessionId, 'sessionId (jti) is present')
|
||||
assert.equal(typeof session.createdAt, 'number')
|
||||
|
||||
// Validating the same token off a request yields the same identity.
|
||||
const validated = sessionService.validateSession(reqWithCookie(token))
|
||||
assert.ok(validated)
|
||||
assert.equal(validated.userId, USER.id)
|
||||
assert.equal(validated.username, USER.username)
|
||||
assert.equal(validated.role, USER.role)
|
||||
assert.equal(validated.authMethod, 'local')
|
||||
assert.equal(validated.sessionId, session.sessionId)
|
||||
})
|
||||
|
||||
test('validateSession accepts a Bearer token as well as a cookie', () => {
|
||||
const { token } = sessionService.createSession(USER, 'mobile')
|
||||
const validated = sessionService.validateSession(reqWithBearer(token))
|
||||
assert.ok(validated)
|
||||
assert.equal(validated.userId, USER.id)
|
||||
assert.equal(validated.authMethod, 'mobile')
|
||||
})
|
||||
|
||||
test('authMethod defaults to local when an unknown method is passed', () => {
|
||||
const { session } = sessionService.createSession(USER, 'bogus')
|
||||
assert.equal(session.authMethod, 'local')
|
||||
})
|
||||
|
||||
test('a partial (TOTP challenge) token is NOT a valid session', () => {
|
||||
const challenge = sessionService.createPartialSession(USER)
|
||||
assert.equal(typeof challenge, 'string')
|
||||
// Stage-tagged tokens must never validate as a full session.
|
||||
assert.equal(sessionService.validateSession(reqWithCookie(challenge)), null)
|
||||
assert.equal(sessionService.decodeIdentity(challenge), null)
|
||||
})
|
||||
|
||||
test('upgradeSessionAfterTotp accepts a challenge and rejects a session token', () => {
|
||||
const challenge = sessionService.createPartialSession(USER)
|
||||
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
|
||||
assert.ok(decoded)
|
||||
assert.equal(decoded.id, USER.id)
|
||||
assert.equal(decoded.stage, 'totp')
|
||||
|
||||
// A normal session token is not a TOTP challenge — must be rejected here.
|
||||
const { token } = sessionService.createSession(USER, 'local')
|
||||
assert.equal(sessionService.upgradeSessionAfterTotp(token), null)
|
||||
})
|
||||
|
||||
test('validateSession / decodeIdentity return null for missing or garbage input', () => {
|
||||
assert.equal(sessionService.validateSession({ cookies: {}, headers: {} }), null)
|
||||
assert.equal(sessionService.decodeIdentity(null), null)
|
||||
assert.equal(sessionService.decodeIdentity('not-a-jwt'), null)
|
||||
})
|
||||
|
||||
test('revoke / invalidate stubs report success without throwing', () => {
|
||||
assert.equal(sessionService.revokeSession('sid-1'), true)
|
||||
assert.equal(sessionService.invalidateSession('sid-1'), true)
|
||||
assert.equal(sessionService.invalidateAllUserSessions(USER.id), true)
|
||||
})
|
||||
|
||||
test('sessionMeta derives ip / userAgent / deviceHash from the request', () => {
|
||||
const meta = sessionService.sessionMeta({ ip: '203.0.113.5', headers: { 'user-agent': 'jest' } })
|
||||
assert.equal(meta.ip, '203.0.113.5')
|
||||
assert.equal(meta.userAgent, 'jest')
|
||||
assert.equal(typeof meta.deviceHash, 'string')
|
||||
assert.ok(meta.deviceHash.length > 0)
|
||||
})
|
||||
|
||||
test('backward-compat: utils/auth facade still exports the original API', () => {
|
||||
for (const name of [
|
||||
'isLoggedIn',
|
||||
'requireRole',
|
||||
'signToken',
|
||||
'verifyToken',
|
||||
'signTotpChallenge',
|
||||
'verifyTotpChallenge',
|
||||
'setAuthCookie',
|
||||
'clearAuthCookie',
|
||||
'getUserFromRequest',
|
||||
]) {
|
||||
assert.equal(typeof authFacade[name], 'function', `${name} is exported as a function`)
|
||||
}
|
||||
assert.equal(typeof authFacade.COOKIE_NAME, 'string')
|
||||
|
||||
// getUserFromRequest still returns the historical { id, username, role } shape.
|
||||
const { token } = sessionService.createSession(USER, 'local')
|
||||
const decoded = authFacade.getUserFromRequest(reqWithCookie(token))
|
||||
assert.deepEqual(decoded, { id: USER.id, username: USER.username, role: USER.role })
|
||||
assert.equal(authFacade.getUserFromRequest({ cookies: {}, headers: {} }), null)
|
||||
})
|
||||
116
server/test/ssoCallback.test.js
Normal file
116
server/test/ssoCallback.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
|
||||
const ssoState = require('../src/auth/ssoState')
|
||||
const token = require('../src/auth/token')
|
||||
// Modules whose methods we stub (exports are plain objects → mutable in-process).
|
||||
const users = require('../src/model/users/users.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const authProviders = require('../src/model/authProviders/authProviders.model')
|
||||
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||
const registry = require('../src/auth/providers/registry')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const GOOGLE_ROW = { id: 'google', kind: 'google', name: 'Google', enabled: 1, client_id: 'cid', client_secret_enc: 'enc' }
|
||||
const PROFILE = { subject: 'sub-1', email: 'alice@example.com', name: 'Alice' }
|
||||
|
||||
let logged
|
||||
beforeEach(() => {
|
||||
logged = []
|
||||
activity.log = async (evt) => { logged.push(evt) }
|
||||
authProviders.getWithSecret = async () => ({ ...GOOGLE_ROW })
|
||||
// Bypass real OAuth network calls: the provider just yields a fixed profile.
|
||||
registry.instantiate = () => ({ handleCallback: async () => ({ ...PROFILE }) })
|
||||
userIdentities.findByProviderSubject = async () => null
|
||||
userIdentities.link = async () => 1
|
||||
users.getById = async (id) => ({ id, username: 'alice', role: 'admin' })
|
||||
users.recordLogin = async () => {} // avoid the real DB on the success path
|
||||
})
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200, redirectedTo: null, cookies: {}, cleared: [],
|
||||
status(c) { this.statusCode = c; return this },
|
||||
json(b) { this.body = b; return this },
|
||||
redirect(u) { this.redirectedTo = u; return this },
|
||||
cookie(n, v) { this.cookies[n] = v; return this },
|
||||
clearCookie(n) { this.cleared.push(n); return this },
|
||||
}
|
||||
}
|
||||
|
||||
function makeReq(tx, { state, code = 'auth-code' } = {}) {
|
||||
return {
|
||||
params: { provider: 'google' },
|
||||
cookies: { [ssoState.TX_COOKIE]: tx.txToken },
|
||||
query: { state: state ?? tx.nonce, code },
|
||||
ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
|
||||
}
|
||||
}
|
||||
|
||||
test('linked identity → session cookie set, redirect to /admin, login logged', async () => {
|
||||
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(makeReq(tx), res)
|
||||
|
||||
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie was set')
|
||||
assert.equal(res.redirectedTo, '/admin')
|
||||
assert.ok(res.cleared.includes(ssoState.TX_COOKIE), 'tx cookie cleared')
|
||||
assert.equal(logged.at(-1).action, 'auth.sso.login')
|
||||
})
|
||||
|
||||
test('linked identity honors a safe returnTo', async () => {
|
||||
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(makeReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/admin/posts')
|
||||
})
|
||||
|
||||
test('UNLINKED identity → no session, redirect to not_linked (link-only policy)', async () => {
|
||||
userIdentities.findByProviderSubject = async () => null
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(makeReq(tx), res)
|
||||
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no session cookie')
|
||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=not_linked')
|
||||
assert.equal(logged.length, 0)
|
||||
})
|
||||
|
||||
test('link mode → identity linked to the acting user, redirect to account', async () => {
|
||||
let linkArgs = null
|
||||
userIdentities.link = async (args) => { linkArgs = args; return 1 }
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'link', linkUserId: 5 })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(makeReq(tx), res)
|
||||
|
||||
assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com' })
|
||||
assert.equal(res.redirectedTo, '/admin/account?linked=google')
|
||||
assert.equal(logged.at(-1).action, 'auth.sso.link')
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
|
||||
})
|
||||
|
||||
test('link mode refuses an identity already owned by another user', async () => {
|
||||
userIdentities.findByProviderSubject = async () => ({ user_id: 999 })
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'link', linkUserId: 5 })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(makeReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/admin/account?link_error=in_use')
|
||||
})
|
||||
|
||||
test('bad state (CSRF) → rejected before any provider work', async () => {
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(makeReq(tx, { state: 'tampered-nonce' }), res)
|
||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
||||
})
|
||||
38
server/test/ssoState.test.js
Normal file
38
server/test/ssoState.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const ssoState = require('../src/auth/ssoState')
|
||||
|
||||
test('createTx → verifyTx round-trips the flow payload', () => {
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
|
||||
assert.ok(tx.nonce && tx.verifier && tx.codeChallenge && tx.txToken)
|
||||
|
||||
const payload = ssoState.verifyTx(tx.txToken, tx.nonce)
|
||||
assert.ok(payload)
|
||||
assert.equal(payload.provider, 'google')
|
||||
assert.equal(payload.mode, 'login')
|
||||
assert.equal(payload.returnTo, '/admin/posts')
|
||||
assert.equal(payload.verifier, tx.verifier)
|
||||
})
|
||||
|
||||
test('codeChallenge is the S256 hash of the verifier', () => {
|
||||
const tx = ssoState.createTx({ provider: 'discord', mode: 'login' })
|
||||
const expected = crypto.createHash('sha256').update(tx.verifier).digest('base64url')
|
||||
assert.equal(tx.codeChallenge, expected)
|
||||
})
|
||||
|
||||
test('verifyTx rejects a mismatched / tampered nonce', () => {
|
||||
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
|
||||
assert.equal(ssoState.verifyTx(tx.txToken, 'wrong-nonce'), null)
|
||||
assert.equal(ssoState.verifyTx(tx.txToken, null), null)
|
||||
assert.equal(ssoState.verifyTx(null, tx.nonce), null)
|
||||
})
|
||||
|
||||
test('verifyTx rejects a non-tx token', () => {
|
||||
const token = require('../src/auth/token')
|
||||
const notTx = token.signToken({ id: 1, username: 'a', role: 'admin' })
|
||||
assert.equal(ssoState.verifyTx(notTx, 'anything'), null)
|
||||
})
|
||||
Reference in New Issue
Block a user