diff --git a/backend/security/src/index.ts b/backend/security/src/index.ts index c0f2877a..3afe38bb 100644 --- a/backend/security/src/index.ts +++ b/backend/security/src/index.ts @@ -21,7 +21,7 @@ import invitationsRoutes from './routes/invitations' import internalRoutes from './routes/internal' import apiTokensRoutes, { orgTokensRouter } from './routes/api-tokens' import { tokenAuthRateLimiter } from './middleware/api-token-auth' -import { oidcService } from './services/oidc' +import { initializeAllTenants } from './services/oidc' dotenv.config() @@ -96,15 +96,15 @@ async function startServer() { }) try { - console.log('🔧 Initializing OIDC service...') - if (oidcService.isConfigured()) { - await oidcService.initialize() - console.log('✅ OIDC service initialized successfully') - } else { - console.log('⚠️ OIDC service not configured - local auth only') - } + console.log('🔧 Initializing OIDC service(s)...') + // One client per configured tenant, each against its OWN Authentik. + // initializeAllTenants warms them in parallel and starts each one's + // self-heal loop; a tenant whose Authentik is down is logged and left to + // the background retry rather than blocking the others from coming up. + await initializeAllTenants() + console.log('✅ OIDC service(s) initialized') } catch (error) { - console.error('❌ Failed to initialize OIDC service:', error) + console.error('❌ Failed to initialize OIDC service(s):', error) console.log('⚠️ Continuing with local authentication only') } @@ -118,12 +118,15 @@ async function startServer() { // human ran `kubectl rollout restart`. This self-heals on its own once // Authentik comes back, with zero request traffic required. Requests // arriving in the meantime also get a lazy re-init attempt via - // oidcService.ensureInitialized() (see routes/auth.ts, authentikPassword.ts, - // AuthentikIdentityProvider.ts) — both paths share the same in-flight - // promise + cooldown so they never double-fire against Authentik. - if (oidcService.isConfigured() && !oidcService.isInitialized()) { - oidcService.startBackgroundRetry() - } + // getOidcService().ensureInitialized() (see routes/auth.ts, + // authentikPassword.ts, AuthentikIdentityProvider.ts) — both paths share + // the same in-flight promise + cooldown so they never double-fire against + // Authentik. + // + // The retry loops are started by initializeAllTenants() above, per tenant, + // so there is no separate kick-off here. Each tenant self-heals + // independently: one tenant's Authentik being down neither blocks nor + // resets another's. const portNumber = typeof PORT === 'string' ? parseInt(PORT, 10) : PORT httpServer.listen(portNumber, () => { diff --git a/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts b/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts index 0164b8c6..c57e9f68 100644 --- a/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts +++ b/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts @@ -21,7 +21,8 @@ import crypto from 'crypto' import jwt from 'jsonwebtoken' import { v4 as uuidv4 } from 'uuid' import { db as defaultDb } from '../../config/database' -import { oidcService as defaultOidc } from '../../services/oidc' +import { getOidcService, type OIDCServiceLike } from '../../services/oidc' +import { currentTenant } from './tenants' import { authentikPasswordLogin as defaultPasswordLogin } from '../../services/authentikPassword' import { runInternalProvision } from '../../services/organizationProvisioning' import { @@ -212,7 +213,7 @@ export function emailVerificationEnabled(): boolean { export interface AuthentikProviderDeps { db: Db - oidc: typeof defaultOidc + oidc: OIDCServiceLike passwordLoginFn: (email: string, password: string) => Promise /** Drives Authentik enrollment + OIDC sync; returns the synced user projection. */ signupFn: (input: SignupInput) => Promise @@ -251,7 +252,23 @@ function maskContact(v: string): string { export class AuthentikIdentityProvider implements IdentityProvider { private db: Db - private oidc: typeof defaultOidc + /** + * Injected override (tests). Left undefined in production so `oidc` resolves + * the CURRENT tenant's client on each access — this provider is constructed + * once, outside any request, so binding an instance here would permanently + * pin it to whichever tenant happened to be ambient at construction. + */ + private oidcOverride?: OIDCServiceLike + + /** + * The OIDC client for the tenant serving the CURRENT request, resolved on + * every access rather than captured once. An injected override always wins, + * so tests are unaffected. + */ + private get oidc(): OIDCServiceLike { + return this.oidcOverride ?? getOidcService() + } + private passwordLoginFn: (email: string, password: string) => Promise private signupFn: (input: SignupInput) => Promise private notifications: NotificationClient @@ -270,7 +287,7 @@ export class AuthentikIdentityProvider implements IdentityProvider { constructor(deps: Partial = {}) { this.db = deps.db ?? defaultDb - this.oidc = deps.oidc ?? defaultOidc + this.oidcOverride = deps.oidc this.passwordLoginFn = deps.passwordLoginFn ?? (async (email, password) => { @@ -715,7 +732,7 @@ export class AuthentikIdentityProvider implements IdentityProvider { async issueM2MToken(input: M2MTokenInput): Promise { if (this.issueM2MFn) return this.issueM2MFn(input) // client-credentials grant against the global token endpoint. - const issuer = process.env.AUTHENTIK_ISSUER_URL || 'http://localhost:9000/application/o/fuzefront/' + const issuer = currentTenant('issuer').issuerUrl const tokenEndpoint = new URL('/application/o/token/', new URL(issuer).origin).toString() const body = new URLSearchParams({ grant_type: 'client_credentials', diff --git a/backend/security/src/providers/authentik/accountApi.ts b/backend/security/src/providers/authentik/accountApi.ts index 58381d95..3463b013 100644 --- a/backend/security/src/providers/authentik/accountApi.ts +++ b/backend/security/src/providers/authentik/accountApi.ts @@ -1,212 +1,217 @@ -/** - * Authentik admin-API calls for ACCOUNT sign-in methods (social source - * connections + password). - * - * Provider-internal: this is one of the few places the identity vendor is named. - * Everything above it speaks the neutral `IdentityProvider` contract, so the - * Authentik `pk`s, source slugs, and endpoint shapes below never cross the - * boundary. Swap this file to swap providers. - * - * Fail-closed: every call throws on any transport/HTTP error. A caller never - * receives a permissive default (e.g. "no connections", which would let the - * last-sign-in-method guard wave through an account lockout). - */ - -/** A social source connection as Authentik models it. */ -export interface OAuthConnection { - /** Authentik connection pk — provider-internal, used only to unlink. */ - pk: number - /** Authentik source slug; mapped to our neutral provider slug by the caller. */ - sourceSlug: string - /** Epoch millis the connection was created, when Authentik reports it. */ - createdAt?: number -} - -function baseUrl(): string { - return ( - process.env.AUTHENTIK_BASE_URL || - process.env.AUTHENTIK_ISSUER_URL?.replace(/\/application\/o\/.*$/, '') || - 'http://localhost:9000' - ).replace(/\/$/, '') -} - -function adminToken(): string { - const token = process.env.AUTHENTIK_ADMIN_TOKEN - if (!token) { - // Fail-closed: without admin credentials we cannot read connection state, - // and guessing it is exactly how an account gets locked out. - throw new Error('AUTHENTIK_ADMIN_TOKEN is not configured') - } - return token -} - -function headers(): Record { - return { - Authorization: `Bearer ${adminToken()}`, - Accept: 'application/json', - } -} - -async function call(path: string, init: RequestInit = {}): Promise { - let res: Response - try { - res = await fetch(`${baseUrl()}${path}`, { - ...init, - headers: { ...headers(), ...(init.headers as Record) }, - }) - } catch (err) { - throw new Error(`identity store unreachable: ${(err as Error).message}`) - } - return res -} - -async function okJson(res: Response, what: string): Promise { - if (!res.ok) { - const body = (await res.text().catch(() => '')).slice(0, 300) - throw new Error(`identity store ${what} failed: HTTP ${res.status} ${body}`) - } - return res.json() -} - -/** - * Resolve the identity store's user pk from the email. - * - * Email is the natural key our local projection matches on (the local `id` is a - * generated uuid, deliberately NOT the OIDC `sub`), so it is the only join we - * have. `email=` is an exact filter in Authentik's user list API. - */ -export async function findUserPk(email: string): Promise { - const res = await call(`/api/v3/core/users/?email=${encodeURIComponent(email)}`) - const data = await okJson(res, 'user lookup') - const results: any[] = data?.results ?? [] - // Exact, case-insensitive match — never trust the filter to be exact-only. - const hit = results.find( - r => typeof r?.email === 'string' && r.email.toLowerCase() === email.toLowerCase() - ) - if (!hit || typeof hit.pk !== 'number') { - throw new Error('identity store user not found') - } - return hit.pk -} - -/** - * Resolve the identity-store user pk from email, CREATING the user if absent. - * - * Used by the SERVER-BROKERED Google path: after we validate Google's id_token we - * provision (or find) the account IN AUTHENTIK so the IdP stays the system of - * record and a later password/Google sign-in de-dupes to ONE account by email. - * Idempotent: a concurrent create that loses the race is recovered by re-reading. - */ -export async function findOrCreateUserPk( - email: string, - firstName?: string, - lastName?: string -): Promise { - try { - return await findUserPk(email) - } catch { - // Not found — create. Any other transport error would have thrown a - // different message but findUserPk only throws "not found" on a clean 200 - // with no match; a real transport failure re-throws below via the create call. - } - const name = [firstName, lastName].filter(Boolean).join(' ').trim() || email - const res = await call('/api/v3/core/users/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: email, - email, - name, - is_active: true, - // `internal` service-account-free human account; Authentik defaults path. - type: 'internal', - }), - }) - if (res.ok) { - const created = (await res.json()) as { pk?: number } - if (typeof created?.pk === 'number') return created.pk - } - // A 400 "username/email already exists" means a racing create won — re-read. - return findUserPk(email) -} - -/** - * Ensure the user's OAuth source connection exists for `sourceSlug` (idempotent). - * - * This is what makes `getIdentityConnections` show Google as linked and what the - * unlink guard reads. Because the browser no longer transits Authentik's source - * flow in the brokered path, WE must record the connection ourselves. - */ -export async function ensureOAuthConnection( - userPk: number, - sourceSlug: string, - identifier: string -): Promise { - const existing = await listOAuthConnections(userPk) - if (existing.some(c => c.sourceSlug === sourceSlug)) return - const res = await call('/api/v3/sources/user_connections/oauth/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user: userPk, source: sourceSlug, identifier }), - }) - // 400 on a racing duplicate is fine — the connection now exists either way. - if (!res.ok && res.status !== 400) { - const body = (await res.text().catch(() => '')).slice(0, 300) - throw new Error(`identity store link-connection failed: HTTP ${res.status} ${body}`) - } -} - -/** List the user's OAuth source connections. */ -export async function listOAuthConnections(userPk: number): Promise { - const res = await call(`/api/v3/sources/user_connections/oauth/?user=${userPk}`) - const data = await okJson(res, 'connection list') - const results: any[] = data?.results ?? [] - return results.map(r => ({ - pk: r.pk, - // `source` may be an expanded object or a bare slug depending on version. - sourceSlug: typeof r.source === 'string' ? r.source : (r.source?.slug ?? ''), - createdAt: r.created ? new Date(r.created).getTime() : undefined, - })) -} - -/** Delete one OAuth source connection by its connection pk. */ -export async function deleteOAuthConnection(connectionPk: number): Promise { - const res = await call(`/api/v3/sources/user_connections/oauth/${connectionPk}/`, { - method: 'DELETE', - }) - // 404 = already gone; unlink is idempotent, so that is a success. - if (!res.ok && res.status !== 404) { - const body = (await res.text().catch(() => '')).slice(0, 300) - throw new Error(`identity store unlink failed: HTTP ${res.status} ${body}`) - } -} - -/** - * Set the user's password IN THE IDENTITY STORE (never a local hash). - * - * A policy rejection comes back as a 400 with the reasons; the caller maps that - * to a 400 rather than a generic failure. - */ -export async function setUserPassword(userPk: number, password: string): Promise { - const res = await call(`/api/v3/core/users/${userPk}/set_password/`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }), - }) - if (res.status === 400) { - const body = (await res.text().catch(() => '')).slice(0, 300) - throw new PasswordPolicyError(body || 'password rejected by policy') - } - if (!res.ok) { - const body = (await res.text().catch(() => '')).slice(0, 300) - throw new Error(`identity store set-password failed: HTTP ${res.status} ${body}`) - } -} - -/** Thrown when the identity store rejects a password on policy grounds (→ 400). */ -export class PasswordPolicyError extends Error { - constructor(message: string) { - super(message) - this.name = 'PasswordPolicyError' - } -} +/** + * Authentik admin-API calls for ACCOUNT sign-in methods (social source + * connections + password). + * + * Provider-internal: this is one of the few places the identity vendor is named. + * Everything above it speaks the neutral `IdentityProvider` contract, so the + * Authentik `pk`s, source slugs, and endpoint shapes below never cross the + * boundary. Swap this file to swap providers. + * + * Fail-closed: every call throws on any transport/HTTP error. A caller never + * receives a permissive default (e.g. "no connections", which would let the + * last-sign-in-method guard wave through an account lockout). + */ +import { currentTenant } from './tenants' + +/** A social source connection as Authentik models it. */ +export interface OAuthConnection { + /** Authentik connection pk — provider-internal, used only to unlink. */ + pk: number + /** Authentik source slug; mapped to our neutral provider slug by the caller. */ + sourceSlug: string + /** Epoch millis the connection was created, when Authentik reports it. */ + createdAt?: number +} + +function baseUrl(): string { + const tenant = currentTenant('Authentik base URL') + return ( + tenant.baseUrl || + tenant.issuerUrl.replace(/\/application\/o\/.*$/, '') || + 'http://localhost:9000' + ).replace(/\/$/, '') +} + +function adminToken(): string { + // Per-tenant: this token grants admin access to ONE Authentik instance. + // Presenting one tenant's token to another's API would either fail or — + // worse — succeed against the wrong directory. + const token = currentTenant('Authentik admin token').adminToken + if (!token) { + // Fail-closed: without admin credentials we cannot read connection state, + // and guessing it is exactly how an account gets locked out. + throw new Error('Authentik admin token is not configured for this tenant') + } + return token +} + +function headers(): Record { + return { + Authorization: `Bearer ${adminToken()}`, + Accept: 'application/json', + } +} + +async function call(path: string, init: RequestInit = {}): Promise { + let res: Response + try { + res = await fetch(`${baseUrl()}${path}`, { + ...init, + headers: { ...headers(), ...(init.headers as Record) }, + }) + } catch (err) { + throw new Error(`identity store unreachable: ${(err as Error).message}`) + } + return res +} + +async function okJson(res: Response, what: string): Promise { + if (!res.ok) { + const body = (await res.text().catch(() => '')).slice(0, 300) + throw new Error(`identity store ${what} failed: HTTP ${res.status} ${body}`) + } + return res.json() +} + +/** + * Resolve the identity store's user pk from the email. + * + * Email is the natural key our local projection matches on (the local `id` is a + * generated uuid, deliberately NOT the OIDC `sub`), so it is the only join we + * have. `email=` is an exact filter in Authentik's user list API. + */ +export async function findUserPk(email: string): Promise { + const res = await call(`/api/v3/core/users/?email=${encodeURIComponent(email)}`) + const data = await okJson(res, 'user lookup') + const results: any[] = data?.results ?? [] + // Exact, case-insensitive match — never trust the filter to be exact-only. + const hit = results.find( + r => typeof r?.email === 'string' && r.email.toLowerCase() === email.toLowerCase() + ) + if (!hit || typeof hit.pk !== 'number') { + throw new Error('identity store user not found') + } + return hit.pk +} + +/** + * Resolve the identity-store user pk from email, CREATING the user if absent. + * + * Used by the SERVER-BROKERED Google path: after we validate Google's id_token we + * provision (or find) the account IN AUTHENTIK so the IdP stays the system of + * record and a later password/Google sign-in de-dupes to ONE account by email. + * Idempotent: a concurrent create that loses the race is recovered by re-reading. + */ +export async function findOrCreateUserPk( + email: string, + firstName?: string, + lastName?: string +): Promise { + try { + return await findUserPk(email) + } catch { + // Not found — create. Any other transport error would have thrown a + // different message but findUserPk only throws "not found" on a clean 200 + // with no match; a real transport failure re-throws below via the create call. + } + const name = [firstName, lastName].filter(Boolean).join(' ').trim() || email + const res = await call('/api/v3/core/users/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: email, + email, + name, + is_active: true, + // `internal` service-account-free human account; Authentik defaults path. + type: 'internal', + }), + }) + if (res.ok) { + const created = (await res.json()) as { pk?: number } + if (typeof created?.pk === 'number') return created.pk + } + // A 400 "username/email already exists" means a racing create won — re-read. + return findUserPk(email) +} + +/** + * Ensure the user's OAuth source connection exists for `sourceSlug` (idempotent). + * + * This is what makes `getIdentityConnections` show Google as linked and what the + * unlink guard reads. Because the browser no longer transits Authentik's source + * flow in the brokered path, WE must record the connection ourselves. + */ +export async function ensureOAuthConnection( + userPk: number, + sourceSlug: string, + identifier: string +): Promise { + const existing = await listOAuthConnections(userPk) + if (existing.some(c => c.sourceSlug === sourceSlug)) return + const res = await call('/api/v3/sources/user_connections/oauth/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: userPk, source: sourceSlug, identifier }), + }) + // 400 on a racing duplicate is fine — the connection now exists either way. + if (!res.ok && res.status !== 400) { + const body = (await res.text().catch(() => '')).slice(0, 300) + throw new Error(`identity store link-connection failed: HTTP ${res.status} ${body}`) + } +} + +/** List the user's OAuth source connections. */ +export async function listOAuthConnections(userPk: number): Promise { + const res = await call(`/api/v3/sources/user_connections/oauth/?user=${userPk}`) + const data = await okJson(res, 'connection list') + const results: any[] = data?.results ?? [] + return results.map(r => ({ + pk: r.pk, + // `source` may be an expanded object or a bare slug depending on version. + sourceSlug: typeof r.source === 'string' ? r.source : (r.source?.slug ?? ''), + createdAt: r.created ? new Date(r.created).getTime() : undefined, + })) +} + +/** Delete one OAuth source connection by its connection pk. */ +export async function deleteOAuthConnection(connectionPk: number): Promise { + const res = await call(`/api/v3/sources/user_connections/oauth/${connectionPk}/`, { + method: 'DELETE', + }) + // 404 = already gone; unlink is idempotent, so that is a success. + if (!res.ok && res.status !== 404) { + const body = (await res.text().catch(() => '')).slice(0, 300) + throw new Error(`identity store unlink failed: HTTP ${res.status} ${body}`) + } +} + +/** + * Set the user's password IN THE IDENTITY STORE (never a local hash). + * + * A policy rejection comes back as a 400 with the reasons; the caller maps that + * to a 400 rather than a generic failure. + */ +export async function setUserPassword(userPk: number, password: string): Promise { + const res = await call(`/api/v3/core/users/${userPk}/set_password/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) + if (res.status === 400) { + const body = (await res.text().catch(() => '')).slice(0, 300) + throw new PasswordPolicyError(body || 'password rejected by policy') + } + if (!res.ok) { + const body = (await res.text().catch(() => '')).slice(0, 300) + throw new Error(`identity store set-password failed: HTTP ${res.status} ${body}`) + } +} + +/** Thrown when the identity store rejects a password on policy grounds (→ 400). */ +export class PasswordPolicyError extends Error { + constructor(message: string) { + super(message) + this.name = 'PasswordPolicyError' + } +} diff --git a/backend/security/src/routes/auth.ts b/backend/security/src/routes/auth.ts index 80701744..b2e8e26a 100644 --- a/backend/security/src/routes/auth.ts +++ b/backend/security/src/routes/auth.ts @@ -1,824 +1,825 @@ -import crypto from 'crypto' -import express from 'express' -import rateLimit from 'express-rate-limit' -import bcrypt from 'bcryptjs' -import jwt from 'jsonwebtoken' -import { v4 as uuidv4 } from 'uuid' -import { db } from '../config/database' -import { authenticateToken } from '../middleware/auth' -import { User } from '../types/shared' -import { oidcService } from '../services/oidc' -import { - authentikPasswordLogin, - InvalidCredentialsError, - AuthentikUnavailableError, - UnsupportedFlowStageError, -} from '../services/authentikPassword' -import { runInternalProvision } from '../services/organizationProvisioning' -import { putBrokerCode, takeBrokerCode, sweepBrokerCodes } from '../services/brokerCodes' - - -const CODE_TTL_MS = 60_000 - -// Use the configured frontend base URL for all redirects so that exchange codes -// ride HTTPS in production rather than a hardcoded http:// origin. -const FRONTEND_BASE = (process.env.FRONTEND_URL || 'http://fuzefront.dev.local').replace(/\/$/, '') - -// Periodic sweep: remove never-redeemed codes that have passed their TTL. -// .unref() prevents this interval from keeping the process alive in tests. -setInterval(() => sweepBrokerCodes(), CODE_TTL_MS).unref() - -const router = express.Router() - -/** - * Self-heal provisioning on login: ensure the user has a personal org and that - * every org they own which isn't `active` gets reconciled. Fire-and-forget — - * this must never block or fail the login response. Acts as the safety net when - * the identity.user.created Kafka event was lost. - */ -function selfHealProvisioningOnLogin(userId: string): void { - runInternalProvision(userId).catch(err => { - console.error(`Login self-heal provisioning failed for ${userId}:`, err) - }) -} - -/** - * @swagger - * /api/auth/login: - * post: - * summary: User login - * description: Authenticate user with email and password, returns JWT token - * tags: [Authentication] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/LoginRequest' - * example: - * email: "admin@frontfuse.dev" - * password: "admin123" - * responses: - * 200: - * description: Login successful - * content: - * application/json: - * schema: - * allOf: - * - $ref: '#/components/schemas/LoginResponse' - * - type: object - * properties: - * sessionId: - * type: string - * format: uuid - * description: Session identifier - * example: - * token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - * user: - * id: "550e8400-e29b-41d4-a716-446655440000" - * email: "admin@frontfuse.dev" - * firstName: "Admin" - * lastName: "User" - * roles: ["admin", "user"] - * sessionId: "123e4567-e89b-12d3-a456-426614174000" - * 400: - * description: Missing email or password - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * example: - * error: "Email and password required" - * 401: - * description: Invalid credentials - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * example: - * error: "Invalid credentials" - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -// POST /auth/login - Mock login -router.post('/login', async (req, res) => { - const requestId = uuidv4().substring(0, 8) - const startTime = Date.now() - - console.log(`🔐 [${requestId}] Login request received:`, { - timestamp: new Date().toISOString(), - ip: req.ip || req.connection.remoteAddress, - userAgent: req.get('User-Agent'), - origin: req.get('Origin'), - referer: req.get('Referer'), - contentType: req.get('Content-Type'), - bodyKeys: Object.keys(req.body || {}), - hasEmail: !!req.body?.email, - hasPassword: !!req.body?.password, - emailDomain: req.body?.email ? req.body.email.split('@')[1] : 'none', - }) - - try { - const { email, password } = req.body - - if (!email || !password) { - console.log(`❌ [${requestId}] Missing credentials:`, { - hasEmail: !!email, - hasPassword: !!password, - responseTime: Date.now() - startTime, - }) - return res.status(400).json({ error: 'Email and password required' }) - } - - console.log(`🔍 [${requestId}] Looking up user:`, { - email, - passwordLength: password.length, - }) - - // Find user - const userRow = await db('users').where('email', email).first() - - if (!userRow) { - console.log(`❌ [${requestId}] User not found:`, { - email, - responseTime: Date.now() - startTime, - }) - return res.status(401).json({ error: 'Invalid credentials' }) - } - - console.log(`👤 [${requestId}] User found:`, { - userId: userRow.id, - email: userRow.email, - hasPasswordHash: !!userRow.password_hash, - roles: userRow.roles, - }) - - // Verify password - console.log(`🔒 [${requestId}] Verifying password...`) - const isValidPassword = await bcrypt.compare( - password, - userRow.password_hash - ) - - if (!isValidPassword) { - console.log(`❌ [${requestId}] Invalid password:`, { - email, - responseTime: Date.now() - startTime, - }) - return res.status(401).json({ error: 'Invalid credentials' }) - } - - console.log(`✅ [${requestId}] Password verified, generating token...`) - - // Create the session id first so it can be embedded in the token; this lets - // logout invalidate only THIS session rather than all of the user's sessions. - const sessionId = uuidv4() - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours - - // Generate JWT - const token = jwt.sign( - { userId: userRow.id, sessionId }, - process.env.JWT_SECRET!, - { expiresIn: '24h' } - ) - - console.log(`🎫 [${requestId}] JWT token generated:`, { - tokenLength: token.length, - tokenPreview: token.substring(0, 20) + '...', - }) - - console.log(`💾 [${requestId}] Creating session:`, { - sessionId, - expiresAt: expiresAt.toISOString(), - }) - - await db('sessions').insert({ - id: sessionId, - user_id: userRow.id, - expires_at: expiresAt, - }) - - // Debug logging for roles parsing - console.log(`🔍 [${requestId}] Parsing roles:`, { - rawRoles: userRow.roles, - rolesType: typeof userRow.roles, - rolesLength: userRow.roles?.length, - firstChar: userRow.roles?.[0], - fallback: '["user"]', - }) - - const user: User = { - id: userRow.id, - email: userRow.email, - firstName: userRow.first_name, - lastName: userRow.last_name, - defaultAppId: userRow.default_app_id, - roles: Array.isArray(userRow.roles) - ? userRow.roles - : JSON.parse(userRow.roles || '["user"]'), - } - - console.log(`🎉 [${requestId}] Login successful:`, { - userId: user.id, - email: user.email, - roles: user.roles, - sessionId, - responseTime: Date.now() - startTime, - }) - - // Self-heal provisioning in the background (does not block the response). - selfHealProvisioningOnLogin(user.id) - - res.json({ - token, - user, - sessionId, - }) - } catch (error) { - console.error(`💥 [${requestId}] Login error:`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - responseTime: Date.now() - startTime, - }) - res.status(500).json({ error: 'Internal server error' }) - } -}) - -/** - * @swagger - * /api/auth/user: - * get: - * summary: Get current user - * description: Get information about the currently authenticated user - * tags: [Authentication] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Current user information - * content: - * application/json: - * schema: - * type: object - * properties: - * user: - * $ref: '#/components/schemas/User' - * example: - * user: - * id: "550e8400-e29b-41d4-a716-446655440000" - * email: "admin@frontfuse.dev" - * firstName: "Admin" - * lastName: "User" - * roles: ["admin", "user"] - * 401: - * description: Access token required - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 403: - * description: Invalid token - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -// GET /auth/user - Get current user -router.get('/user', authenticateToken, async (req, res) => { - res.json({ user: req.user }) -}) - -/** - * @swagger - * /api/auth/logout: - * post: - * summary: User logout - * description: Logout the current user and invalidate their session - * tags: [Authentication] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Logout successful - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: "Logged out successfully" - * 500: - * description: Logout failed - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -// POST /auth/logout -router.post('/logout', authenticateToken, async (req: any, res) => { - try { - const authHeader = req.headers['authorization'] - const token = authHeader && authHeader.split(' ')[1] - - if (token) { - const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { - userId: string - sessionId?: string - } - // Invalidate only the current session, not every session the user has. - if (decoded.sessionId) { - await db('sessions').where('id', decoded.sessionId).del() - } - } - - res.json({ message: 'Logged out successfully' }) - } catch (error) { - res.status(500).json({ error: 'Logout failed' }) - } -}) - -/** - * @swagger - * /api/auth/oidc/login: - * get: - * summary: Initiate OIDC login - * description: Redirects to Authentik for OIDC authentication - * tags: [Authentication] - * security: [] - * responses: - * 302: - * description: Redirect to Authentik login page - * 500: - * description: OIDC not configured or server error - */ -router.get('/oidc/login', async (req, res) => { - const requestId = uuidv4().substring(0, 8) - // Structured trace: enough to diagnose a broken handoff from pod logs alone - // (misconfigured issuer/redirect/frontend-base, or an uninitialized client - // whose discovery against Authentik failed at boot). - console.log('🔐 OIDC login request received', { - requestId, - referer: req.get('Referer'), - configured: oidcService.isConfigured?.(), - initialized: oidcService.isInitialized?.(), - issuerUrl: process.env.AUTHENTIK_ISSUER_URL, - redirectUri: process.env.AUTHENTIK_REDIRECT_URI, - frontendBase: FRONTEND_BASE, - }) - - try { - if (!oidcService.isConfigured()) { - console.log(`❌ [${requestId}] OIDC not configured`) - return res.status(500).json({ - error: 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.' - }) - } - - if (!oidcService.isInitialized()) { - await oidcService.ensureInitialized() - } - - const state = uuidv4() - const { url, codeVerifier } = oidcService.generateAuthUrl(state) - - console.log(`🔗 [${requestId}] Redirecting to Authentik:`, url) - // Persist BOTH the CSRF state and the PKCE code_verifier in HttpOnly cookies - // so the callback is replica-agnostic (the service runs >1 replica; the - // callback frequently lands on a different pod than /oidc/login). - res.setHeader('Set-Cookie', [ - `oidc_state=${state}; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`, - `oidc_cv=${codeVerifier}; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`, - ]) - res.redirect(url) - } catch (error) { - console.error(`❌ [${requestId}] OIDC login error:`, error) - res.status(500).json({ error: 'Failed to initiate OIDC login' }) - } -}) - -/** - * @swagger - * /api/auth/oidc/signup: - * get: - * summary: Initiate account sign-up via Authentik enrollment - * description: > - * Redirects to Authentik's enrollment flow with the OIDC authorize URL - * as the flow's ?next= target, so a freshly-enrolled (and auto-logged-in) - * user continues straight through the normal OIDC callback and lands in - * the app with a session — no second sign-in step. - * tags: [Authentication] - * security: [] - * responses: - * 302: - * description: Redirect to the Authentik enrollment flow - * 500: - * description: OIDC not configured or server error - */ -// Unauthenticated redirect endpoint — cheap, but cap per-client abuse anyway -// (URL-minting/log-noise). Generous: legitimate users click this once or twice. -const signupRedirectRateLimiter = rateLimit({ - windowMs: 5 * 60_000, - limit: 30, - standardHeaders: true, - legacyHeaders: false, - message: { error: 'Too many sign-up attempts. Try again later.' }, -}) - -router.get('/oidc/signup', signupRedirectRateLimiter, async (req, res) => { - const requestId = uuidv4().substring(0, 8) - try { - if (!oidcService.isConfigured()) { - console.log('❌ OIDC not configured (signup)', { requestId }) - return res.status(500).json({ - error: 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.', - }) - } - - if (!oidcService.isInitialized()) { - await oidcService.ensureInitialized() - } - - const state = uuidv4() - const { url, codeVerifier } = oidcService.generateAuthUrl(state) - // Wrap the authorize URL (same Authentik origin) in the enrollment flow's - // ?next= — Authentik redirects there after the flow's user-login stage. - const authorize = new URL(url) - const enrollSlug = - process.env.AUTHENTIK_ENROLLMENT_FLOW_SLUG || 'fuzefront-enrollment' - const enrollUrl = `${authorize.origin}/if/flow/${encodeURIComponent(enrollSlug)}/?next=${encodeURIComponent(`${authorize.pathname}${authorize.search}`)}` - - console.log('🔗 Redirecting to Authentik enrollment', { requestId, enrollUrl }) - // Same replica-agnostic state/PKCE cookies as /oidc/login — the enrollment - // flow funnels into the identical authorize → callback exchange. - res.setHeader('Set-Cookie', [ - `oidc_state=${state}; HttpOnly; Secure; SameSite=Lax; Max-Age=1800; Path=/`, - `oidc_cv=${codeVerifier}; HttpOnly; Secure; SameSite=Lax; Max-Age=1800; Path=/`, - ]) - res.redirect(enrollUrl) - } catch (error) { - console.error('❌ OIDC signup error', { requestId }, error) - res.status(500).json({ error: 'Failed to initiate sign-up' }) - } -}) - -// Rate limit for the password endpoint: the flow-executor login is a -// credential-stuffing surface, so cap FAILED attempts per client before we -// ever contact Authentik (same express-rate-limit convention as -// tokenAuthRateLimiter). Successful sign-ins are never throttled. -const passwordLoginRateLimiter = rateLimit({ - windowMs: 5 * 60_000, - limit: 10, - // Count ONLY rejected credentials (401) against the budget: 503s from an - // Authentik outage or an MFA-required account must not lock users out. - skipSuccessfulRequests: true, - requestWasSuccessful: (_req, res) => res.statusCode !== 401, - standardHeaders: true, - legacyHeaders: false, - message: { error: 'Too many sign-in attempts. Try again later.' }, -}) - -/** - * @swagger - * /api/auth/oidc/password: - * post: - * summary: Password sign-in against Authentik (no redirect) - * description: > - * Authenticates email+password by driving Authentik's flow-executor API - * server-side, then completes the OIDC code exchange with the resulting - * Authentik session. Authentik remains the sole identity authority; the - * response shape matches /api/auth/login so the frontend treats both - * identically. - * tags: [Authentication] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [email, password] - * properties: - * email: { type: string } - * password: { type: string } - * responses: - * 200: - * description: Authenticated — platform JWT + user - * 400: - * description: Missing email or password - * 401: - * description: Invalid credentials - * 503: - * description: OIDC not configured, Authentik unreachable, or the - * account requires a browser flow (MFA/consent) - */ -router.post('/oidc/password', passwordLoginRateLimiter, async (req, res) => { - const requestId = uuidv4().substring(0, 8) - const { email, password } = req.body || {} - - console.log('🔐 Authentik password login request', { - requestId, - hasEmail: !!email, - configured: oidcService.isConfigured?.(), - initialized: oidcService.isInitialized?.(), - }) - - if (!email || !password) { - return res.status(400).json({ error: 'Email and password required' }) - } - if (!oidcService.isConfigured()) { - return res.status(503).json({ - error: - 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.', - }) - } - - try { - // Lazy re-init mirrors the monolith and /oidc/login: Authentik may not - // have been ready when this replica booted — self-heal here instead of - // 503ing until an SSO request happens to re-initialize the client. - if (!oidcService.isInitialized()) { - try { - // ensureInitialized() dedupes concurrent callers onto one in-flight - // attempt and fails fast during the post-failure cooldown, instead - // of firing a fresh discovery call per request. - await oidcService.ensureInitialized() - } catch (initErr) { - console.error('❌ OIDC lazy init failed', JSON.stringify({ requestId, message: (initErr as Error).message?.replace(/[\r\n]+/g, ' ') })) - return res - .status(503) - .json({ error: 'Authentication service unavailable. Try again shortly.' }) - } - } - - const user = await authentikPasswordLogin(email, password) - - // Session + JWT minting — identical to the local login / OIDC callback. - const sessionId = uuidv4() - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours - // This IS FuzeFront's identity service — the issuer of platform tokens - // (same mint as /login and the OIDC callback), not a product self-minting. - // nosemgrep: fuze-auth-self-minted-user-token, semgrep.fuze-auth-self-minted-user-token - const token = jwt.sign( - { userId: user.id, sessionId }, - process.env.JWT_SECRET!, - { expiresIn: '24h' } - ) - await db('sessions').insert({ - id: sessionId, - user_id: user.id, - expires_at: expiresAt, - }) - - selfHealProvisioningOnLogin(user.id) - - console.log('🎉 Authentik password login successful', { requestId, userId: user.id }) - return res.json({ token, user, sessionId }) - } catch (error) { - if (error instanceof InvalidCredentialsError) { - console.log('❌ Authentik rejected credentials', { requestId }) - return res.status(401).json({ error: 'Invalid credentials' }) - } - if (error instanceof UnsupportedFlowStageError) { - console.warn('⚠️ Unsupported Authentik flow stage', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') })) - return res.status(503).json({ - error: - 'This account requires a browser sign-in flow (e.g. MFA). Use the SSO button instead.', - }) - } - if (error instanceof AuthentikUnavailableError) { - console.error('❌ Authentik unavailable', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') })) - return res - .status(503) - .json({ error: 'Authentication service unavailable. Try again shortly.' }) - } - console.error('❌ Authentik password login error', { requestId }, error) - return res.status(500).json({ error: 'Authentication failed' }) - } -}) - -/** - * @swagger - * /api/auth/oidc/callback: - * get: - * summary: OIDC callback handler - * description: Handles the callback from Authentik after successful authentication - * tags: [Authentication] - * security: [] - * parameters: - * - in: query - * name: code - * required: true - * schema: - * type: string - * description: Authorization code from Authentik - * - in: query - * name: state - * required: true - * schema: - * type: string - * description: State parameter for CSRF protection - * responses: - * 302: - * description: Redirect to frontend with authentication token - * 400: - * description: Missing code or state parameter - * 500: - * description: Authentication failed - */ -router.get('/oidc/callback', async (req, res) => { - const requestId = uuidv4().substring(0, 8) - const { code, state, error } = req.query - - console.log(`🔄 [${requestId}] OIDC callback received:`, { - hasCode: !!code, - hasState: !!state, - error, - }) - - // Helper: clear the state + code_verifier cookies and redirect to the frontend - // with an error. Called on every failure path so the cookies don't remain - // valid for their 10-min Max-Age. - const clearState = (res: import('express').Response) => - res.setHeader('Set-Cookie', [ - 'oidc_state=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/', - 'oidc_cv=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/', - ]) - - try { - // CSRF guard: verify state cookie matches query param - const cookieHeader = req.headers.cookie || '' - const stateCookieMatch = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_state=')) - const cookieState = stateCookieMatch ? stateCookieMatch.slice('oidc_state='.length) : null - const queryState = (req.query.state as string) || '' - - if (!cookieState || cookieState.length !== queryState.length) { - clearState(res) - return res.redirect(`${FRONTEND_BASE}/?error=invalid_state`) - } - try { - if (!crypto.timingSafeEqual(Buffer.from(cookieState, 'utf8'), Buffer.from(queryState, 'utf8'))) { - clearState(res) - return res.redirect(`${FRONTEND_BASE}/?error=invalid_state`) - } - } catch (e) { - console.warn(`[${requestId}] State comparison error (fail-safe deny):`, e) - clearState(res) - return res.redirect(`${FRONTEND_BASE}/?error=invalid_state`) - } - // Read the PKCE code_verifier from its cookie (set at /oidc/login) before we - // clear the cookies on the success path. - const cvCookieMatch = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_cv=')) - const codeVerifier = cvCookieMatch ? cvCookieMatch.slice('oidc_cv='.length) : '' - - // Clear both cookies on the success path too - res.setHeader('Set-Cookie', [ - 'oidc_state=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/', - 'oidc_cv=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/', - ]) - - if (error) { - console.log(`❌ [${requestId}] OIDC error:`, error) - return res.redirect(`${FRONTEND_BASE}/?error=oidc_error&message=${encodeURIComponent(error as string)}`) - } - - if (!code || !state) { - console.log(`❌ [${requestId}] Missing code or state`) - return res.redirect(`${FRONTEND_BASE}/?error=missing_parameters`) - } - - if (!codeVerifier) { - console.log(`❌ [${requestId}] Missing oidc_cv cookie (PKCE verifier)`) - return res.redirect(`${FRONTEND_BASE}/?error=invalid_state`) - } - - // Handle the callback and get user (PKCE verifier from the cookie) - const user = await oidcService.handleCallback(code as string, state as string, codeVerifier) - console.log(`✅ [${requestId}] User authenticated via OIDC:`, user.email) - - // Create session id first so it can be embedded in the token - const sessionId = uuidv4() - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours - - // Generate JWT token (includes sessionId so logout can target this session) - const token = jwt.sign({ userId: user.id, sessionId }, process.env.JWT_SECRET!, { - expiresIn: '24h', - }) - - await db('sessions').insert({ - id: sessionId, - user_id: user.id, - expires_at: expiresAt, - }) - - console.log(`🎉 [${requestId}] OIDC login successful for:`, user.email) - - // Self-heal provisioning in the background (does not block the redirect). - selfHealProvisioningOnLogin(user.id) - - // Issue a short-lived opaque exchange code instead of putting the bearer token in the URL - // (avoids token leakage via referrer headers, server logs, and browser history). - const exchangeCode = crypto.randomBytes(32).toString('hex') - // Shared store: redeemable by BOTH /api/auth/token-exchange and the - // provider-agnostic /api/v1/security/session/exchange (which social login uses). - putBrokerCode(exchangeCode, { - token, - sessionId, - user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles }, - expiresAt: Date.now() + CODE_TTL_MS, - }) - res.redirect(`${FRONTEND_BASE}/?code=${exchangeCode}`) - - } catch (error) { - console.error(`❌ [${requestId}] OIDC callback error:`, error) - clearState(res) - res.redirect(`${FRONTEND_BASE}/?error=authentication_failed`) - } -}) - -/** - * @swagger - * /api/auth/method: - * get: - * summary: Get available authentication methods - * description: Returns which authentication methods are available - * tags: [Authentication] - * security: [] - * responses: - * 200: - * description: Available authentication methods - * content: - * application/json: - * schema: - * type: object - * properties: - * methods: - * type: array - * items: - * type: string - * example: ["local", "oidc"] - * oidcConfigured: - * type: boolean - * defaultMethod: - * type: string - */ -router.get('/method', (req, res) => { - const oidcConfigured = oidcService.isConfigured() - - const methods = ['local'] // Always support local auth - if (oidcConfigured) { - methods.push('oidc') - } - - res.json({ - methods, - oidcConfigured, - defaultMethod: oidcConfigured ? 'oidc' : 'local', - oidcLoginUrl: oidcConfigured ? '/api/auth/oidc/login' : null, - }) -}) - -/** - * @swagger - * /api/auth/token-exchange: - * post: - * summary: Exchange OIDC code for token - * description: Single-use, 60s TTL exchange of the opaque code issued by /oidc/callback for a JWT token and sessionId - * tags: [Authentication] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [code] - * properties: - * code: - * type: string - * responses: - * 200: - * description: Token and sessionId returned - * 400: - * description: code required - * 401: - * description: invalid or expired code - */ -router.post('/token-exchange', async (req, res) => { - const { code } = req.body - if (!code || typeof code !== 'string') { - return res.status(400).json({ error: 'code required' }) - } - const pending = takeBrokerCode(code) - if (!pending) { - return res.status(401).json({ error: 'invalid or expired code' }) - } - return res.json({ token: pending.token, sessionId: pending.sessionId }) -}) - -export default router +import { currentTenant, currentTenantOrUndefined } from '../providers/authentik/tenants' +import crypto from 'crypto' +import express from 'express' +import rateLimit from 'express-rate-limit' +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import { v4 as uuidv4 } from 'uuid' +import { db } from '../config/database' +import { authenticateToken } from '../middleware/auth' +import { User } from '../types/shared' +import { getOidcService } from '../services/oidc' +import { + authentikPasswordLogin, + InvalidCredentialsError, + AuthentikUnavailableError, + UnsupportedFlowStageError, +} from '../services/authentikPassword' +import { runInternalProvision } from '../services/organizationProvisioning' +import { putBrokerCode, takeBrokerCode, sweepBrokerCodes } from '../services/brokerCodes' + + +const CODE_TTL_MS = 60_000 + +// Use the configured frontend base URL for all redirects so that exchange codes +// ride HTTPS in production rather than a hardcoded http:// origin. +const FRONTEND_BASE = (process.env.FRONTEND_URL || 'http://fuzefront.dev.local').replace(/\/$/, '') + +// Periodic sweep: remove never-redeemed codes that have passed their TTL. +// .unref() prevents this interval from keeping the process alive in tests. +setInterval(() => sweepBrokerCodes(), CODE_TTL_MS).unref() + +const router = express.Router() + +/** + * Self-heal provisioning on login: ensure the user has a personal org and that + * every org they own which isn't `active` gets reconciled. Fire-and-forget — + * this must never block or fail the login response. Acts as the safety net when + * the identity.user.created Kafka event was lost. + */ +function selfHealProvisioningOnLogin(userId: string): void { + runInternalProvision(userId).catch(err => { + console.error(`Login self-heal provisioning failed for ${userId}:`, err) + }) +} + +/** + * @swagger + * /api/auth/login: + * post: + * summary: User login + * description: Authenticate user with email and password, returns JWT token + * tags: [Authentication] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LoginRequest' + * example: + * email: "admin@frontfuse.dev" + * password: "admin123" + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/LoginResponse' + * - type: object + * properties: + * sessionId: + * type: string + * format: uuid + * description: Session identifier + * example: + * token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * user: + * id: "550e8400-e29b-41d4-a716-446655440000" + * email: "admin@frontfuse.dev" + * firstName: "Admin" + * lastName: "User" + * roles: ["admin", "user"] + * sessionId: "123e4567-e89b-12d3-a456-426614174000" + * 400: + * description: Missing email or password + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Email and password required" + * 401: + * description: Invalid credentials + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Invalid credentials" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// POST /auth/login - Mock login +router.post('/login', async (req, res) => { + const requestId = uuidv4().substring(0, 8) + const startTime = Date.now() + + console.log(`🔐 [${requestId}] Login request received:`, { + timestamp: new Date().toISOString(), + ip: req.ip || req.connection.remoteAddress, + userAgent: req.get('User-Agent'), + origin: req.get('Origin'), + referer: req.get('Referer'), + contentType: req.get('Content-Type'), + bodyKeys: Object.keys(req.body || {}), + hasEmail: !!req.body?.email, + hasPassword: !!req.body?.password, + emailDomain: req.body?.email ? req.body.email.split('@')[1] : 'none', + }) + + try { + const { email, password } = req.body + + if (!email || !password) { + console.log(`❌ [${requestId}] Missing credentials:`, { + hasEmail: !!email, + hasPassword: !!password, + responseTime: Date.now() - startTime, + }) + return res.status(400).json({ error: 'Email and password required' }) + } + + console.log(`🔍 [${requestId}] Looking up user:`, { + email, + passwordLength: password.length, + }) + + // Find user + const userRow = await db('users').where('email', email).first() + + if (!userRow) { + console.log(`❌ [${requestId}] User not found:`, { + email, + responseTime: Date.now() - startTime, + }) + return res.status(401).json({ error: 'Invalid credentials' }) + } + + console.log(`👤 [${requestId}] User found:`, { + userId: userRow.id, + email: userRow.email, + hasPasswordHash: !!userRow.password_hash, + roles: userRow.roles, + }) + + // Verify password + console.log(`🔒 [${requestId}] Verifying password...`) + const isValidPassword = await bcrypt.compare( + password, + userRow.password_hash + ) + + if (!isValidPassword) { + console.log(`❌ [${requestId}] Invalid password:`, { + email, + responseTime: Date.now() - startTime, + }) + return res.status(401).json({ error: 'Invalid credentials' }) + } + + console.log(`✅ [${requestId}] Password verified, generating token...`) + + // Create the session id first so it can be embedded in the token; this lets + // logout invalidate only THIS session rather than all of the user's sessions. + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + + // Generate JWT + const token = jwt.sign( + { userId: userRow.id, sessionId }, + process.env.JWT_SECRET!, + { expiresIn: '24h' } + ) + + console.log(`🎫 [${requestId}] JWT token generated:`, { + tokenLength: token.length, + tokenPreview: token.substring(0, 20) + '...', + }) + + console.log(`💾 [${requestId}] Creating session:`, { + sessionId, + expiresAt: expiresAt.toISOString(), + }) + + await db('sessions').insert({ + id: sessionId, + user_id: userRow.id, + expires_at: expiresAt, + }) + + // Debug logging for roles parsing + console.log(`🔍 [${requestId}] Parsing roles:`, { + rawRoles: userRow.roles, + rolesType: typeof userRow.roles, + rolesLength: userRow.roles?.length, + firstChar: userRow.roles?.[0], + fallback: '["user"]', + }) + + const user: User = { + id: userRow.id, + email: userRow.email, + firstName: userRow.first_name, + lastName: userRow.last_name, + defaultAppId: userRow.default_app_id, + roles: Array.isArray(userRow.roles) + ? userRow.roles + : JSON.parse(userRow.roles || '["user"]'), + } + + console.log(`🎉 [${requestId}] Login successful:`, { + userId: user.id, + email: user.email, + roles: user.roles, + sessionId, + responseTime: Date.now() - startTime, + }) + + // Self-heal provisioning in the background (does not block the response). + selfHealProvisioningOnLogin(user.id) + + res.json({ + token, + user, + sessionId, + }) + } catch (error) { + console.error(`💥 [${requestId}] Login error:`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + responseTime: Date.now() - startTime, + }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +/** + * @swagger + * /api/auth/user: + * get: + * summary: Get current user + * description: Get information about the currently authenticated user + * tags: [Authentication] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Current user information + * content: + * application/json: + * schema: + * type: object + * properties: + * user: + * $ref: '#/components/schemas/User' + * example: + * user: + * id: "550e8400-e29b-41d4-a716-446655440000" + * email: "admin@frontfuse.dev" + * firstName: "Admin" + * lastName: "User" + * roles: ["admin", "user"] + * 401: + * description: Access token required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Invalid token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// GET /auth/user - Get current user +router.get('/user', authenticateToken, async (req, res) => { + res.json({ user: req.user }) +}) + +/** + * @swagger + * /api/auth/logout: + * post: + * summary: User logout + * description: Logout the current user and invalidate their session + * tags: [Authentication] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Logout successful + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "Logged out successfully" + * 500: + * description: Logout failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// POST /auth/logout +router.post('/logout', authenticateToken, async (req: any, res) => { + try { + const authHeader = req.headers['authorization'] + const token = authHeader && authHeader.split(' ')[1] + + if (token) { + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { + userId: string + sessionId?: string + } + // Invalidate only the current session, not every session the user has. + if (decoded.sessionId) { + await db('sessions').where('id', decoded.sessionId).del() + } + } + + res.json({ message: 'Logged out successfully' }) + } catch (error) { + res.status(500).json({ error: 'Logout failed' }) + } +}) + +/** + * @swagger + * /api/auth/oidc/login: + * get: + * summary: Initiate OIDC login + * description: Redirects to Authentik for OIDC authentication + * tags: [Authentication] + * security: [] + * responses: + * 302: + * description: Redirect to Authentik login page + * 500: + * description: OIDC not configured or server error + */ +router.get('/oidc/login', async (req, res) => { + const requestId = uuidv4().substring(0, 8) + // Structured trace: enough to diagnose a broken handoff from pod logs alone + // (misconfigured issuer/redirect/frontend-base, or an uninitialized client + // whose discovery against Authentik failed at boot). + console.log('🔐 OIDC login request received', { + requestId, + referer: req.get('Referer'), + configured: getOidcService().isConfigured?.(), + initialized: getOidcService().isInitialized?.(), + issuerUrl: currentTenantOrUndefined()?.issuerUrl, + redirectUri: currentTenantOrUndefined()?.redirectUri, + frontendBase: FRONTEND_BASE, + }) + + try { + if (!getOidcService().isConfigured()) { + console.log(`❌ [${requestId}] OIDC not configured`) + return res.status(500).json({ + error: 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.' + }) + } + + if (!getOidcService().isInitialized()) { + await getOidcService().ensureInitialized() + } + + const state = uuidv4() + const { url, codeVerifier } = getOidcService().generateAuthUrl(state) + + console.log(`🔗 [${requestId}] Redirecting to Authentik:`, url) + // Persist BOTH the CSRF state and the PKCE code_verifier in HttpOnly cookies + // so the callback is replica-agnostic (the service runs >1 replica; the + // callback frequently lands on a different pod than /oidc/login). + res.setHeader('Set-Cookie', [ + `oidc_state=${state}; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`, + `oidc_cv=${codeVerifier}; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`, + ]) + res.redirect(url) + } catch (error) { + console.error(`❌ [${requestId}] OIDC login error:`, error) + res.status(500).json({ error: 'Failed to initiate OIDC login' }) + } +}) + +/** + * @swagger + * /api/auth/oidc/signup: + * get: + * summary: Initiate account sign-up via Authentik enrollment + * description: > + * Redirects to Authentik's enrollment flow with the OIDC authorize URL + * as the flow's ?next= target, so a freshly-enrolled (and auto-logged-in) + * user continues straight through the normal OIDC callback and lands in + * the app with a session — no second sign-in step. + * tags: [Authentication] + * security: [] + * responses: + * 302: + * description: Redirect to the Authentik enrollment flow + * 500: + * description: OIDC not configured or server error + */ +// Unauthenticated redirect endpoint — cheap, but cap per-client abuse anyway +// (URL-minting/log-noise). Generous: legitimate users click this once or twice. +const signupRedirectRateLimiter = rateLimit({ + windowMs: 5 * 60_000, + limit: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many sign-up attempts. Try again later.' }, +}) + +router.get('/oidc/signup', signupRedirectRateLimiter, async (req, res) => { + const requestId = uuidv4().substring(0, 8) + try { + if (!getOidcService().isConfigured()) { + console.log('❌ OIDC not configured (signup)', { requestId }) + return res.status(500).json({ + error: 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.', + }) + } + + if (!getOidcService().isInitialized()) { + await getOidcService().ensureInitialized() + } + + const state = uuidv4() + const { url, codeVerifier } = getOidcService().generateAuthUrl(state) + // Wrap the authorize URL (same Authentik origin) in the enrollment flow's + // ?next= — Authentik redirects there after the flow's user-login stage. + const authorize = new URL(url) + const enrollSlug = + currentTenant('enrollmentFlowSlug').enrollmentFlowSlug + const enrollUrl = `${authorize.origin}/if/flow/${encodeURIComponent(enrollSlug)}/?next=${encodeURIComponent(`${authorize.pathname}${authorize.search}`)}` + + console.log('🔗 Redirecting to Authentik enrollment', { requestId, enrollUrl }) + // Same replica-agnostic state/PKCE cookies as /oidc/login — the enrollment + // flow funnels into the identical authorize → callback exchange. + res.setHeader('Set-Cookie', [ + `oidc_state=${state}; HttpOnly; Secure; SameSite=Lax; Max-Age=1800; Path=/`, + `oidc_cv=${codeVerifier}; HttpOnly; Secure; SameSite=Lax; Max-Age=1800; Path=/`, + ]) + res.redirect(enrollUrl) + } catch (error) { + console.error('❌ OIDC signup error', { requestId }, error) + res.status(500).json({ error: 'Failed to initiate sign-up' }) + } +}) + +// Rate limit for the password endpoint: the flow-executor login is a +// credential-stuffing surface, so cap FAILED attempts per client before we +// ever contact Authentik (same express-rate-limit convention as +// tokenAuthRateLimiter). Successful sign-ins are never throttled. +const passwordLoginRateLimiter = rateLimit({ + windowMs: 5 * 60_000, + limit: 10, + // Count ONLY rejected credentials (401) against the budget: 503s from an + // Authentik outage or an MFA-required account must not lock users out. + skipSuccessfulRequests: true, + requestWasSuccessful: (_req, res) => res.statusCode !== 401, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many sign-in attempts. Try again later.' }, +}) + +/** + * @swagger + * /api/auth/oidc/password: + * post: + * summary: Password sign-in against Authentik (no redirect) + * description: > + * Authenticates email+password by driving Authentik's flow-executor API + * server-side, then completes the OIDC code exchange with the resulting + * Authentik session. Authentik remains the sole identity authority; the + * response shape matches /api/auth/login so the frontend treats both + * identically. + * tags: [Authentication] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string } + * password: { type: string } + * responses: + * 200: + * description: Authenticated — platform JWT + user + * 400: + * description: Missing email or password + * 401: + * description: Invalid credentials + * 503: + * description: OIDC not configured, Authentik unreachable, or the + * account requires a browser flow (MFA/consent) + */ +router.post('/oidc/password', passwordLoginRateLimiter, async (req, res) => { + const requestId = uuidv4().substring(0, 8) + const { email, password } = req.body || {} + + console.log('🔐 Authentik password login request', { + requestId, + hasEmail: !!email, + configured: getOidcService().isConfigured?.(), + initialized: getOidcService().isInitialized?.(), + }) + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password required' }) + } + if (!getOidcService().isConfigured()) { + return res.status(503).json({ + error: + 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.', + }) + } + + try { + // Lazy re-init mirrors the monolith and /oidc/login: Authentik may not + // have been ready when this replica booted — self-heal here instead of + // 503ing until an SSO request happens to re-initialize the client. + if (!getOidcService().isInitialized()) { + try { + // ensureInitialized() dedupes concurrent callers onto one in-flight + // attempt and fails fast during the post-failure cooldown, instead + // of firing a fresh discovery call per request. + await getOidcService().ensureInitialized() + } catch (initErr) { + console.error('❌ OIDC lazy init failed', JSON.stringify({ requestId, message: (initErr as Error).message?.replace(/[\r\n]+/g, ' ') })) + return res + .status(503) + .json({ error: 'Authentication service unavailable. Try again shortly.' }) + } + } + + const user = await authentikPasswordLogin(email, password) + + // Session + JWT minting — identical to the local login / OIDC callback. + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + // This IS FuzeFront's identity service — the issuer of platform tokens + // (same mint as /login and the OIDC callback), not a product self-minting. + // nosemgrep: fuze-auth-self-minted-user-token, semgrep.fuze-auth-self-minted-user-token + const token = jwt.sign( + { userId: user.id, sessionId }, + process.env.JWT_SECRET!, + { expiresIn: '24h' } + ) + await db('sessions').insert({ + id: sessionId, + user_id: user.id, + expires_at: expiresAt, + }) + + selfHealProvisioningOnLogin(user.id) + + console.log('🎉 Authentik password login successful', { requestId, userId: user.id }) + return res.json({ token, user, sessionId }) + } catch (error) { + if (error instanceof InvalidCredentialsError) { + console.log('❌ Authentik rejected credentials', { requestId }) + return res.status(401).json({ error: 'Invalid credentials' }) + } + if (error instanceof UnsupportedFlowStageError) { + console.warn('⚠️ Unsupported Authentik flow stage', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') })) + return res.status(503).json({ + error: + 'This account requires a browser sign-in flow (e.g. MFA). Use the SSO button instead.', + }) + } + if (error instanceof AuthentikUnavailableError) { + console.error('❌ Authentik unavailable', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') })) + return res + .status(503) + .json({ error: 'Authentication service unavailable. Try again shortly.' }) + } + console.error('❌ Authentik password login error', { requestId }, error) + return res.status(500).json({ error: 'Authentication failed' }) + } +}) + +/** + * @swagger + * /api/auth/oidc/callback: + * get: + * summary: OIDC callback handler + * description: Handles the callback from Authentik after successful authentication + * tags: [Authentication] + * security: [] + * parameters: + * - in: query + * name: code + * required: true + * schema: + * type: string + * description: Authorization code from Authentik + * - in: query + * name: state + * required: true + * schema: + * type: string + * description: State parameter for CSRF protection + * responses: + * 302: + * description: Redirect to frontend with authentication token + * 400: + * description: Missing code or state parameter + * 500: + * description: Authentication failed + */ +router.get('/oidc/callback', async (req, res) => { + const requestId = uuidv4().substring(0, 8) + const { code, state, error } = req.query + + console.log(`🔄 [${requestId}] OIDC callback received:`, { + hasCode: !!code, + hasState: !!state, + error, + }) + + // Helper: clear the state + code_verifier cookies and redirect to the frontend + // with an error. Called on every failure path so the cookies don't remain + // valid for their 10-min Max-Age. + const clearState = (res: import('express').Response) => + res.setHeader('Set-Cookie', [ + 'oidc_state=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/', + 'oidc_cv=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/', + ]) + + try { + // CSRF guard: verify state cookie matches query param + const cookieHeader = req.headers.cookie || '' + const stateCookieMatch = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_state=')) + const cookieState = stateCookieMatch ? stateCookieMatch.slice('oidc_state='.length) : null + const queryState = (req.query.state as string) || '' + + if (!cookieState || cookieState.length !== queryState.length) { + clearState(res) + return res.redirect(`${FRONTEND_BASE}/?error=invalid_state`) + } + try { + if (!crypto.timingSafeEqual(Buffer.from(cookieState, 'utf8'), Buffer.from(queryState, 'utf8'))) { + clearState(res) + return res.redirect(`${FRONTEND_BASE}/?error=invalid_state`) + } + } catch (e) { + console.warn(`[${requestId}] State comparison error (fail-safe deny):`, e) + clearState(res) + return res.redirect(`${FRONTEND_BASE}/?error=invalid_state`) + } + // Read the PKCE code_verifier from its cookie (set at /oidc/login) before we + // clear the cookies on the success path. + const cvCookieMatch = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_cv=')) + const codeVerifier = cvCookieMatch ? cvCookieMatch.slice('oidc_cv='.length) : '' + + // Clear both cookies on the success path too + res.setHeader('Set-Cookie', [ + 'oidc_state=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/', + 'oidc_cv=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/', + ]) + + if (error) { + console.log(`❌ [${requestId}] OIDC error:`, error) + return res.redirect(`${FRONTEND_BASE}/?error=oidc_error&message=${encodeURIComponent(error as string)}`) + } + + if (!code || !state) { + console.log(`❌ [${requestId}] Missing code or state`) + return res.redirect(`${FRONTEND_BASE}/?error=missing_parameters`) + } + + if (!codeVerifier) { + console.log(`❌ [${requestId}] Missing oidc_cv cookie (PKCE verifier)`) + return res.redirect(`${FRONTEND_BASE}/?error=invalid_state`) + } + + // Handle the callback and get user (PKCE verifier from the cookie) + const user = await getOidcService().handleCallback(code as string, state as string, codeVerifier) + console.log(`✅ [${requestId}] User authenticated via OIDC:`, user.email) + + // Create session id first so it can be embedded in the token + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + + // Generate JWT token (includes sessionId so logout can target this session) + const token = jwt.sign({ userId: user.id, sessionId }, process.env.JWT_SECRET!, { + expiresIn: '24h', + }) + + await db('sessions').insert({ + id: sessionId, + user_id: user.id, + expires_at: expiresAt, + }) + + console.log(`🎉 [${requestId}] OIDC login successful for:`, user.email) + + // Self-heal provisioning in the background (does not block the redirect). + selfHealProvisioningOnLogin(user.id) + + // Issue a short-lived opaque exchange code instead of putting the bearer token in the URL + // (avoids token leakage via referrer headers, server logs, and browser history). + const exchangeCode = crypto.randomBytes(32).toString('hex') + // Shared store: redeemable by BOTH /api/auth/token-exchange and the + // provider-agnostic /api/v1/security/session/exchange (which social login uses). + putBrokerCode(exchangeCode, { + token, + sessionId, + user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles }, + expiresAt: Date.now() + CODE_TTL_MS, + }) + res.redirect(`${FRONTEND_BASE}/?code=${exchangeCode}`) + + } catch (error) { + console.error(`❌ [${requestId}] OIDC callback error:`, error) + clearState(res) + res.redirect(`${FRONTEND_BASE}/?error=authentication_failed`) + } +}) + +/** + * @swagger + * /api/auth/method: + * get: + * summary: Get available authentication methods + * description: Returns which authentication methods are available + * tags: [Authentication] + * security: [] + * responses: + * 200: + * description: Available authentication methods + * content: + * application/json: + * schema: + * type: object + * properties: + * methods: + * type: array + * items: + * type: string + * example: ["local", "oidc"] + * oidcConfigured: + * type: boolean + * defaultMethod: + * type: string + */ +router.get('/method', (req, res) => { + const oidcConfigured = getOidcService().isConfigured() + + const methods = ['local'] // Always support local auth + if (oidcConfigured) { + methods.push('oidc') + } + + res.json({ + methods, + oidcConfigured, + defaultMethod: oidcConfigured ? 'oidc' : 'local', + oidcLoginUrl: oidcConfigured ? '/api/auth/oidc/login' : null, + }) +}) + +/** + * @swagger + * /api/auth/token-exchange: + * post: + * summary: Exchange OIDC code for token + * description: Single-use, 60s TTL exchange of the opaque code issued by /oidc/callback for a JWT token and sessionId + * tags: [Authentication] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [code] + * properties: + * code: + * type: string + * responses: + * 200: + * description: Token and sessionId returned + * 400: + * description: code required + * 401: + * description: invalid or expired code + */ +router.post('/token-exchange', async (req, res) => { + const { code } = req.body + if (!code || typeof code !== 'string') { + return res.status(400).json({ error: 'code required' }) + } + const pending = takeBrokerCode(code) + if (!pending) { + return res.status(401).json({ error: 'invalid or expired code' }) + } + return res.json({ token: pending.token, sessionId: pending.sessionId }) +}) + +export default router diff --git a/backend/security/src/routes/invitations.ts b/backend/security/src/routes/invitations.ts index eee076a3..02df8403 100644 --- a/backend/security/src/routes/invitations.ts +++ b/backend/security/src/routes/invitations.ts @@ -1,168 +1,169 @@ -/** - * Public token-based invitation routes. - * GET /api/invitations/:token — resolve (no auth required) - * POST /api/invitations/:token/accept — accept (auth optional) - */ -import express from 'express' -import { v4 as uuidv4 } from 'uuid' -import { db } from '../config/database' -import { assignOrganizationRole } from '../utils/permit/role-assignment' - -const router = express.Router() - -/** - * Mask an email address for safe public exposure. - * Only the first character before '@' is preserved; the rest is replaced with '***'. - * Example: 'user@example.com' → 'u***@example.com' - */ -export function maskEmail(email: string): string { - const atIndex = email.indexOf('@') - if (atIndex <= 0) return '***' - return email[0] + '***' + email.slice(atIndex) -} - -// GET /api/invitations/:token — public, no auth -router.get('/:token', async (req: any, res) => { - try { - const { token } = req.params - - const invitation = await db('organization_invitations') - .where('token', token) - .first() - - if (!invitation) { - return res.status(404).json({ error: 'Invitation not found' }) - } - - // Expired by status or by time - if (invitation.status !== 'pending' || new Date(invitation.expires_at) < new Date()) { - return res.status(410).json({ error: 'This invitation has expired or been revoked' }) - } - - const organization = await db('organizations') - .where('id', invitation.organization_id) - .first() - - res.json({ - invitation: { - id: invitation.id, - email: maskEmail(invitation.email), - role: invitation.role, - expires_at: invitation.expires_at, - status: invitation.status, - }, - organization: { - id: organization?.id, - name: organization?.name, - slug: organization?.slug, - }, - }) - } catch (error: any) { - console.error('Error resolving invitation:', error) - res.status(500).json({ error: 'Failed to resolve invitation' }) - } -}) - -// POST /api/invitations/:token/accept — accept (auth optional via req.user) -router.post('/:token/accept', async (req: any, res) => { - try { - const { token } = req.params - - const invitation = await db('organization_invitations') - .where('token', token) - .first() - - if (!invitation) { - return res.status(404).json({ error: 'Invitation not found' }) - } - - // Not authenticated: direct to enroll - if (!req.user) { - const enrollUrl = `${process.env.AUTHENTIK_ISSUER_URL || ''}/if/flow/enrollment/` - return res.status(202).json({ - action: 'enroll', - enrollUrl, - message: 'Please create an account or sign in to accept this invitation', - }) - } - - // Email mismatch - if (req.user.email.toLowerCase() !== invitation.email.toLowerCase()) { - return res.status(403).json({ - error: 'This invitation was sent to a different email address', - }) - } - - // Revoked or expired (check before CAS so we give an informative 410, not a 409) - if (invitation.status === 'revoked' || new Date(invitation.expires_at) < new Date()) { - return res.status(410).json({ error: 'This invitation has expired or been revoked' }) - } - - // Atomic compare-and-swap accept: transition status pending→accepted inside - // the transaction. If another request raced us, rowCount will be 0 → 409. - let casSucceeded = false - await db.transaction(async (trx: any) => { - const result = await trx.raw( - `UPDATE organization_invitations SET status='accepted' WHERE id=? AND status='pending' RETURNING *`, - [invitation.id] - ) - const rowCount = result.rowCount ?? (result.rows ? result.rows.length : 0) - if (rowCount === 0) { - // Another request already accepted this invitation (race condition). - return - } - casSucceeded = true - - // Upsert membership (user may already be a member) - const existingMembership = await trx('organization_memberships') - .where('user_id', req.user.id) - .where('organization_id', invitation.organization_id) - .first() - - if (!existingMembership) { - await trx('organization_memberships').insert({ - id: uuidv4(), - user_id: req.user.id, - organization_id: invitation.organization_id, - role: invitation.role, - status: 'active', - joined_at: new Date(), - permissions: JSON.stringify({}), - metadata: JSON.stringify({}), - }) - } - }) - - if (!casSucceeded) { - return res.status(409).json({ error: 'Invitation has already been accepted' }) - } - - // Assign Permit role for the accepted member — non-blocking: a Permit outage - // must not undo an accepted invitation. The role can be reconciled later. - try { - await assignOrganizationRole( - req.user.id, - invitation.organization_id, - invitation.role as 'owner' | 'admin' | 'member' | 'viewer' - ) - } catch (permitErr) { - console.error( - `Permit role assignment failed for user ${req.user.id} in org ${invitation.organization_id} (non-fatal):`, - permitErr - ) - } - - res.json({ - message: 'Invitation accepted successfully', - organizationId: invitation.organization_id, - role: invitation.role, - }) - } catch (error: any) { - console.error('Error accepting invitation:', error) - res.status(500).json({ error: 'Failed to accept invitation' }) - } -}) - -export default router - - +/** + * Public token-based invitation routes. + * GET /api/invitations/:token — resolve (no auth required) + * POST /api/invitations/:token/accept — accept (auth optional) + */ +import { currentTenant } from '../providers/authentik/tenants' +import express from 'express' +import { v4 as uuidv4 } from 'uuid' +import { db } from '../config/database' +import { assignOrganizationRole } from '../utils/permit/role-assignment' + +const router = express.Router() + +/** + * Mask an email address for safe public exposure. + * Only the first character before '@' is preserved; the rest is replaced with '***'. + * Example: 'user@example.com' → 'u***@example.com' + */ +export function maskEmail(email: string): string { + const atIndex = email.indexOf('@') + if (atIndex <= 0) return '***' + return email[0] + '***' + email.slice(atIndex) +} + +// GET /api/invitations/:token — public, no auth +router.get('/:token', async (req: any, res) => { + try { + const { token } = req.params + + const invitation = await db('organization_invitations') + .where('token', token) + .first() + + if (!invitation) { + return res.status(404).json({ error: 'Invitation not found' }) + } + + // Expired by status or by time + if (invitation.status !== 'pending' || new Date(invitation.expires_at) < new Date()) { + return res.status(410).json({ error: 'This invitation has expired or been revoked' }) + } + + const organization = await db('organizations') + .where('id', invitation.organization_id) + .first() + + res.json({ + invitation: { + id: invitation.id, + email: maskEmail(invitation.email), + role: invitation.role, + expires_at: invitation.expires_at, + status: invitation.status, + }, + organization: { + id: organization?.id, + name: organization?.name, + slug: organization?.slug, + }, + }) + } catch (error: any) { + console.error('Error resolving invitation:', error) + res.status(500).json({ error: 'Failed to resolve invitation' }) + } +}) + +// POST /api/invitations/:token/accept — accept (auth optional via req.user) +router.post('/:token/accept', async (req: any, res) => { + try { + const { token } = req.params + + const invitation = await db('organization_invitations') + .where('token', token) + .first() + + if (!invitation) { + return res.status(404).json({ error: 'Invitation not found' }) + } + + // Not authenticated: direct to enroll + if (!req.user) { + const enrollUrl = `${currentTenant('invitation enrollment URL').issuerUrl}/if/flow/enrollment/` + return res.status(202).json({ + action: 'enroll', + enrollUrl, + message: 'Please create an account or sign in to accept this invitation', + }) + } + + // Email mismatch + if (req.user.email.toLowerCase() !== invitation.email.toLowerCase()) { + return res.status(403).json({ + error: 'This invitation was sent to a different email address', + }) + } + + // Revoked or expired (check before CAS so we give an informative 410, not a 409) + if (invitation.status === 'revoked' || new Date(invitation.expires_at) < new Date()) { + return res.status(410).json({ error: 'This invitation has expired or been revoked' }) + } + + // Atomic compare-and-swap accept: transition status pending→accepted inside + // the transaction. If another request raced us, rowCount will be 0 → 409. + let casSucceeded = false + await db.transaction(async (trx: any) => { + const result = await trx.raw( + `UPDATE organization_invitations SET status='accepted' WHERE id=? AND status='pending' RETURNING *`, + [invitation.id] + ) + const rowCount = result.rowCount ?? (result.rows ? result.rows.length : 0) + if (rowCount === 0) { + // Another request already accepted this invitation (race condition). + return + } + casSucceeded = true + + // Upsert membership (user may already be a member) + const existingMembership = await trx('organization_memberships') + .where('user_id', req.user.id) + .where('organization_id', invitation.organization_id) + .first() + + if (!existingMembership) { + await trx('organization_memberships').insert({ + id: uuidv4(), + user_id: req.user.id, + organization_id: invitation.organization_id, + role: invitation.role, + status: 'active', + joined_at: new Date(), + permissions: JSON.stringify({}), + metadata: JSON.stringify({}), + }) + } + }) + + if (!casSucceeded) { + return res.status(409).json({ error: 'Invitation has already been accepted' }) + } + + // Assign Permit role for the accepted member — non-blocking: a Permit outage + // must not undo an accepted invitation. The role can be reconciled later. + try { + await assignOrganizationRole( + req.user.id, + invitation.organization_id, + invitation.role as 'owner' | 'admin' | 'member' | 'viewer' + ) + } catch (permitErr) { + console.error( + `Permit role assignment failed for user ${req.user.id} in org ${invitation.organization_id} (non-fatal):`, + permitErr + ) + } + + res.json({ + message: 'Invitation accepted successfully', + organizationId: invitation.organization_id, + role: invitation.role, + }) + } catch (error: any) { + console.error('Error accepting invitation:', error) + res.status(500).json({ error: 'Failed to accept invitation' }) + } +}) + +export default router + + diff --git a/backend/security/src/services/authentikPassword.ts b/backend/security/src/services/authentikPassword.ts index f6162265..b49ed9ba 100644 --- a/backend/security/src/services/authentikPassword.ts +++ b/backend/security/src/services/authentikPassword.ts @@ -1,975 +1,980 @@ -/** - * Server-side Authentik password authentication — no browser redirect. - * - * The login page shows native email/password fields; this service drives - * Authentik's flow-executor JSON API with those credentials, then completes a - * standard OIDC authorization-code + PKCE exchange using the authenticated - * Authentik session. Authentik therefore remains the SOLE identity authority - * (same as the redirect flow), and everything downstream — user sync into the - * platform DB, session/JWT minting — reuses the existing OIDC machinery. - * - * Flow: - * 1. GET /api/v3/flows/executor//?query= → identification stage - * 2. POST { component, uid_field: email [, password] } - * 3. POST { component, password } (separate password stage) - * 4. challenge "xak-flow-redirect" → Authentik session established - * 5. GET the OIDC authorize URL with the session cookies (implicit consent) - * → 302 …/api/auth/oidc/callback?code=…&state=… - * 6. oidcService.handleCallback(code, state, codeVerifier) → synced User - * - * Only single-factor identification/password flows are supported. Users with - * MFA or other stages configured must use the browser (Google/SSO) path — we - * fail closed with a clear error rather than trying to drive arbitrary stages. - */ -import { generators } from 'openid-client' -import { oidcService } from './oidc' -import { User } from '../types/shared' -import { logger } from '../lib/logger' - -/** - * Hard per-fetch timeout (ms) for EVERY server-side Authentik HTTP hop driven in - * this module — the flow-executor requests, the OIDC authorize→code redirect - * chain, and the Admin-API set_password calls. Without it a single stuck hop - * (e.g. the authorize hairpin out to app.fuzefront.com via Cloudflare) hangs the - * whole login request forever, so the client only fails after its own ~60s - * timeout with no server log pointing at the culprit. A bounded AbortController - * turns that into a fast, labelled AuthentikUnavailableError instead. - * Overridable via AUTHENTIK_FLOW_TIMEOUT_MS without a rebuild. - */ -const AUTHENTIK_FLOW_TIMEOUT_MS = - Number(process.env.AUTHENTIK_FLOW_TIMEOUT_MS) || 10000 - -/** - * WHOLE-REQUEST budget (ms) for one server-brokered sign-in/sign-up. - * - * The per-hop cap above bounds each INDIVIDUAL fetch, but a login is a CHAIN: - * 2-3 flow-executor stages (each following up to 10 redirects) followed by the - * authorize→code chain (up to 10 more hops). Every hop used to get a fresh 10s - * budget, so the server's worst case ran to minutes while the browser gives the - * whole call only LOGIN_TIMEOUT_MS (15s — frontend/src/services/api.ts). The - * client therefore always aborted first, and the user got a bare - * "timeout of 15000ms exceeded" with no status and no message, while the - * labelled server-side diagnostics ("hop timed out", naming the exact stage) - * were still waiting to be produced and never reached anyone. - * - * Bounding the CHAIN — not just each link — is what makes the server answer - * first. This MUST stay below the client's bound so a stalled sign-in surfaces - * as a real, logged, labelled HTTP response instead of a blind client-side - * abort: 40s here against the client's 45s. The margin is for the response - * trip, so retune the pair together and never let this cross above it. - * - * Sized to the documented slow path (16-30s), not to what sign-in SHOULD cost - * — a budget below the real worst case rejects logins that would have - * succeeded. Tighten both once the underlying slow hop is fixed. - * Overridable via AUTHENTIK_LOGIN_DEADLINE_MS. - */ -const AUTHENTIK_LOGIN_DEADLINE_MS = - Number(process.env.AUTHENTIK_LOGIN_DEADLINE_MS) || 40000 - -/** - * A hop slower than this is reported at WARN, with its label, even when the - * login ultimately succeeds. - * - * Per-hop timings already existed — at `logger.debug`. LOG_LEVEL defaults to - * `info` and is not set anywhere in the chart, so in production that detail has - * always been switched OFF, and answering "which hop is slow?" required either - * a config change or a redeploy. That is why this path has accumulated timeout - * band-aids (#362, #371) instead of a diagnosis: the evidence was never in the - * logs when the incident happened. - * - * A slow-hop threshold is the standard fix (a slow-query log): silent on the - * fast path, fully detailed exactly when something is wrong — no LOG_LEVEL - * change, no redeploy, no spam. Overridable via AUTHENTIK_SLOW_HOP_WARN_MS. - */ -const AUTHENTIK_SLOW_HOP_WARN_MS = - Number(process.env.AUTHENTIK_SLOW_HOP_WARN_MS) || 1000 - -/** - * A whole sign-in slower than this is reported at WARN even though it - * SUCCEEDED. Set above the ~5.5s fast path, well below the client bound — the - * gap between them is precisely the band that was silently eating sign-in. - */ -const AUTHENTIK_LOGIN_WARN_MS = - Number(process.env.AUTHENTIK_LOGIN_WARN_MS) || 8000 - -/** Monotonic-ish elapsed helper for the per-step timing logs. */ -function since(startMs: number): number { - return Math.round(Date.now() - startMs) -} - -/** - * Record one hop's cost. Always available at debug; promoted to warn when the - * hop is slow enough to be the thing worth looking at. - * - * Leading hypothesis for what this will show, stated so the logs can refute it: - * these hops all target the same in-cluster origin, and this pod's own - * `dnsConfig` (deploy/helm/.../security.yaml) documents CoreDNS "intermittently - * stalls lookups in 5s/10s retry multiples", capped to ~2s by timeout:1 / - * attempts:2 — with the note that this service "resolves authentik-server on - * every auth flow". A DNS lookup happens per NEW CONNECTION, and the leaked - * response bodies (see drainBody) forced a new connection per hop, so the stall - * was multiplied by hop count: ~6 hops x ~2s is most of the observed 16-30s. - * If that is right, draining bodies lets undici reuse one keep-alive socket per - * origin and the stalls collapse to at most one. `elapsedMs` per labelled hop - * is what confirms or kills that; if connect/DNS time still dominates, the next - * step is an explicit keep-alive dispatcher pinned to the Authentik origin. - */ -function recordHop( - label: string, - elapsedMs: number, - fields: Record = {} -): void { - const entry = { label, elapsedMs, ...fields } - if (elapsedMs >= AUTHENTIK_SLOW_HOP_WARN_MS) { - logger.warn(entry, 'authentikPassword: SLOW hop') - return - } - logger.debug(entry, 'authentikPassword: hop') -} - -/** - * A wall-clock budget shared by every hop of ONE login/signup attempt. - * - * Each hop asks for `hopBudget()`, which is the smaller of the per-hop cap and - * whatever is actually left — so the chain can never outlive the whole-request - * deadline no matter how many redirects Authentik asks us to follow. - */ -class Deadline { - private readonly expiresAt: number - private readonly budgetMs: number - - constructor(budgetMs: number = AUTHENTIK_LOGIN_DEADLINE_MS) { - this.budgetMs = budgetMs - this.expiresAt = Date.now() + budgetMs - } - - remainingMs(): number { - return this.expiresAt - Date.now() - } - - /** Per-hop allowance: never more than the cap, never more than what's left. */ - hopBudget(cap: number = AUTHENTIK_FLOW_TIMEOUT_MS): number { - return Math.min(cap, this.remainingMs()) - } - - /** - * Throw a labelled error if the whole-request budget is already spent, so the - * chain stops at a named stage instead of starting a hop it cannot finish. - */ - assertLive(label: string): void { - if (this.remainingMs() > 0) return - logger.error( - { label, budgetMs: this.budgetMs }, - 'authentikPassword: login deadline exceeded' - ) - throw new AuthentikUnavailableError( - `sign-in exceeded its ${this.budgetMs}ms budget before ${label}` - ) - } -} - -/** - * Release the connection behind a response whose body we are going to discard. - * - * undici (Node's fetch) keeps the underlying socket checked out until the body - * is consumed or cancelled. Every redirect hop below reads only `location` and - * moves on, so without this the socket for each hop stayed pinned for the rest - * of the request — and subsequent hops to the SAME origin queued behind the - * leaked ones. That is exactly the shape of the intermittent multi-second - * stalls this module keeps getting timeout band-aids for: not one slow hop, but - * hops waiting on connections their own predecessors never gave back. - */ -function drainBody(res: Response): void { - // `.cancel()` rejects if the body is already disturbed/locked; either way the - // connection is no longer ours to hold, so the outcome is not interesting. - void res.body?.cancel().catch(() => undefined) -} - -/** - * `fetch` with a hard AbortController deadline. On timeout the AbortError is - * normalised to a labelled AuthentikUnavailableError carrying the elapsed time - * and the target, so prod logs pinpoint exactly which hop stalled. - * - * Pass a `Deadline` for anything on the login/signup chain so the hop's - * allowance is clamped to the whole-request budget rather than getting a fresh - * full-length one. - */ -async function fetchWithTimeout( - url: string, - init: RequestInit, - label: string, - deadline?: Deadline, - cap: number = AUTHENTIK_FLOW_TIMEOUT_MS -): Promise { - if (deadline) deadline.assertLive(label) - const timeoutMs = deadline ? deadline.hopBudget(cap) : cap - const controller = new AbortController() - const started = Date.now() - const timer = setTimeout(() => controller.abort(), timeoutMs) - try { - return await fetch(url, { ...init, signal: controller.signal }) - } catch (err) { - const e = err as Error - if (e.name === 'AbortError') { - logger.error( - { label, elapsedMs: since(started), timeoutMs, url }, - 'authentikPassword: hop timed out' - ) - throw new AuthentikUnavailableError( - `${label} timed out after ${timeoutMs}ms` - ) - } - throw err - } finally { - clearTimeout(timer) - } -} - -export class InvalidCredentialsError extends Error { - constructor(message = 'Invalid credentials') { - super(message) - this.name = 'InvalidCredentialsError' - } -} - -export class AuthentikUnavailableError extends Error { - constructor(message = 'Authentication service unavailable') { - super(message) - this.name = 'AuthentikUnavailableError' - } -} - -export class UnsupportedFlowStageError extends Error { - constructor(stage: string) { - super(`Unsupported Authentik flow stage: ${stage} (only identification+password is supported server-side)`) - this.name = 'UnsupportedFlowStageError' - } -} - -/** Minimal cookie jar for the short-lived per-login Authentik session. */ -export class CookieJar { - private cookies = new Map() - - absorb(res: { headers: Headers }): void { - // Node >=18.14 exposes getSetCookie(); fall back to the single-value get() - // (sufficient in practice — Authentik sets one cookie per response hop). - const anyHeaders = res.headers as Headers & { getSetCookie?: () => string[] } - const setCookies: string[] = - typeof anyHeaders.getSetCookie === 'function' - ? anyHeaders.getSetCookie() - : ([res.headers.get('set-cookie')].filter(Boolean) as string[]) - for (const sc of setCookies) { - const pair = sc.split(';')[0] - const eq = pair.indexOf('=') - if (eq <= 0) continue - const name = pair.slice(0, eq).trim() - const value = pair.slice(eq + 1).trim() - if (value === '' || /max-age=0|expires=thu, 01 jan 1970/i.test(sc)) { - this.cookies.delete(name) - } else { - this.cookies.set(name, value) - } - } - } - - header(): string { - return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') - } - - get(name: string): string | undefined { - return this.cookies.get(name) - } -} - -export function authentikBaseUrl(): string { - if (process.env.AUTHENTIK_BASE_URL) { - return process.env.AUTHENTIK_BASE_URL.replace(/\/$/, '') - } - const issuer = - process.env.AUTHENTIK_ISSUER_URL || - 'http://localhost:9000/application/o/fuzefront/' - return new URL(issuer).origin -} - -function authFlowSlug(): string { - return process.env.AUTHENTIK_AUTH_FLOW_SLUG || 'default-authentication-flow' -} - -export function redirectUri(): string { - return ( - process.env.AUTHENTIK_REDIRECT_URI || - 'http://fuzefront.dev.local/api/auth/oidc/callback' - ) -} - -function enrollmentFlowSlug(): string { - return process.env.AUTHENTIK_ENROLLMENT_FLOW_SLUG || 'fuzefront-enrollment' -} - -export interface FlowChallenge { - component?: string - type?: string - to?: string - password_fields?: boolean - response_errors?: Record> - [key: string]: unknown -} - -export async function flowRequest( - base: string, - slug: string, - jar: CookieJar, - body?: Record, - deadline?: Deadline -): Promise { - // Authentik commonly answers the first executor request with a 302 that - // establishes the session cookie (Location points back into the flow), so - // follow same-origin redirects manually, carrying the jar. Per Django 302 - // semantics a redirected POST is retried as GET. - let url = `${base}/api/v3/flows/executor/${slug}/?query=` - let method: 'GET' | 'POST' = body ? 'POST' : 'GET' - let payload: string | undefined = body ? JSON.stringify(body) : undefined - - for (let hop = 0; hop < 10; hop++) { - const headers: Record = { - Accept: 'application/json', - // Django CSRF validates Referer on secure requests. - Referer: `${base}/`, - } - const cookie = jar.header() - if (cookie) headers['Cookie'] = cookie - if (method === 'POST') { - headers['Content-Type'] = 'application/json' - const csrf = jar.get('authentik_csrf') - if (csrf) headers['X-CSRFToken'] = csrf - } - - let res: Response - const stepStart = Date.now() - try { - res = await fetchWithTimeout( - url, - { method, headers, body: payload, redirect: 'manual' }, - `flow.step slug=${slug} hop=${hop} ${method}`, - deadline - ) - } catch (err) { - if (err instanceof AuthentikUnavailableError) throw err - throw new AuthentikUnavailableError( - `Authentik unreachable at ${base}: ${(err as Error).message}` - ) - } - recordHop(`flow.step slug=${slug} hop=${hop} ${method}`, since(stepStart), { - slug, - hop, - method, - status: res.status, - }) - jar.absorb(res) - - const loc = res.headers.get('location') - if ([301, 302, 303, 307, 308].includes(res.status) && loc) { - // Only `location` is wanted from a redirect — hand the socket back before - // issuing the next hop, or it stays checked out for the whole request and - // the following hops queue behind it (see drainBody). - drainBody(res) - const nextUrl = new URL(loc, url) - // The jar carries authentik_session/authentik_csrf — never present those - // cookies to any host other than Authentik itself. - if (nextUrl.origin !== new URL(base).origin) { - throw new AuthentikUnavailableError( - `Flow executor redirected off-origin to ${nextUrl.origin} — refusing to follow with session cookies` - ) - } - url = nextUrl.toString() - // 301/302/303 rewrite the retry as GET (Django semantics); 307/308 - // preserve the original method and body per HTTP spec. - if (res.status !== 307 && res.status !== 308) { - method = 'GET' - payload = undefined - } - continue - } - const contentTypeEarly = res.headers.get('content-type') || '' - if (!res.ok) { - // A 4xx with a JSON body is a FLOW response (e.g. 400 carrying - // response_errors for rejected credentials) — return it so the caller - // maps it to 401, instead of mislabeling it a 503 outage. - if (res.status < 500 && contentTypeEarly.includes('json')) { - return (await res.json()) as FlowChallenge - } - // Surface Authentik's own error payload — a bare status is undebuggable - // from CI logs (e.g. 403 CSRF vs 404 unknown flow slug). - const bodySnippet = (await res.text().catch(() => '')).slice(0, 300) - throw new AuthentikUnavailableError( - `Authentik flow executor HTTP ${res.status} at ${url}: ${bodySnippet}` - ) - } - if (!contentTypeEarly.includes('json')) { - const bodySnippet = (await res.text().catch(() => '')).slice(0, 300) - throw new AuthentikUnavailableError( - `Authentik flow executor returned non-JSON (${contentTypeEarly}) at ${url}: ${bodySnippet}` - ) - } - return (await res.json()) as FlowChallenge - } - throw new AuthentikUnavailableError('Authentik flow executor redirect loop') -} - -function challengeHasCredentialErrors(challenge: FlowChallenge): boolean { - const errs = challenge.response_errors - if (!errs) return false - return Object.keys(errs).length > 0 -} - -/** - * Authenticate email+password against Authentik and return the synced platform - * User. Throws InvalidCredentialsError / AuthentikUnavailableError / - * UnsupportedFlowStageError. - */ -export async function authentikPasswordLogin( - email: string, - password: string -): Promise { - const loginStart = Date.now() - logger.info({ email }, 'authentikPassword: login start') - try { - const user = await authentikPasswordLoginInner(email, password) - const elapsedMs = since(loginStart) - // A login that SUCCEEDS at 25s is the failure mode that broke sign-in: it - // never errors, so nothing alerts, and it only becomes visible once a - // client bound trips underneath it. Report a slow success as loudly as a - // slow hop — the per-hop WARNs above then say which stage owned the time. - if (elapsedMs >= AUTHENTIK_LOGIN_WARN_MS) { - logger.warn( - { email, elapsedMs, thresholdMs: AUTHENTIK_LOGIN_WARN_MS }, - 'authentikPassword: SLOW login (succeeded)' - ) - } else { - logger.info({ email, elapsedMs }, 'authentikPassword: login succeeded') - } - return user - } catch (err) { - logger.error( - { - email, - elapsedMs: since(loginStart), - errName: (err as Error).name, - err: (err as Error).message, - }, - 'authentikPassword: login failed' - ) - throw err - } -} - -async function authentikPasswordLoginInner( - email: string, - password: string -): Promise { - if (!oidcService.isConfigured()) { - throw new AuthentikUnavailableError('OIDC is not configured/initialized') - } - if (!oidcService.isInitialized()) { - // Lazy re-init: dedupes concurrent callers onto one in-flight attempt and - // fails fast during the post-failure cooldown (see oidc.ts). Preserves - // the original error type/message on failure. - try { - await oidcService.ensureInitialized() - } catch { - throw new AuthentikUnavailableError('OIDC is not configured/initialized') - } - } - - const base = authentikBaseUrl() - const slug = authFlowSlug() - const jar = new CookieJar() - // One budget for the WHOLE chain — flow stages AND the authorize hops below. - const deadline = new Deadline() - - // ── Drive the authentication flow ───────────────────────────────────────── - let challenge = await flowRequest(base, slug, jar, undefined, deadline) - const MAX_STEPS = 6 - let authenticated = false - - for (let step = 0; step < MAX_STEPS; step++) { - const component = challenge.component || challenge.type || '' - - if (component === 'xak-flow-redirect') { - authenticated = true - break - } - - if (component === 'ak-stage-identification') { - const body: Record = { - component, - uid_field: email, - } - // Combined identification+password stage - if (challenge.password_fields) body.password = password - challenge = await flowRequest(base, slug, jar, body, deadline) - } else if (component === 'ak-stage-password') { - challenge = await flowRequest( - base, - slug, - jar, - { component, password }, - deadline - ) - } else if (component === 'ak-stage-access-denied') { - throw new InvalidCredentialsError() - } else { - // MFA, consent, prompts, … — not driveable server-side. - throw new UnsupportedFlowStageError(component || 'unknown') - } - - if (challengeHasCredentialErrors(challenge)) { - throw new InvalidCredentialsError() - } - } - - if (!authenticated) { - const last = challenge.component || challenge.type || 'unknown' - if (last !== 'xak-flow-redirect') { - throw new UnsupportedFlowStageError(last) - } - } - - // Complete OIDC code+PKCE with the now-authenticated Authentik session, on - // whatever is LEFT of the login budget rather than a fresh one. - return completeOidcWithSession(base, jar, deadline) -} - -/** - * Drive the OIDC authorize→code exchange using an ALREADY-AUTHENTICATED - * Authentik session (the cookie jar). Shared by both server-side password login - * and server-side signup (enrollment auto-logs the new user in, establishing the - * same session). Token exchange + user sync is identical to the redirect - * callback path — Authentik stays the SOLE identity authority. - */ -/** - * Rewrite the (browser-facing, EXTERNAL) authorize URL onto the internal - * Authentik base — protocol+host only, path/query untouched — so this - * server-side hop stays in-cluster instead of hairpinning out through - * Cloudflare/ingress. Safe because: - * - `redirect_uri`/`state`/PKCE params are unchanged, so token validation - * (handleCallback) still matches. - * - Authentik's issuer_mode is `per_provider`, so `iss` is fixed to the - * external issuer regardless of request host (see oidc.ts). - * Measured impact: authorize.hop ~6.5s (external, via Cloudflare) -> ~0.2s - * (internal service DNS). `base` already resolves to AUTHENTIK_BASE_URL when - * set (see authentikBaseUrl()); this is a no-op when it is not. - */ -function toInternalAuthorizeUrl(externalUrl: string, base: string): string { - try { - const u = new URL(externalUrl) - const b = new URL(base) - u.protocol = b.protocol - u.host = b.host - return u.toString() - } catch { - return externalUrl - } -} - -export async function completeOidcWithSession( - base: string, - jar: CookieJar, - deadline?: Deadline -): Promise { - const state = generators.state() - const { url: authorizeUrl, codeVerifier } = oidcService.generateAuthUrl(state) - const target = redirectUri() - - let location = toInternalAuthorizeUrl(authorizeUrl, base) - let code: string | null = null - let returnedState: string | null = null - const oidcStart = Date.now() - - for (let hop = 0; hop < 10; hop++) { - let res: Response - const hopStart = Date.now() - try { - res = await fetchWithTimeout( - location, - { - method: 'GET', - headers: { Cookie: jar.header(), Accept: 'application/json' }, - redirect: 'manual', - }, - `authorize.hop hop=${hop}`, - deadline - ) - } catch (err) { - if (err instanceof AuthentikUnavailableError) throw err - throw new AuthentikUnavailableError( - `Authorize request failed: ${(err as Error).message}` - ) - } - recordHop(`authorize.hop hop=${hop}`, since(hopStart), { - hop, - status: res.status, - }) - jar.absorb(res) - // Nothing in this chain ever reads an authorize response BODY — only its - // status, cookies and `location`. Release the socket now so the next hop - // doesn't queue behind it (see drainBody). - drainBody(res) - - const next = res.headers.get('location') - if (!next) { - // 200 here means Authentik rendered a flow UI (consent / re-auth) — - // implicit consent is expected on the FuzeFront provider. - throw new UnsupportedFlowStageError( - `authorize returned HTTP ${res.status} without redirect (consent flow?)` - ) - } - const resolvedUrl = new URL(next, location) - const resolved = resolvedUrl.toString() - if (resolved.startsWith(target)) { - const u = new URL(resolved) - code = u.searchParams.get('code') - returnedState = u.searchParams.get('state') - const err = u.searchParams.get('error') - if (err) { - throw new AuthentikUnavailableError(`Authorize error: ${err}`) - } - break - } - // Continue only within Authentik's own origin — the jar must not follow - // an arbitrary redirect elsewhere. - if (resolvedUrl.origin !== new URL(base).origin) { - throw new AuthentikUnavailableError( - `Authorize flow redirected off-origin to ${resolvedUrl.origin} — refusing to follow with session cookies` - ) - } - location = resolved - } - - if (!code) { - throw new AuthentikUnavailableError( - 'Authorize flow did not produce an authorization code' - ) - } - - logger.info( - { elapsedMs: since(oidcStart) }, - 'authentikPassword: authorize chain resolved to code; entering token exchange' - ) - // Token exchange + user sync — identical to the redirect callback path. - // - // This stage is openid-client's, not ours, so it obeys OIDC_HTTP_TIMEOUT_MS - // (15s) PER CALL and makes two (token, then userinfo) — on its own it can - // outlast the whole login budget several times over and put us right back to - // the client aborting first. Hold it to what is left of the budget so the - // server still answers inside the browser's window with a stage-labelled - // error. The underlying HTTP call may run on in the background; the point is - // to stop WAITING on it, not to pretend it was cancelled. - const exchangeStart = Date.now() - const exchange = oidcService.handleCallback( - code, - returnedState || state, - codeVerifier - ) - const bounded = deadline - ? withDeadline(exchange, deadline, 'oidc.tokenExchange') - : exchange - // Timed like any other hop: this stage is two openid-client round-trips - // (token, then userinfo) and is just as capable of being THE slow one, so it - // must not be the one stage missing from the timing breakdown. - return bounded.finally(() => - recordHop('oidc.tokenExchange', since(exchangeStart)) - ) -} - -/** - * Resolve with `work`, or reject with a labelled AuthentikUnavailableError once - * the shared budget is spent — whichever happens first. - */ -function withDeadline( - work: Promise, - deadline: Deadline, - label: string -): Promise { - const remaining = deadline.remainingMs() - if (remaining <= 0) { - // Do not leave the already-started work as an unhandled rejection. - void work.catch(() => undefined) - deadline.assertLive(label) - } - let timer: NodeJS.Timeout - const expiry = new Promise((_resolve, reject) => { - timer = setTimeout(() => { - logger.error( - { label, remainingMs: remaining }, - 'authentikPassword: stage exceeded the remaining login budget' - ) - reject( - new AuthentikUnavailableError( - `${label} exceeded the remaining ${remaining}ms of the sign-in budget` - ) - ) - }, remaining) - if (typeof timer.unref === 'function') timer.unref() - }) - // When the timer wins the race, `work` is still in flight; a later rejection - // from it would otherwise surface as an unhandled rejection and (under - // Node's default) take the process down. - void work.catch(() => undefined) - return Promise.race([work, expiry]).finally(() => clearTimeout(timer)) -} - -/** Thrown when an account already exists for the email (signup conflict). */ -export class EnrollmentConflictError extends Error { - constructor(message = 'An account with that email already exists') { - super(message) - this.name = 'EnrollmentConflictError' - } -} - -export interface AuthentikSignupInput { - email: string - password: string - firstName?: string - lastName?: string - username?: string -} - -/** - * Create the account in AUTHENTIK by driving the self-service enrollment flow - * server-side (same CookieJar + flow-executor driver as password login), then - * complete the OIDC code exchange — the enrollment flow's final user-login - * stage establishes an authenticated session, so the freshly-created user is - * synced into the platform DB via the SAME `syncUserToDatabase` path login - * uses. Authentik is the sole identity store; no local bcrypt user is written. - * - * The blueprint flow (deploy/helm/.../authentik/blueprints/flow-enrollment.yaml) - * has a single prompt stage (email/username/password/password_repeat/tos) then - * a user-write + user-login stage. Only that shape is driven; any other stage - * (e.g. an email-verification stage) fails closed as unsupported server-side. - */ -export async function authentikSignup(input: AuthentikSignupInput): Promise { - const signupStart = Date.now() - logger.info({ email: input.email }, 'authentikPassword: signup start') - try { - const user = await authentikSignupInner(input) - logger.info( - { email: input.email, elapsedMs: since(signupStart) }, - 'authentikPassword: signup succeeded' - ) - return user - } catch (err) { - logger.error( - { - email: input.email, - elapsedMs: since(signupStart), - errName: (err as Error).name, - err: (err as Error).message, - }, - 'authentikPassword: signup failed' - ) - throw err - } -} - -async function authentikSignupInner(input: AuthentikSignupInput): Promise { - if (!oidcService.isConfigured()) { - throw new AuthentikUnavailableError('OIDC is not configured/initialized') - } - if (!oidcService.isInitialized()) { - try { - await oidcService.ensureInitialized() - } catch { - throw new AuthentikUnavailableError('OIDC is not configured/initialized') - } - } - if (!input.email || !input.password) { - throw new InvalidCredentialsError('email and password are required') - } - - const base = authentikBaseUrl() - const slug = enrollmentFlowSlug() - const jar = new CookieJar() - // Signup drives the same multi-hop chain as login and is bounded by the same - // client-side LOGIN_TIMEOUT_MS, so it gets the same whole-request budget. - const deadline = new Deadline() - - // Derive a username from the local-part when the caller did not supply one. - const username = - input.username || input.email.split('@')[0].replace(/[^a-zA-Z0-9_.-]/g, '') || input.email - - let challenge = await flowRequest(base, slug, jar, undefined, deadline) - const MAX_STEPS = 8 - let enrolled = false - - for (let step = 0; step < MAX_STEPS; step++) { - const component = challenge.component || challenge.type || '' - - if (component === 'xak-flow-redirect') { - enrolled = true - break - } - - if (component === 'ak-stage-prompt') { - // The enrollment prompt collects all fields at once. Include name fields - // and the ToS acceptance; Authentik ignores unknown fields. - const body: Record = { - component, - email: input.email, - username, - password: input.password, - password_repeat: input.password, - tos_accepted: true, - } - if (input.firstName || input.lastName) { - body.name = [input.firstName, input.lastName].filter(Boolean).join(' ') - } - challenge = await flowRequest(base, slug, jar, body, deadline) - } else if (component === 'ak-stage-user-login' || component === 'ak-stage-user-write') { - // Non-interactive stages that occasionally surface a challenge — re-POST - // the bare component to advance. - challenge = await flowRequest(base, slug, jar, { component }, deadline) - } else if (component === 'ak-stage-access-denied') { - throw new EnrollmentConflictError() - } else { - // Captcha, email-verification, MFA-enroll, consent … not driveable here. - throw new UnsupportedFlowStageError(component || 'unknown') - } - - if (challengeHasCredentialErrors(challenge)) { - // Distinguish "already exists" from a password-policy rejection. - const errs = challenge.response_errors || {} - const flat = JSON.stringify(errs).toLowerCase() - if ( - errs.email || - errs.username || - /already|exist|taken|unique/.test(flat) - ) { - throw new EnrollmentConflictError() - } - throw new InvalidCredentialsError( - 'Enrollment rejected: ' + flat.slice(0, 200) - ) - } - } - - if (!enrolled) { - const last = challenge.component || challenge.type || 'unknown' - throw new UnsupportedFlowStageError(last) - } - - // Enrollment auto-logged-in → complete OIDC + sync via the shared path, on - // whatever is LEFT of the signup budget rather than a fresh one. - return completeOidcWithSession(base, jar, deadline) -} - -/** Thrown when the identity store has no account for the address. */ -export class AuthentikUserNotFoundError extends Error { - constructor(message = 'No identity-store account for that address') { - super(message) - this.name = 'AuthentikUserNotFoundError' - } -} - -/** Thrown when the new password is rejected by the identity store's policy. */ -export class PasswordPolicyError extends Error { - constructor(message = 'Password does not meet the password policy') { - super(message) - this.name = 'PasswordPolicyError' - } -} - -function authentikAdminToken(): string { - const token = process.env.AUTHENTIK_ADMIN_TOKEN - if (!token) { - throw new AuthentikUnavailableError( - 'AUTHENTIK_ADMIN_TOKEN is required to set an account password' - ) - } - return token -} - -/** - * Set an account's password IN THE IDENTITY STORE (Authentik) via the Admin API. - * - * Authentik is the sole credential store — FuzeFront never writes a local - * password hash, so a reset MUST land here or it has not happened. Resolves the - * account by email (`GET /api/v3/core/users/?email=`) then drives - * `POST /api/v3/core/users/{pk}/set_password/`. - * - * Fail-closed: an unresolvable account, a policy rejection, or any transport - * error throws — a caller never treats a non-2xx as "reset". - */ -export async function authentikSetPassword( - email: string, - newPassword: string -): Promise { - if (!email || !newPassword) { - throw new InvalidCredentialsError('email and newPassword are required') - } - const base = authentikBaseUrl() - const headers = { - Authorization: `Bearer ${authentikAdminToken()}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - } - - let lookup: Response - try { - lookup = await fetchWithTimeout( - `${base}/api/v3/core/users/?email=${encodeURIComponent(email)}`, - { headers }, - 'setPassword.lookup' - ) - } catch (err) { - if (err instanceof AuthentikUnavailableError) throw err - throw new AuthentikUnavailableError( - `identity-store lookup failed: ${(err as Error).message}` - ) - } - if (!lookup.ok) { - throw new AuthentikUnavailableError( - `identity-store user lookup returned HTTP ${lookup.status}` - ) - } - const body = (await lookup.json().catch(() => ({}))) as { - results?: Array<{ pk: number | string; email?: string }> - } - // Match the address exactly (case-insensitively): the query is a filter, not - // an exact-match guarantee, and resetting the WRONG account is unacceptable. - const match = (body.results || []).find( - u => (u.email || '').toLowerCase() === email.toLowerCase() - ) - if (!match) throw new AuthentikUserNotFoundError() - - let res: Response - try { - res = await fetchWithTimeout( - `${base}/api/v3/core/users/${match.pk}/set_password/`, - { - method: 'POST', - headers, - body: JSON.stringify({ password: newPassword }), - }, - 'setPassword.set' - ) - } catch (err) { - if (err instanceof AuthentikUnavailableError) throw err - throw new AuthentikUnavailableError( - `identity-store set_password failed: ${(err as Error).message}` - ) - } - // 400 is the password-policy rejection; surface it distinctly so the API can - // answer 400 rather than a generic failure. - if (res.status === 400) { - const text = await res.text().catch(() => '') - throw new PasswordPolicyError( - text ? `Password rejected by policy: ${text}` : undefined - ) - } - if (!res.ok) { - throw new AuthentikUnavailableError( - `identity-store set_password returned HTTP ${res.status}` - ) - } -} +/** + * Server-side Authentik password authentication — no browser redirect. + * + * The login page shows native email/password fields; this service drives + * Authentik's flow-executor JSON API with those credentials, then completes a + * standard OIDC authorization-code + PKCE exchange using the authenticated + * Authentik session. Authentik therefore remains the SOLE identity authority + * (same as the redirect flow), and everything downstream — user sync into the + * platform DB, session/JWT minting — reuses the existing OIDC machinery. + * + * Flow: + * 1. GET /api/v3/flows/executor//?query= → identification stage + * 2. POST { component, uid_field: email [, password] } + * 3. POST { component, password } (separate password stage) + * 4. challenge "xak-flow-redirect" → Authentik session established + * 5. GET the OIDC authorize URL with the session cookies (implicit consent) + * → 302 …/api/auth/oidc/callback?code=…&state=… + * 6. getOidcService().handleCallback(code, state, codeVerifier) → synced User + * + * Only single-factor identification/password flows are supported. Users with + * MFA or other stages configured must use the browser (Google/SSO) path — we + * fail closed with a clear error rather than trying to drive arbitrary stages. + */ +import { generators } from 'openid-client' +import { getOidcService } from './oidc' +import { currentTenant } from '../providers/authentik/tenants' +import { User } from '../types/shared' +import { logger } from '../lib/logger' + +/** + * Hard per-fetch timeout (ms) for EVERY server-side Authentik HTTP hop driven in + * this module — the flow-executor requests, the OIDC authorize→code redirect + * chain, and the Admin-API set_password calls. Without it a single stuck hop + * (e.g. the authorize hairpin out to app.fuzefront.com via Cloudflare) hangs the + * whole login request forever, so the client only fails after its own ~60s + * timeout with no server log pointing at the culprit. A bounded AbortController + * turns that into a fast, labelled AuthentikUnavailableError instead. + * Overridable via AUTHENTIK_FLOW_TIMEOUT_MS without a rebuild. + */ +const AUTHENTIK_FLOW_TIMEOUT_MS = + Number(process.env.AUTHENTIK_FLOW_TIMEOUT_MS) || 10000 + +/** + * WHOLE-REQUEST budget (ms) for one server-brokered sign-in/sign-up. + * + * The per-hop cap above bounds each INDIVIDUAL fetch, but a login is a CHAIN: + * 2-3 flow-executor stages (each following up to 10 redirects) followed by the + * authorize→code chain (up to 10 more hops). Every hop used to get a fresh 10s + * budget, so the server's worst case ran to minutes while the browser gives the + * whole call only LOGIN_TIMEOUT_MS (15s — frontend/src/services/api.ts). The + * client therefore always aborted first, and the user got a bare + * "timeout of 15000ms exceeded" with no status and no message, while the + * labelled server-side diagnostics ("hop timed out", naming the exact stage) + * were still waiting to be produced and never reached anyone. + * + * Bounding the CHAIN — not just each link — is what makes the server answer + * first. This MUST stay below the client's bound so a stalled sign-in surfaces + * as a real, logged, labelled HTTP response instead of a blind client-side + * abort: 40s here against the client's 45s. The margin is for the response + * trip, so retune the pair together and never let this cross above it. + * + * Sized to the documented slow path (16-30s), not to what sign-in SHOULD cost + * — a budget below the real worst case rejects logins that would have + * succeeded. Tighten both once the underlying slow hop is fixed. + * Overridable via AUTHENTIK_LOGIN_DEADLINE_MS. + */ +const AUTHENTIK_LOGIN_DEADLINE_MS = + Number(process.env.AUTHENTIK_LOGIN_DEADLINE_MS) || 40000 + +/** + * A hop slower than this is reported at WARN, with its label, even when the + * login ultimately succeeds. + * + * Per-hop timings already existed — at `logger.debug`. LOG_LEVEL defaults to + * `info` and is not set anywhere in the chart, so in production that detail has + * always been switched OFF, and answering "which hop is slow?" required either + * a config change or a redeploy. That is why this path has accumulated timeout + * band-aids (#362, #371) instead of a diagnosis: the evidence was never in the + * logs when the incident happened. + * + * A slow-hop threshold is the standard fix (a slow-query log): silent on the + * fast path, fully detailed exactly when something is wrong — no LOG_LEVEL + * change, no redeploy, no spam. Overridable via AUTHENTIK_SLOW_HOP_WARN_MS. + */ +const AUTHENTIK_SLOW_HOP_WARN_MS = + Number(process.env.AUTHENTIK_SLOW_HOP_WARN_MS) || 1000 + +/** + * A whole sign-in slower than this is reported at WARN even though it + * SUCCEEDED. Set above the ~5.5s fast path, well below the client bound — the + * gap between them is precisely the band that was silently eating sign-in. + */ +const AUTHENTIK_LOGIN_WARN_MS = + Number(process.env.AUTHENTIK_LOGIN_WARN_MS) || 8000 + +/** Monotonic-ish elapsed helper for the per-step timing logs. */ +function since(startMs: number): number { + return Math.round(Date.now() - startMs) +} + +/** + * Record one hop's cost. Always available at debug; promoted to warn when the + * hop is slow enough to be the thing worth looking at. + * + * Leading hypothesis for what this will show, stated so the logs can refute it: + * these hops all target the same in-cluster origin, and this pod's own + * `dnsConfig` (deploy/helm/.../security.yaml) documents CoreDNS "intermittently + * stalls lookups in 5s/10s retry multiples", capped to ~2s by timeout:1 / + * attempts:2 — with the note that this service "resolves authentik-server on + * every auth flow". A DNS lookup happens per NEW CONNECTION, and the leaked + * response bodies (see drainBody) forced a new connection per hop, so the stall + * was multiplied by hop count: ~6 hops x ~2s is most of the observed 16-30s. + * If that is right, draining bodies lets undici reuse one keep-alive socket per + * origin and the stalls collapse to at most one. `elapsedMs` per labelled hop + * is what confirms or kills that; if connect/DNS time still dominates, the next + * step is an explicit keep-alive dispatcher pinned to the Authentik origin. + */ +function recordHop( + label: string, + elapsedMs: number, + fields: Record = {} +): void { + const entry = { label, elapsedMs, ...fields } + if (elapsedMs >= AUTHENTIK_SLOW_HOP_WARN_MS) { + logger.warn(entry, 'authentikPassword: SLOW hop') + return + } + logger.debug(entry, 'authentikPassword: hop') +} + +/** + * A wall-clock budget shared by every hop of ONE login/signup attempt. + * + * Each hop asks for `hopBudget()`, which is the smaller of the per-hop cap and + * whatever is actually left — so the chain can never outlive the whole-request + * deadline no matter how many redirects Authentik asks us to follow. + */ +class Deadline { + private readonly expiresAt: number + private readonly budgetMs: number + + constructor(budgetMs: number = AUTHENTIK_LOGIN_DEADLINE_MS) { + this.budgetMs = budgetMs + this.expiresAt = Date.now() + budgetMs + } + + remainingMs(): number { + return this.expiresAt - Date.now() + } + + /** Per-hop allowance: never more than the cap, never more than what's left. */ + hopBudget(cap: number = AUTHENTIK_FLOW_TIMEOUT_MS): number { + return Math.min(cap, this.remainingMs()) + } + + /** + * Throw a labelled error if the whole-request budget is already spent, so the + * chain stops at a named stage instead of starting a hop it cannot finish. + */ + assertLive(label: string): void { + if (this.remainingMs() > 0) return + logger.error( + { label, budgetMs: this.budgetMs }, + 'authentikPassword: login deadline exceeded' + ) + throw new AuthentikUnavailableError( + `sign-in exceeded its ${this.budgetMs}ms budget before ${label}` + ) + } +} + +/** + * Release the connection behind a response whose body we are going to discard. + * + * undici (Node's fetch) keeps the underlying socket checked out until the body + * is consumed or cancelled. Every redirect hop below reads only `location` and + * moves on, so without this the socket for each hop stayed pinned for the rest + * of the request — and subsequent hops to the SAME origin queued behind the + * leaked ones. That is exactly the shape of the intermittent multi-second + * stalls this module keeps getting timeout band-aids for: not one slow hop, but + * hops waiting on connections their own predecessors never gave back. + */ +function drainBody(res: Response): void { + // `.cancel()` rejects if the body is already disturbed/locked; either way the + // connection is no longer ours to hold, so the outcome is not interesting. + void res.body?.cancel().catch(() => undefined) +} + +/** + * `fetch` with a hard AbortController deadline. On timeout the AbortError is + * normalised to a labelled AuthentikUnavailableError carrying the elapsed time + * and the target, so prod logs pinpoint exactly which hop stalled. + * + * Pass a `Deadline` for anything on the login/signup chain so the hop's + * allowance is clamped to the whole-request budget rather than getting a fresh + * full-length one. + */ +async function fetchWithTimeout( + url: string, + init: RequestInit, + label: string, + deadline?: Deadline, + cap: number = AUTHENTIK_FLOW_TIMEOUT_MS +): Promise { + if (deadline) deadline.assertLive(label) + const timeoutMs = deadline ? deadline.hopBudget(cap) : cap + const controller = new AbortController() + const started = Date.now() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } catch (err) { + const e = err as Error + if (e.name === 'AbortError') { + logger.error( + { label, elapsedMs: since(started), timeoutMs, url }, + 'authentikPassword: hop timed out' + ) + throw new AuthentikUnavailableError( + `${label} timed out after ${timeoutMs}ms` + ) + } + throw err + } finally { + clearTimeout(timer) + } +} + +export class InvalidCredentialsError extends Error { + constructor(message = 'Invalid credentials') { + super(message) + this.name = 'InvalidCredentialsError' + } +} + +export class AuthentikUnavailableError extends Error { + constructor(message = 'Authentication service unavailable') { + super(message) + this.name = 'AuthentikUnavailableError' + } +} + +export class UnsupportedFlowStageError extends Error { + constructor(stage: string) { + super(`Unsupported Authentik flow stage: ${stage} (only identification+password is supported server-side)`) + this.name = 'UnsupportedFlowStageError' + } +} + +/** Minimal cookie jar for the short-lived per-login Authentik session. */ +export class CookieJar { + private cookies = new Map() + + absorb(res: { headers: Headers }): void { + // Node >=18.14 exposes getSetCookie(); fall back to the single-value get() + // (sufficient in practice — Authentik sets one cookie per response hop). + const anyHeaders = res.headers as Headers & { getSetCookie?: () => string[] } + const setCookies: string[] = + typeof anyHeaders.getSetCookie === 'function' + ? anyHeaders.getSetCookie() + : ([res.headers.get('set-cookie')].filter(Boolean) as string[]) + for (const sc of setCookies) { + const pair = sc.split(';')[0] + const eq = pair.indexOf('=') + if (eq <= 0) continue + const name = pair.slice(0, eq).trim() + const value = pair.slice(eq + 1).trim() + if (value === '' || /max-age=0|expires=thu, 01 jan 1970/i.test(sc)) { + this.cookies.delete(name) + } else { + this.cookies.set(name, value) + } + } + } + + header(): string { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') + } + + get(name: string): string | undefined { + return this.cookies.get(name) + } +} + +/** + * In-cluster base for THIS tenant's Authentik. Resolved from the tenant rather + * than the environment so the server-side flow-executor calls land on the + * tenant's own instance (authentik-server vs authentik-mendys-server) instead + * of whichever one the process happened to be configured with. + */ +export function authentikBaseUrl(): string { + const tenant = currentTenant('authentikBaseUrl') + if (tenant.baseUrl) return tenant.baseUrl.replace(/\/$/, '') + return new URL(tenant.issuerUrl).origin +} + +function authFlowSlug(): string { + return process.env.AUTHENTIK_AUTH_FLOW_SLUG || 'default-authentication-flow' +} + +export function redirectUri(): string { + return currentTenant('redirectUri').redirectUri +} + +/** + * Enrollment flow slug inside THIS tenant's Authentik. Each tenant ships its own + * enrollment flow (fuzefront-enrollment vs mendys-enrollment), so this must not + * fall back to a global default. + */ +function enrollmentFlowSlug(): string { + return currentTenant('enrollmentFlowSlug').enrollmentFlowSlug +} + +export interface FlowChallenge { + component?: string + type?: string + to?: string + password_fields?: boolean + response_errors?: Record> + [key: string]: unknown +} + +export async function flowRequest( + base: string, + slug: string, + jar: CookieJar, + body?: Record, + deadline?: Deadline +): Promise { + // Authentik commonly answers the first executor request with a 302 that + // establishes the session cookie (Location points back into the flow), so + // follow same-origin redirects manually, carrying the jar. Per Django 302 + // semantics a redirected POST is retried as GET. + let url = `${base}/api/v3/flows/executor/${slug}/?query=` + let method: 'GET' | 'POST' = body ? 'POST' : 'GET' + let payload: string | undefined = body ? JSON.stringify(body) : undefined + + for (let hop = 0; hop < 10; hop++) { + const headers: Record = { + Accept: 'application/json', + // Django CSRF validates Referer on secure requests. + Referer: `${base}/`, + } + const cookie = jar.header() + if (cookie) headers['Cookie'] = cookie + if (method === 'POST') { + headers['Content-Type'] = 'application/json' + const csrf = jar.get('authentik_csrf') + if (csrf) headers['X-CSRFToken'] = csrf + } + + let res: Response + const stepStart = Date.now() + try { + res = await fetchWithTimeout( + url, + { method, headers, body: payload, redirect: 'manual' }, + `flow.step slug=${slug} hop=${hop} ${method}`, + deadline + ) + } catch (err) { + if (err instanceof AuthentikUnavailableError) throw err + throw new AuthentikUnavailableError( + `Authentik unreachable at ${base}: ${(err as Error).message}` + ) + } + recordHop(`flow.step slug=${slug} hop=${hop} ${method}`, since(stepStart), { + slug, + hop, + method, + status: res.status, + }) + jar.absorb(res) + + const loc = res.headers.get('location') + if ([301, 302, 303, 307, 308].includes(res.status) && loc) { + // Only `location` is wanted from a redirect — hand the socket back before + // issuing the next hop, or it stays checked out for the whole request and + // the following hops queue behind it (see drainBody). + drainBody(res) + const nextUrl = new URL(loc, url) + // The jar carries authentik_session/authentik_csrf — never present those + // cookies to any host other than Authentik itself. + if (nextUrl.origin !== new URL(base).origin) { + throw new AuthentikUnavailableError( + `Flow executor redirected off-origin to ${nextUrl.origin} — refusing to follow with session cookies` + ) + } + url = nextUrl.toString() + // 301/302/303 rewrite the retry as GET (Django semantics); 307/308 + // preserve the original method and body per HTTP spec. + if (res.status !== 307 && res.status !== 308) { + method = 'GET' + payload = undefined + } + continue + } + const contentTypeEarly = res.headers.get('content-type') || '' + if (!res.ok) { + // A 4xx with a JSON body is a FLOW response (e.g. 400 carrying + // response_errors for rejected credentials) — return it so the caller + // maps it to 401, instead of mislabeling it a 503 outage. + if (res.status < 500 && contentTypeEarly.includes('json')) { + return (await res.json()) as FlowChallenge + } + // Surface Authentik's own error payload — a bare status is undebuggable + // from CI logs (e.g. 403 CSRF vs 404 unknown flow slug). + const bodySnippet = (await res.text().catch(() => '')).slice(0, 300) + throw new AuthentikUnavailableError( + `Authentik flow executor HTTP ${res.status} at ${url}: ${bodySnippet}` + ) + } + if (!contentTypeEarly.includes('json')) { + const bodySnippet = (await res.text().catch(() => '')).slice(0, 300) + throw new AuthentikUnavailableError( + `Authentik flow executor returned non-JSON (${contentTypeEarly}) at ${url}: ${bodySnippet}` + ) + } + return (await res.json()) as FlowChallenge + } + throw new AuthentikUnavailableError('Authentik flow executor redirect loop') +} + +function challengeHasCredentialErrors(challenge: FlowChallenge): boolean { + const errs = challenge.response_errors + if (!errs) return false + return Object.keys(errs).length > 0 +} + +/** + * Authenticate email+password against Authentik and return the synced platform + * User. Throws InvalidCredentialsError / AuthentikUnavailableError / + * UnsupportedFlowStageError. + */ +export async function authentikPasswordLogin( + email: string, + password: string +): Promise { + const loginStart = Date.now() + logger.info({ email }, 'authentikPassword: login start') + try { + const user = await authentikPasswordLoginInner(email, password) + const elapsedMs = since(loginStart) + // A login that SUCCEEDS at 25s is the failure mode that broke sign-in: it + // never errors, so nothing alerts, and it only becomes visible once a + // client bound trips underneath it. Report a slow success as loudly as a + // slow hop — the per-hop WARNs above then say which stage owned the time. + if (elapsedMs >= AUTHENTIK_LOGIN_WARN_MS) { + logger.warn( + { email, elapsedMs, thresholdMs: AUTHENTIK_LOGIN_WARN_MS }, + 'authentikPassword: SLOW login (succeeded)' + ) + } else { + logger.info({ email, elapsedMs }, 'authentikPassword: login succeeded') + } + return user + } catch (err) { + logger.error( + { + email, + elapsedMs: since(loginStart), + errName: (err as Error).name, + err: (err as Error).message, + }, + 'authentikPassword: login failed' + ) + throw err + } +} + +async function authentikPasswordLoginInner( + email: string, + password: string +): Promise { + if (!getOidcService().isConfigured()) { + throw new AuthentikUnavailableError('OIDC is not configured/initialized') + } + if (!getOidcService().isInitialized()) { + // Lazy re-init: dedupes concurrent callers onto one in-flight attempt and + // fails fast during the post-failure cooldown (see oidc.ts). Preserves + // the original error type/message on failure. + try { + await getOidcService().ensureInitialized() + } catch { + throw new AuthentikUnavailableError('OIDC is not configured/initialized') + } + } + + const base = authentikBaseUrl() + const slug = authFlowSlug() + const jar = new CookieJar() + // One budget for the WHOLE chain — flow stages AND the authorize hops below. + const deadline = new Deadline() + + // ── Drive the authentication flow ───────────────────────────────────────── + let challenge = await flowRequest(base, slug, jar, undefined, deadline) + const MAX_STEPS = 6 + let authenticated = false + + for (let step = 0; step < MAX_STEPS; step++) { + const component = challenge.component || challenge.type || '' + + if (component === 'xak-flow-redirect') { + authenticated = true + break + } + + if (component === 'ak-stage-identification') { + const body: Record = { + component, + uid_field: email, + } + // Combined identification+password stage + if (challenge.password_fields) body.password = password + challenge = await flowRequest(base, slug, jar, body, deadline) + } else if (component === 'ak-stage-password') { + challenge = await flowRequest( + base, + slug, + jar, + { component, password }, + deadline + ) + } else if (component === 'ak-stage-access-denied') { + throw new InvalidCredentialsError() + } else { + // MFA, consent, prompts, … — not driveable server-side. + throw new UnsupportedFlowStageError(component || 'unknown') + } + + if (challengeHasCredentialErrors(challenge)) { + throw new InvalidCredentialsError() + } + } + + if (!authenticated) { + const last = challenge.component || challenge.type || 'unknown' + if (last !== 'xak-flow-redirect') { + throw new UnsupportedFlowStageError(last) + } + } + + // Complete OIDC code+PKCE with the now-authenticated Authentik session, on + // whatever is LEFT of the login budget rather than a fresh one. + return completeOidcWithSession(base, jar, deadline) +} + +/** + * Drive the OIDC authorize→code exchange using an ALREADY-AUTHENTICATED + * Authentik session (the cookie jar). Shared by both server-side password login + * and server-side signup (enrollment auto-logs the new user in, establishing the + * same session). Token exchange + user sync is identical to the redirect + * callback path — Authentik stays the SOLE identity authority. + */ +/** + * Rewrite the (browser-facing, EXTERNAL) authorize URL onto the internal + * Authentik base — protocol+host only, path/query untouched — so this + * server-side hop stays in-cluster instead of hairpinning out through + * Cloudflare/ingress. Safe because: + * - `redirect_uri`/`state`/PKCE params are unchanged, so token validation + * (handleCallback) still matches. + * - Authentik's issuer_mode is `per_provider`, so `iss` is fixed to the + * external issuer regardless of request host (see oidc.ts). + * Measured impact: authorize.hop ~6.5s (external, via Cloudflare) -> ~0.2s + * (internal service DNS). `base` already resolves to AUTHENTIK_BASE_URL when + * set (see authentikBaseUrl()); this is a no-op when it is not. + */ +function toInternalAuthorizeUrl(externalUrl: string, base: string): string { + try { + const u = new URL(externalUrl) + const b = new URL(base) + u.protocol = b.protocol + u.host = b.host + return u.toString() + } catch { + return externalUrl + } +} + +export async function completeOidcWithSession( + base: string, + jar: CookieJar, + deadline?: Deadline +): Promise { + const state = generators.state() + const { url: authorizeUrl, codeVerifier } = getOidcService().generateAuthUrl(state) + const target = redirectUri() + + let location = toInternalAuthorizeUrl(authorizeUrl, base) + let code: string | null = null + let returnedState: string | null = null + const oidcStart = Date.now() + + for (let hop = 0; hop < 10; hop++) { + let res: Response + const hopStart = Date.now() + try { + res = await fetchWithTimeout( + location, + { + method: 'GET', + headers: { Cookie: jar.header(), Accept: 'application/json' }, + redirect: 'manual', + }, + `authorize.hop hop=${hop}`, + deadline + ) + } catch (err) { + if (err instanceof AuthentikUnavailableError) throw err + throw new AuthentikUnavailableError( + `Authorize request failed: ${(err as Error).message}` + ) + } + recordHop(`authorize.hop hop=${hop}`, since(hopStart), { + hop, + status: res.status, + }) + jar.absorb(res) + // Nothing in this chain ever reads an authorize response BODY — only its + // status, cookies and `location`. Release the socket now so the next hop + // doesn't queue behind it (see drainBody). + drainBody(res) + + const next = res.headers.get('location') + if (!next) { + // 200 here means Authentik rendered a flow UI (consent / re-auth) — + // implicit consent is expected on the FuzeFront provider. + throw new UnsupportedFlowStageError( + `authorize returned HTTP ${res.status} without redirect (consent flow?)` + ) + } + const resolvedUrl = new URL(next, location) + const resolved = resolvedUrl.toString() + if (resolved.startsWith(target)) { + const u = new URL(resolved) + code = u.searchParams.get('code') + returnedState = u.searchParams.get('state') + const err = u.searchParams.get('error') + if (err) { + throw new AuthentikUnavailableError(`Authorize error: ${err}`) + } + break + } + // Continue only within Authentik's own origin — the jar must not follow + // an arbitrary redirect elsewhere. + if (resolvedUrl.origin !== new URL(base).origin) { + throw new AuthentikUnavailableError( + `Authorize flow redirected off-origin to ${resolvedUrl.origin} — refusing to follow with session cookies` + ) + } + location = resolved + } + + if (!code) { + throw new AuthentikUnavailableError( + 'Authorize flow did not produce an authorization code' + ) + } + + logger.info( + { elapsedMs: since(oidcStart) }, + 'authentikPassword: authorize chain resolved to code; entering token exchange' + ) + // Token exchange + user sync — identical to the redirect callback path. + // + // This stage is openid-client's, not ours, so it obeys OIDC_HTTP_TIMEOUT_MS + // (15s) PER CALL and makes two (token, then userinfo) — on its own it can + // outlast the whole login budget several times over and put us right back to + // the client aborting first. Hold it to what is left of the budget so the + // server still answers inside the browser's window with a stage-labelled + // error. The underlying HTTP call may run on in the background; the point is + // to stop WAITING on it, not to pretend it was cancelled. + const exchangeStart = Date.now() + const exchange = getOidcService().handleCallback( + code, + returnedState || state, + codeVerifier + ) + const bounded = deadline + ? withDeadline(exchange, deadline, 'oidc.tokenExchange') + : exchange + // Timed like any other hop: this stage is two openid-client round-trips + // (token, then userinfo) and is just as capable of being THE slow one, so it + // must not be the one stage missing from the timing breakdown. + return bounded.finally(() => + recordHop('oidc.tokenExchange', since(exchangeStart)) + ) +} + +/** + * Resolve with `work`, or reject with a labelled AuthentikUnavailableError once + * the shared budget is spent — whichever happens first. + */ +function withDeadline( + work: Promise, + deadline: Deadline, + label: string +): Promise { + const remaining = deadline.remainingMs() + if (remaining <= 0) { + // Do not leave the already-started work as an unhandled rejection. + void work.catch(() => undefined) + deadline.assertLive(label) + } + let timer: NodeJS.Timeout + const expiry = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + logger.error( + { label, remainingMs: remaining }, + 'authentikPassword: stage exceeded the remaining login budget' + ) + reject( + new AuthentikUnavailableError( + `${label} exceeded the remaining ${remaining}ms of the sign-in budget` + ) + ) + }, remaining) + if (typeof timer.unref === 'function') timer.unref() + }) + // When the timer wins the race, `work` is still in flight; a later rejection + // from it would otherwise surface as an unhandled rejection and (under + // Node's default) take the process down. + void work.catch(() => undefined) + return Promise.race([work, expiry]).finally(() => clearTimeout(timer)) +} + +/** Thrown when an account already exists for the email (signup conflict). */ +export class EnrollmentConflictError extends Error { + constructor(message = 'An account with that email already exists') { + super(message) + this.name = 'EnrollmentConflictError' + } +} + +export interface AuthentikSignupInput { + email: string + password: string + firstName?: string + lastName?: string + username?: string +} + +/** + * Create the account in AUTHENTIK by driving the self-service enrollment flow + * server-side (same CookieJar + flow-executor driver as password login), then + * complete the OIDC code exchange — the enrollment flow's final user-login + * stage establishes an authenticated session, so the freshly-created user is + * synced into the platform DB via the SAME `syncUserToDatabase` path login + * uses. Authentik is the sole identity store; no local bcrypt user is written. + * + * The blueprint flow (deploy/helm/.../authentik/blueprints/flow-enrollment.yaml) + * has a single prompt stage (email/username/password/password_repeat/tos) then + * a user-write + user-login stage. Only that shape is driven; any other stage + * (e.g. an email-verification stage) fails closed as unsupported server-side. + */ +export async function authentikSignup(input: AuthentikSignupInput): Promise { + const signupStart = Date.now() + logger.info({ email: input.email }, 'authentikPassword: signup start') + try { + const user = await authentikSignupInner(input) + logger.info( + { email: input.email, elapsedMs: since(signupStart) }, + 'authentikPassword: signup succeeded' + ) + return user + } catch (err) { + logger.error( + { + email: input.email, + elapsedMs: since(signupStart), + errName: (err as Error).name, + err: (err as Error).message, + }, + 'authentikPassword: signup failed' + ) + throw err + } +} + +async function authentikSignupInner(input: AuthentikSignupInput): Promise { + if (!getOidcService().isConfigured()) { + throw new AuthentikUnavailableError('OIDC is not configured/initialized') + } + if (!getOidcService().isInitialized()) { + try { + await getOidcService().ensureInitialized() + } catch { + throw new AuthentikUnavailableError('OIDC is not configured/initialized') + } + } + if (!input.email || !input.password) { + throw new InvalidCredentialsError('email and password are required') + } + + const base = authentikBaseUrl() + const slug = enrollmentFlowSlug() + const jar = new CookieJar() + // Signup drives the same multi-hop chain as login and is bounded by the same + // client-side LOGIN_TIMEOUT_MS, so it gets the same whole-request budget. + const deadline = new Deadline() + + // Derive a username from the local-part when the caller did not supply one. + const username = + input.username || input.email.split('@')[0].replace(/[^a-zA-Z0-9_.-]/g, '') || input.email + + let challenge = await flowRequest(base, slug, jar, undefined, deadline) + const MAX_STEPS = 8 + let enrolled = false + + for (let step = 0; step < MAX_STEPS; step++) { + const component = challenge.component || challenge.type || '' + + if (component === 'xak-flow-redirect') { + enrolled = true + break + } + + if (component === 'ak-stage-prompt') { + // The enrollment prompt collects all fields at once. Include name fields + // and the ToS acceptance; Authentik ignores unknown fields. + const body: Record = { + component, + email: input.email, + username, + password: input.password, + password_repeat: input.password, + tos_accepted: true, + } + if (input.firstName || input.lastName) { + body.name = [input.firstName, input.lastName].filter(Boolean).join(' ') + } + challenge = await flowRequest(base, slug, jar, body, deadline) + } else if (component === 'ak-stage-user-login' || component === 'ak-stage-user-write') { + // Non-interactive stages that occasionally surface a challenge — re-POST + // the bare component to advance. + challenge = await flowRequest(base, slug, jar, { component }, deadline) + } else if (component === 'ak-stage-access-denied') { + throw new EnrollmentConflictError() + } else { + // Captcha, email-verification, MFA-enroll, consent … not driveable here. + throw new UnsupportedFlowStageError(component || 'unknown') + } + + if (challengeHasCredentialErrors(challenge)) { + // Distinguish "already exists" from a password-policy rejection. + const errs = challenge.response_errors || {} + const flat = JSON.stringify(errs).toLowerCase() + if ( + errs.email || + errs.username || + /already|exist|taken|unique/.test(flat) + ) { + throw new EnrollmentConflictError() + } + throw new InvalidCredentialsError( + 'Enrollment rejected: ' + flat.slice(0, 200) + ) + } + } + + if (!enrolled) { + const last = challenge.component || challenge.type || 'unknown' + throw new UnsupportedFlowStageError(last) + } + + // Enrollment auto-logged-in → complete OIDC + sync via the shared path, on + // whatever is LEFT of the signup budget rather than a fresh one. + return completeOidcWithSession(base, jar, deadline) +} + +/** Thrown when the identity store has no account for the address. */ +export class AuthentikUserNotFoundError extends Error { + constructor(message = 'No identity-store account for that address') { + super(message) + this.name = 'AuthentikUserNotFoundError' + } +} + +/** Thrown when the new password is rejected by the identity store's policy. */ +export class PasswordPolicyError extends Error { + constructor(message = 'Password does not meet the password policy') { + super(message) + this.name = 'PasswordPolicyError' + } +} + +function authentikAdminToken(): string { + const token = currentTenant('Authentik admin token').adminToken + if (!token) { + throw new AuthentikUnavailableError( + 'An Authentik admin token is required to set an account password, and none is configured for this tenant' + ) + } + return token +} + +/** + * Set an account's password IN THE IDENTITY STORE (Authentik) via the Admin API. + * + * Authentik is the sole credential store — FuzeFront never writes a local + * password hash, so a reset MUST land here or it has not happened. Resolves the + * account by email (`GET /api/v3/core/users/?email=`) then drives + * `POST /api/v3/core/users/{pk}/set_password/`. + * + * Fail-closed: an unresolvable account, a policy rejection, or any transport + * error throws — a caller never treats a non-2xx as "reset". + */ +export async function authentikSetPassword( + email: string, + newPassword: string +): Promise { + if (!email || !newPassword) { + throw new InvalidCredentialsError('email and newPassword are required') + } + const base = authentikBaseUrl() + const headers = { + Authorization: `Bearer ${authentikAdminToken()}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + } + + let lookup: Response + try { + lookup = await fetchWithTimeout( + `${base}/api/v3/core/users/?email=${encodeURIComponent(email)}`, + { headers }, + 'setPassword.lookup' + ) + } catch (err) { + if (err instanceof AuthentikUnavailableError) throw err + throw new AuthentikUnavailableError( + `identity-store lookup failed: ${(err as Error).message}` + ) + } + if (!lookup.ok) { + throw new AuthentikUnavailableError( + `identity-store user lookup returned HTTP ${lookup.status}` + ) + } + const body = (await lookup.json().catch(() => ({}))) as { + results?: Array<{ pk: number | string; email?: string }> + } + // Match the address exactly (case-insensitively): the query is a filter, not + // an exact-match guarantee, and resetting the WRONG account is unacceptable. + const match = (body.results || []).find( + u => (u.email || '').toLowerCase() === email.toLowerCase() + ) + if (!match) throw new AuthentikUserNotFoundError() + + let res: Response + try { + res = await fetchWithTimeout( + `${base}/api/v3/core/users/${match.pk}/set_password/`, + { + method: 'POST', + headers, + body: JSON.stringify({ password: newPassword }), + }, + 'setPassword.set' + ) + } catch (err) { + if (err instanceof AuthentikUnavailableError) throw err + throw new AuthentikUnavailableError( + `identity-store set_password failed: ${(err as Error).message}` + ) + } + // 400 is the password-policy rejection; surface it distinctly so the API can + // answer 400 rather than a generic failure. + if (res.status === 400) { + const text = await res.text().catch(() => '') + throw new PasswordPolicyError( + text ? `Password rejected by policy: ${text}` : undefined + ) + } + if (!res.ok) { + throw new AuthentikUnavailableError( + `identity-store set_password returned HTTP ${res.status}` + ) + } +} diff --git a/backend/security/src/services/machine-identity.ts b/backend/security/src/services/machine-identity.ts index 1ccd3462..902af7e0 100644 --- a/backend/security/src/services/machine-identity.ts +++ b/backend/security/src/services/machine-identity.ts @@ -1,211 +1,217 @@ -/** - * machine-identity.ts (security-service local copy) - * - * Absorbed from `backend/src/services/machine-identity.ts` so the provider-agnostic - * security-service compiles within its own tsconfig `rootDir` (no cross-package - * relative import). Rewritten on native `fetch` (Node 18+) to avoid an axios - * dependency — behaviour is preserved: register a client_credentials app in the - * identity provider and introspect machine tokens (fail-closed to inactive). - * - * This is provider-internal (Authentik) machinery — it is only imported by the - * concrete `AuthentikIdentityProvider`, never by the neutral API surface. - */ - -export interface TokenIntrospectionResult { - active: boolean - client_id?: string - scope?: string - sub?: string - exp?: number - iat?: number - delegate_user_id?: string - [key: string]: unknown -} - -export interface RegisterMachineClientResult { - clientId: string - clientSecret: string - name: string - providerSlug: string - applicationSlug: string -} - -function getAuthentikBaseUrl(): string { - return ( - process.env.AUTHENTIK_BASE_URL || - process.env.AUTHENTIK_ISSUER_URL?.replace(/\/application\/o\/.*$/, '') || - 'http://localhost:9000' - ) -} - -function getAuthentikAdminToken(): string { - const token = process.env.AUTHENTIK_ADMIN_TOKEN - if (!token) { - throw new Error( - 'AUTHENTIK_ADMIN_TOKEN environment variable is required for machine client registration' - ) - } - return token -} - -function getIntrospectionEndpoint(): string { - const issuerUrl = - process.env.AUTHENTIK_ISSUER_URL || 'http://localhost:9000/application/o/fuzefront/' - return issuerUrl.replace(/\/$/, '') + '/introspect/' -} - -async function postJson(url: string, headers: Record, body: unknown): Promise { - const res = await fetch(url, { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (!res.ok) { - const text = await res.text().catch(() => res.statusText) - throw new Error(`${res.status} ${text}`) - } - return res.json() -} - -async function getJson(url: string, headers: Record): Promise { - const res = await fetch(url, { headers }) - if (!res.ok) { - const text = await res.text().catch(() => res.statusText) - throw new Error(`${res.status} ${text}`) - } - return res.json() -} - -/** - * Registers a new machine/service-account OAuth2 application in the identity - * provider (client_credentials grant only). - */ -export async function registerMachineClient( - name: string, - _scopes: string[] = ['openid'] -): Promise { - const baseUrl = getAuthentikBaseUrl() - const adminToken = getAuthentikAdminToken() - const headers = { Authorization: `Bearer ${adminToken}` } - - const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, '-') - - const providerPayload = { - name: `${name} (machine)`, - authorization_flow: await resolveDefaultAuthorizationFlow(baseUrl, headers), - client_type: 'confidential', - access_code_validity: 'minutes=1', - token_validity: 'hours=1', - allowed_grant_types: ['client_credentials'], - sub_mode: 'hashed_user_id', - issuer_mode: 'global', - } - - let providerId: number - try { - const provider = await postJson( - `${baseUrl}/api/v3/providers/oauth2/`, - headers, - providerPayload - ) - providerId = provider.pk - } catch (error) { - throw new Error(`Failed to create OAuth2 provider in Authentik: ${(error as Error).message}`) - } - - const appPayload = { - name, - slug, - provider: providerId, - meta_description: `Machine identity for ${name}`, - policy_engine_mode: 'any', - } - - let applicationSlug: string - let clientId: string - let clientSecret: string - try { - const app = await postJson(`${baseUrl}/api/v3/core/applications/`, headers, appPayload) - applicationSlug = app.slug - const providerDetail = await getJson( - `${baseUrl}/api/v3/providers/oauth2/${providerId}/`, - headers - ) - clientId = providerDetail.client_id - clientSecret = providerDetail.client_secret - } catch (error) { - throw new Error(`Failed to create Application in Authentik: ${(error as Error).message}`) - } - - return { clientId, clientSecret, name, providerSlug: slug, applicationSlug } -} - -/** - * Validates a client-credentials bearer token via the provider's introspection - * endpoint. Fail-closed: any error / unreachable provider returns inactive. - */ -export async function introspectMachineToken( - bearerToken: string -): Promise { - const introspectionEndpoint = getIntrospectionEndpoint() - const clientId = process.env.AUTHENTIK_CLIENT_ID - const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET - - if (!clientId || !clientSecret) { - console.warn('[machine-identity] AUTHENTIK_CLIENT_ID/CLIENT_SECRET not set; cannot introspect token') - return { active: false } - } - - try { - const params = new URLSearchParams() - params.append('token', bearerToken) - params.append('token_type_hint', 'access_token') - const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 5000) - let res: Response - try { - res = await fetch(introspectionEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${basic}`, - }, - body: params.toString(), - signal: controller.signal, - }) - } finally { - clearTimeout(timeout) - } - - if (res.status === 401) { - console.error('[machine-identity] Introspection rejected (401): check AUTHENTIK_CLIENT_ID/SECRET') - return { active: false } - } - if (!res.ok) return { active: false } - return (await res.json()) as TokenIntrospectionResult - } catch (error) { - console.warn('[machine-identity] Introspection failed; treating token as inactive:', (error as Error).message) - return { active: false } - } -} - -async function resolveDefaultAuthorizationFlow( - baseUrl: string, - headers: Record -): Promise { - try { - const data = await getJson( - `${baseUrl}/api/v3/flows/instances/?designation=authorization`, - headers - ) - const flows: Array<{ slug: string; pk: string }> = data.results || [] - const defaultFlow = flows.find( - f => f.slug.includes('implicit-consent') || f.slug.includes('authorization') - ) - return defaultFlow?.pk || flows[0]?.pk || '' - } catch { - return '' - } -} +/** + * machine-identity.ts (security-service local copy) + * + * Absorbed from `backend/src/services/machine-identity.ts` so the provider-agnostic + * security-service compiles within its own tsconfig `rootDir` (no cross-package + * relative import). Rewritten on native `fetch` (Node 18+) to avoid an axios + * dependency — behaviour is preserved: register a client_credentials app in the + * identity provider and introspect machine tokens (fail-closed to inactive). + * + * This is provider-internal (Authentik) machinery — it is only imported by the + * concrete `AuthentikIdentityProvider`, never by the neutral API surface. + */ + +import { currentTenant } from '../providers/authentik/tenants' + +export interface TokenIntrospectionResult { + active: boolean + client_id?: string + scope?: string + sub?: string + exp?: number + iat?: number + delegate_user_id?: string + [key: string]: unknown +} + +export interface RegisterMachineClientResult { + clientId: string + clientSecret: string + name: string + providerSlug: string + applicationSlug: string +} + +function getAuthentikBaseUrl(): string { + const tenant = currentTenant('Authentik base URL') + return ( + tenant.baseUrl || + tenant.issuerUrl.replace(/\/application\/o\/.*$/, '') || + 'http://localhost:9000' + ) +} + +function getAuthentikAdminToken(): string { + // Per-tenant admin credential — see accountApi.adminToken(). + const token = currentTenant('Authentik admin token').adminToken + if (!token) { + throw new Error( + 'An Authentik admin token is required for machine client registration, and none is configured for this tenant' + ) + } + return token +} + +function getIntrospectionEndpoint(): string { + return currentTenant('introspection endpoint').issuerUrl.replace(/\/$/, '') + '/introspect/' +} + +async function postJson(url: string, headers: Record, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new Error(`${res.status} ${text}`) + } + return res.json() +} + +async function getJson(url: string, headers: Record): Promise { + const res = await fetch(url, { headers }) + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new Error(`${res.status} ${text}`) + } + return res.json() +} + +/** + * Registers a new machine/service-account OAuth2 application in the identity + * provider (client_credentials grant only). + */ +export async function registerMachineClient( + name: string, + _scopes: string[] = ['openid'] +): Promise { + const baseUrl = getAuthentikBaseUrl() + const adminToken = getAuthentikAdminToken() + const headers = { Authorization: `Bearer ${adminToken}` } + + const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, '-') + + const providerPayload = { + name: `${name} (machine)`, + authorization_flow: await resolveDefaultAuthorizationFlow(baseUrl, headers), + client_type: 'confidential', + access_code_validity: 'minutes=1', + token_validity: 'hours=1', + allowed_grant_types: ['client_credentials'], + sub_mode: 'hashed_user_id', + issuer_mode: 'global', + } + + let providerId: number + try { + const provider = await postJson( + `${baseUrl}/api/v3/providers/oauth2/`, + headers, + providerPayload + ) + providerId = provider.pk + } catch (error) { + throw new Error(`Failed to create OAuth2 provider in Authentik: ${(error as Error).message}`) + } + + const appPayload = { + name, + slug, + provider: providerId, + meta_description: `Machine identity for ${name}`, + policy_engine_mode: 'any', + } + + let applicationSlug: string + let clientId: string + let clientSecret: string + try { + const app = await postJson(`${baseUrl}/api/v3/core/applications/`, headers, appPayload) + applicationSlug = app.slug + const providerDetail = await getJson( + `${baseUrl}/api/v3/providers/oauth2/${providerId}/`, + headers + ) + clientId = providerDetail.client_id + clientSecret = providerDetail.client_secret + } catch (error) { + throw new Error(`Failed to create Application in Authentik: ${(error as Error).message}`) + } + + return { clientId, clientSecret, name, providerSlug: slug, applicationSlug } +} + +/** + * Validates a client-credentials bearer token via the provider's introspection + * endpoint. Fail-closed: any error / unreachable provider returns inactive. + */ +export async function introspectMachineToken( + bearerToken: string +): Promise { + const introspectionEndpoint = getIntrospectionEndpoint() + // Introspection MUST use this tenant's client credentials: presenting them to + // another tenant's endpoint would be rejected, and validating a token against + // the wrong directory is the failure the tenant split exists to prevent. + const tenant = currentTenant('introspection client credentials') + const clientId = tenant.clientId + const clientSecret = tenant.clientSecret + + if (!clientId || !clientSecret) { + console.warn('[machine-identity] tenant client credentials not set; cannot introspect token') + return { active: false } + } + + try { + const params = new URLSearchParams() + params.append('token', bearerToken) + params.append('token_type_hint', 'access_token') + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + let res: Response + try { + res = await fetch(introspectionEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basic}`, + }, + body: params.toString(), + signal: controller.signal, + }) + } finally { + clearTimeout(timeout) + } + + if (res.status === 401) { + console.error('[machine-identity] Introspection rejected (401): check AUTHENTIK_CLIENT_ID/SECRET') + return { active: false } + } + if (!res.ok) return { active: false } + return (await res.json()) as TokenIntrospectionResult + } catch (error) { + console.warn('[machine-identity] Introspection failed; treating token as inactive:', (error as Error).message) + return { active: false } + } +} + +async function resolveDefaultAuthorizationFlow( + baseUrl: string, + headers: Record +): Promise { + try { + const data = await getJson( + `${baseUrl}/api/v3/flows/instances/?designation=authorization`, + headers + ) + const flows: Array<{ slug: string; pk: string }> = data.results || [] + const defaultFlow = flows.find( + f => f.slug.includes('implicit-consent') || f.slug.includes('authorization') + ) + return defaultFlow?.pk || flows[0]?.pk || '' + } catch { + return '' + } +} diff --git a/backend/security/src/services/oidc.ts b/backend/security/src/services/oidc.ts index 80ee8493..0c3761b6 100644 --- a/backend/security/src/services/oidc.ts +++ b/backend/security/src/services/oidc.ts @@ -3,6 +3,7 @@ import { db } from '../config/database'; import { User } from '../types/shared'; import { defaultEventPublisher } from './eventPublisher'; import { logger } from '../lib/logger'; +import { AuthentikTenant, allTenants, currentTenant, runWithTenant } from '../providers/authentik/tenants'; /** * HTTP timeout for every server-side OIDC call (discovery, token grant, userinfo, @@ -47,15 +48,29 @@ class OIDCService { private lastInitAttemptAt = 0; private backgroundRetryStarted = false; - constructor() { + /** + * ONE INSTANCE PER TENANT. The config comes from the tenant, not from + * process.env: each tenant is backed by its own Authentik instance, so a + * shared client would run discovery against one directory and then validate + * the other's tokens against those keys. The discovery cache, the init + * cooldown and the background retry loop are all per-instance for the same + * reason — one tenant's Authentik being down must not mark another's client + * as failed, or leave it serving a stale issuer. + */ + constructor(private readonly tenant: AuthentikTenant) { this.config = { - issuerUrl: process.env.AUTHENTIK_ISSUER_URL || 'http://localhost:9000/application/o/fuzefront/', - clientId: process.env.AUTHENTIK_CLIENT_ID || '', - clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || '', - redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://fuzefront.dev.local/api/auth/oidc/callback', + issuerUrl: tenant.issuerUrl, + clientId: tenant.clientId, + clientSecret: tenant.clientSecret, + redirectUri: tenant.redirectUri, }; } + /** Tenant this client serves — used for log correlation and cache keying. */ + get tenantId(): string { + return this.tenant.id; + } + async initialize(): Promise { try { logger.info('oidc: initializing client'); @@ -83,7 +98,12 @@ class OIDCService { // issuer regardless of the request host, so token validation still matches. // The authorization_endpoint stays EXTERNAL (it is browser-facing). let effectiveIssuer = issuer; - const internalBase = process.env.AUTHENTIK_BASE_URL; + // Per-tenant in-cluster base. Reading this from the tenant rather than + // the environment is what keeps each tenant's server-side calls pointed + // at ITS OWN authentik Service (authentik-server vs + // authentik-mendys-server) instead of whichever one the process happened + // to be configured with. + const internalBase = this.tenant.baseUrl || undefined; if (internalBase) { const toInternal = (u?: string): string | undefined => { if (!u) return u; @@ -464,4 +484,70 @@ export async function syncUserToDatabase(userinfo: any): Promise { } } -export const oidcService = new OIDCService(); \ No newline at end of file +/** + * One OIDCService per tenant, keyed by tenant id and created on first use. + * + * Keyed by id rather than by host: several hosts may map to one tenant + * (live./marketplace.mendysrobotics.com), and they must share a single + * discovery cache and a single init/backoff state rather than racing each + * other into duplicate discovery calls. + */ +const byTenant = new Map(); + +/** + * Structural type of the OIDC client, so consumers can depend on the shape + * (and inject fakes in tests) without importing the class or being bound to a + * particular tenant's instance. + */ +export type OIDCServiceLike = OIDCService; + +/** The OIDC client for an explicit tenant. */ +export function getOidcServiceFor(tenant: AuthentikTenant): OIDCService { + let svc = byTenant.get(tenant.id); + if (!svc) { + svc = new OIDCService(tenant); + byTenant.set(tenant.id, svc); + } + return svc; +} + +/** + * The OIDC client for the tenant serving the current request. + * + * Replaces the former `oidcService` singleton, which was constructed at import + * time from process.env and therefore could only ever address one Authentik. + * Throws outside a tenant context rather than guessing — see tenants.ts. + */ +export function getOidcService(): OIDCService { + return getOidcServiceFor(currentTenant('OIDC client')); +} + +/** Drop the per-tenant instances. Tests only. */ +export function resetOidcServicesForTests(): void { + byTenant.clear(); +} + +/** + * Warm every configured tenant at boot: initialise its client and start its + * self-heal loop. Previously this was a single implicit client; with several + * tenants each needs its own, and one tenant's Authentik being down must not + * prevent the others from coming up — so failures are logged, not thrown. + */ +export async function initializeAllTenants(): Promise { + await Promise.all( + allTenants().map(async (tenant) => { + const svc = getOidcServiceFor(tenant); + try { + await runWithTenant(tenant, () => svc.initialize()); + logger.info({ tenant: tenant.id }, 'oidc: tenant client initialized'); + } catch (error) { + logger.warn( + { tenant: tenant.id, err: (error as Error).message }, + 'oidc: tenant client failed to initialize; background retry will self-heal' + ); + } finally { + svc.startBackgroundRetry(); + } + }) + ); +} \ No newline at end of file diff --git a/backend/security/tests/authentik-password-login.test.ts b/backend/security/tests/authentik-password-login.test.ts index d5978d32..0409bd5b 100644 --- a/backend/security/tests/authentik-password-login.test.ts +++ b/backend/security/tests/authentik-password-login.test.ts @@ -1,514 +1,524 @@ -/** - * Unit tests for the server-side Authentik password login - * (services/authentikPassword.ts). - * - * The flow-executor conversation and the authorize redirect are simulated by - * mocking global.fetch — no network. The OIDC pieces (authorize URL, token - * exchange/user sync) are mocked at the oidcService boundary, mirroring how - * the redirect flow's tests isolate openid-client. - */ - -jest.mock('../src/services/oidc', () => ({ - oidcService: { - isConfigured: jest.fn().mockReturnValue(true), - isInitialized: jest.fn().mockReturnValue(true), - generateAuthUrl: jest.fn().mockReturnValue({ - url: 'http://auth.example.test/application/o/authorize/?client_id=x&state=st', - codeVerifier: 'test-code-verifier', - }), - handleCallback: jest.fn().mockResolvedValue({ - id: 'user-1', - email: 'e2e@test.local', - firstName: 'E2E', - lastName: 'User', - roles: ['user'], - }), - }, -})) - -import { - authentikPasswordLogin, - InvalidCredentialsError, - AuthentikUnavailableError, - UnsupportedFlowStageError, -} from '../src/services/authentikPassword' -import { oidcService } from '../src/services/oidc' - -const REDIRECT_URI = 'http://fuzefront.test.local/api/auth/oidc/callback' - -/** Build a minimal fetch Response stand-in. */ -function mkRes(opts: { - status?: number - json?: unknown - setCookies?: string[] - location?: string -}) { - const headerMap = new Map() - if (opts.location) headerMap.set('location', opts.location) - if (opts.json !== undefined) headerMap.set('content-type', 'application/json') - return { - ok: (opts.status ?? 200) >= 200 && (opts.status ?? 200) < 300, - status: opts.status ?? 200, - headers: { - get: (name: string) => headerMap.get(name.toLowerCase()) ?? null, - getSetCookie: () => opts.setCookies ?? [], - }, - json: async () => opts.json ?? {}, - text: async () => JSON.stringify(opts.json ?? ''), - } as unknown as Response -} - -describe('authentikPasswordLogin()', () => { - const savedEnv = { ...process.env } - let fetchMock: jest.Mock - - beforeEach(() => { - jest.clearAllMocks() - process.env.AUTHENTIK_ISSUER_URL = - 'http://auth.example.test/application/o/fuzefront/' - process.env.AUTHENTIK_REDIRECT_URI = REDIRECT_URI - delete process.env.AUTHENTIK_BASE_URL - delete process.env.AUTHENTIK_AUTH_FLOW_SLUG - ;(oidcService.isConfigured as jest.Mock).mockReturnValue(true) - ;(oidcService.isInitialized as jest.Mock).mockReturnValue(true) - fetchMock = jest.fn() - ;(global as any).fetch = fetchMock - }) - - afterAll(() => { - process.env = savedEnv - }) - - it('drives identification → password → redirect, then exchanges the authorize code', async () => { - fetchMock - // 1. GET flow → identification stage (+ CSRF cookie) - .mockResolvedValueOnce( - mkRes({ - json: { component: 'ak-stage-identification', password_fields: false }, - setCookies: ['authentik_csrf=csrf-tok; Path=/'], - }) - ) - // 2. POST identification → password stage - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) - // 3. POST password → flow complete (+ session cookie) - .mockResolvedValueOnce( - mkRes({ - json: { component: 'xak-flow-redirect', to: '/' }, - setCookies: ['authentik_session=sess-1; Path=/; HttpOnly'], - }) - ) - // 4. GET authorize → 302 straight to our callback with the code - .mockResolvedValueOnce( - mkRes({ - status: 302, - location: `${REDIRECT_URI}?code=the-code&state=st`, - }) - ) - - const user = await authentikPasswordLogin('e2e@test.local', 'pw123') - - expect(user.email).toBe('e2e@test.local') - expect(oidcService.handleCallback).toHaveBeenCalledWith( - 'the-code', - 'st', - 'test-code-verifier' - ) - - // Identification POST carried the uid_field + CSRF header + cookie jar. - const [, identInit] = fetchMock.mock.calls[1] - expect(JSON.parse(identInit.body)).toMatchObject({ - component: 'ak-stage-identification', - uid_field: 'e2e@test.local', - }) - expect(identInit.headers['X-CSRFToken']).toBe('csrf-tok') - - // Authorize GET presented the authenticated session cookie. - const [authorizeUrl, authorizeInit] = fetchMock.mock.calls[3] - expect(authorizeUrl).toContain('/application/o/authorize/') - expect(authorizeInit.headers.Cookie).toContain('authentik_session=sess-1') - }) - - it('follows the session-establishing 302 before the first challenge', async () => { - fetchMock - // initial GET -> 302 back into the flow, setting session + csrf cookies - .mockResolvedValueOnce( - mkRes({ - status: 302, - location: - 'http://auth.example.test/api/v3/flows/executor/default-authentication-flow/?query=', - setCookies: [ - 'authentik_session=pre-sess; Path=/', - 'authentik_csrf=csrf-tok; Path=/', - ], - }) - ) - // redirected GET -> identification challenge - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-identification' } }) - ) - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) - .mockResolvedValueOnce( - mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) - ) - .mockResolvedValueOnce( - mkRes({ status: 302, location: `${REDIRECT_URI}?code=c3&state=st` }) - ) - - const user = await authentikPasswordLogin('e2e@test.local', 'pw123') - expect(user.email).toBe('e2e@test.local') - - // The identification POST happened AFTER the redirect hop, with cookies. - const [identUrl, identInit] = fetchMock.mock.calls[2] - expect(identUrl).toContain('/flows/executor/') - expect(identInit.headers.Cookie).toContain('authentik_session=pre-sess') - expect(identInit.headers['X-CSRFToken']).toBe('csrf-tok') - }) - - it('supports a combined identification+password stage (password_fields: true)', async () => { - fetchMock - .mockResolvedValueOnce( - mkRes({ - json: { component: 'ak-stage-identification', password_fields: true }, - }) - ) - .mockResolvedValueOnce( - mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) - ) - .mockResolvedValueOnce( - mkRes({ status: 302, location: `${REDIRECT_URI}?code=c2&state=st` }) - ) - - await authentikPasswordLogin('e2e@test.local', 'pw123') - - const [, identInit] = fetchMock.mock.calls[1] - expect(JSON.parse(identInit.body)).toMatchObject({ - uid_field: 'e2e@test.local', - password: 'pw123', - }) - }) - - it('throws InvalidCredentialsError when the password stage reports response_errors', async () => { - fetchMock - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-identification' } }) - ) - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) - .mockResolvedValueOnce( - mkRes({ - json: { - component: 'ak-stage-password', - response_errors: { - password: [{ string: 'Invalid password', code: 'invalid' }], - }, - }, - }) - ) - - await expect( - authentikPasswordLogin('e2e@test.local', 'wrong') - ).rejects.toBeInstanceOf(InvalidCredentialsError) - expect(oidcService.handleCallback).not.toHaveBeenCalled() - }) - - it('maps a 4xx JSON flow response carrying response_errors to InvalidCredentialsError', async () => { - fetchMock - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-identification' } }) - ) - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) - // Authentik rejects the credentials with an HTTP 400 + JSON errors body - .mockResolvedValueOnce( - mkRes({ - status: 400, - json: { - component: 'ak-stage-password', - response_errors: { - password: [{ string: 'Invalid password', code: 'invalid' }], - }, - }, - }) - ) - - await expect( - authentikPasswordLogin('e2e@test.local', 'wrong') - ).rejects.toBeInstanceOf(InvalidCredentialsError) - }) - - it('throws InvalidCredentialsError on an access-denied stage', async () => { - fetchMock.mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-access-denied' } }) - ) - - await expect( - authentikPasswordLogin('nobody@test.local', 'pw') - ).rejects.toBeInstanceOf(InvalidCredentialsError) - }) - - it('fails closed on stages it cannot drive (e.g. MFA)', async () => { - fetchMock - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-identification' } }) - ) - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-authenticator-validate' } }) - ) - - await expect( - authentikPasswordLogin('mfa@test.local', 'pw') - ).rejects.toBeInstanceOf(UnsupportedFlowStageError) - }) - - it('refuses to follow an off-origin redirect with session cookies', async () => { - fetchMock.mockResolvedValueOnce( - mkRes({ - status: 302, - location: 'http://evil.example.net/steal', - setCookies: ['authentik_session=sess; Path=/'], - }) - ) - - await expect( - authentikPasswordLogin('e2e@test.local', 'pw') - ).rejects.toBeInstanceOf(AuthentikUnavailableError) - // No request was made to the off-origin host. - expect(fetchMock).toHaveBeenCalledTimes(1) - }) - - it('throws AuthentikUnavailableError when Authentik is unreachable', async () => { - fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED')) - - await expect( - authentikPasswordLogin('e2e@test.local', 'pw') - ).rejects.toBeInstanceOf(AuthentikUnavailableError) - }) - - it('throws AuthentikUnavailableError when OIDC is not initialized', async () => { - ;(oidcService.isInitialized as jest.Mock).mockReturnValue(false) - - await expect( - authentikPasswordLogin('e2e@test.local', 'pw') - ).rejects.toBeInstanceOf(AuthentikUnavailableError) - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('fails when authorize renders a flow UI instead of redirecting (consent required)', async () => { - fetchMock - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-identification' } }) - ) - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) - .mockResolvedValueOnce( - mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) - ) - // authorize returns 200 HTML (no Location) — consent flow not implicit - .mockResolvedValueOnce(mkRes({ status: 200 })) - - await expect( - authentikPasswordLogin('e2e@test.local', 'pw') - ).rejects.toBeInstanceOf(UnsupportedFlowStageError) - }) - - // ── Whole-request budget ────────────────────────────────────────────────── - // - // The per-hop timeout bounds each individual fetch, but a login is a CHAIN. - // Every hop used to get a FRESH full-length budget, so the server's worst - // case ran far past the browser's own LOGIN_TIMEOUT_MS — the client aborted - // first and the user got a bare "timeout of 15000ms exceeded" with no status - // and no message, while the labelled server-side diagnostics never got to - // exist. These pin the chain-level bound that makes the server answer first. - - it('abandons the chain with a labelled error once the whole-request budget is spent', async () => { - process.env.AUTHENTIK_LOGIN_DEADLINE_MS = '150' - jest.resetModules() - const { authentikPasswordLogin: login, AuthentikUnavailableError: Unavailable } = - require('../src/services/authentikPassword') - - // Each hop is individually well under the per-hop cap; it is their SUM - // that blows the budget. Authentik keeps redirecting inside the flow. - fetchMock.mockImplementation( - async () => - new Promise(resolve => - setTimeout( - () => - resolve( - mkRes({ - status: 302, - location: - 'http://auth.example.test/api/v3/flows/executor/default-authentication-flow/?query=', - }) - ), - 60 - ) - ) - ) - - const err = await login('e2e@test.local', 'pw').catch((e: Error) => e) - - expect(err).toBeInstanceOf(Unavailable) - // The message names the budget and the stage, so prod logs point at the - // stall instead of the client reporting an anonymous abort. - expect((err as Error).message).toMatch(/150ms budget before flow\.step/) - delete process.env.AUTHENTIK_LOGIN_DEADLINE_MS - }) - - it('clamps a hop to the time remaining rather than giving it a fresh full timeout', async () => { - process.env.AUTHENTIK_LOGIN_DEADLINE_MS = '400' - process.env.AUTHENTIK_FLOW_TIMEOUT_MS = '10000' - jest.resetModules() - const { authentikPasswordLogin: login } = require('../src/services/authentikPassword') - - // First hop burns most of the budget, then a hop hangs forever. Without - // clamping, the hang would get the full 10s per-hop cap and outlive the - // browser; with it, the abort fires within what is LEFT of the 400ms. - fetchMock - .mockImplementationOnce( - async () => - new Promise(resolve => - setTimeout( - () => resolve(mkRes({ json: { component: 'ak-stage-identification' } })), - 250 - ) - ) - ) - .mockImplementationOnce( - (_url: string, init: RequestInit) => - new Promise((_resolve, reject) => { - init.signal?.addEventListener('abort', () => { - const e = new Error('aborted') - e.name = 'AbortError' - reject(e) - }) - }) - ) - - const started = Date.now() - await expect(login('e2e@test.local', 'pw')).rejects.toThrow(/timed out/) - // Comfortably below the 10s per-hop cap — proof the clamp, not the cap, won. - expect(Date.now() - started).toBeLessThan(3000) - - delete process.env.AUTHENTIK_LOGIN_DEADLINE_MS - delete process.env.AUTHENTIK_FLOW_TIMEOUT_MS - }) - - it('bounds the token exchange by the remaining budget, not openid-client’s own timeout', async () => { - // handleCallback is openid-client's stage: OIDC_HTTP_TIMEOUT_MS (15s) per - // call, twice (token + userinfo). Left alone it outlasts the whole login - // budget on its own and the browser aborts first again. - process.env.AUTHENTIK_LOGIN_DEADLINE_MS = '300' - jest.resetModules() - const { authentikPasswordLogin: login } = require('../src/services/authentikPassword') - const { oidcService: oidc } = require('../src/services/oidc') - - oidc.handleCallback.mockReturnValueOnce(new Promise(() => {})) // never settles - - fetchMock - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-identification' } }) - ) - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) - .mockResolvedValueOnce( - mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) - ) - .mockResolvedValueOnce( - mkRes({ status: 302, location: `${REDIRECT_URI}?code=c8&state=st` }) - ) - - const started = Date.now() - await expect(login('e2e@test.local', 'pw')).rejects.toThrow( - /oidc\.tokenExchange exceeded the remaining/ - ) - // Well inside the browser's 15s bound instead of openid-client's 2x15s. - expect(Date.now() - started).toBeLessThan(3000) - - delete process.env.AUTHENTIK_LOGIN_DEADLINE_MS - }) - - it('reports a slow hop at WARN, naming the stage, on a login that still succeeds', async () => { - // The per-hop timings existed only at logger.debug, and LOG_LEVEL defaults - // to info in prod — so the one piece of evidence needed to find the slow - // hop was switched off exactly when it mattered. A slow SUCCESS must be - // visible without a LOG_LEVEL change or a redeploy. - process.env.AUTHENTIK_SLOW_HOP_WARN_MS = '50' - jest.resetModules() - const { authentikPasswordLogin: login } = require('../src/services/authentikPassword') - const { logger } = require('../src/lib/logger') - const warn = jest.spyOn(logger, 'warn').mockImplementation(() => undefined) - - const slow = (res: Response) => - new Promise(resolve => setTimeout(() => resolve(res), 80)) - - fetchMock - .mockImplementationOnce(() => - slow(mkRes({ json: { component: 'ak-stage-identification' } })) - ) - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) - .mockResolvedValueOnce( - mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) - ) - .mockResolvedValueOnce( - mkRes({ status: 302, location: `${REDIRECT_URI}?code=c7&state=st` }) - ) - - // The login SUCCEEDS — the warning is the whole point, not an error path. - await expect(login('e2e@test.local', 'pw')).resolves.toBeDefined() - - const slowHops = warn.mock.calls.filter( - ([, msg]) => msg === 'authentikPassword: SLOW hop' - ) - expect(slowHops).toHaveLength(1) - // Names the exact stage, so prod logs point at the culprit directly. - expect((slowHops[0][0] as any).label).toMatch(/flow\.step .*hop=0 GET/) - expect((slowHops[0][0] as any).elapsedMs).toBeGreaterThanOrEqual(50) - - warn.mockRestore() - delete process.env.AUTHENTIK_SLOW_HOP_WARN_MS - }) - - it('releases the response body of every hop it only reads headers from', async () => { - // undici keeps the socket checked out until the body is consumed or - // cancelled. Redirect hops read only `location`, and authorize hops never - // read a body at all — so an un-cancelled body pins that connection for the - // rest of the request and later hops queue behind their own predecessors. - const cancels: string[] = [] - const withBody = (label: string, res: Response) => - Object.defineProperty(res, 'body', { - value: { - cancel: async () => { - cancels.push(label) - }, - }, - configurable: true, - }) - - fetchMock - // flow hop that only yields a Location - .mockResolvedValueOnce( - withBody( - 'flow-redirect', - mkRes({ - status: 302, - location: - 'http://auth.example.test/api/v3/flows/executor/default-authentication-flow/?query=', - }) - ) - ) - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-identification' } }) - ) - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) - .mockResolvedValueOnce( - mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) - ) - // authorize hop — body never read on any path - .mockResolvedValueOnce( - withBody( - 'authorize', - mkRes({ status: 302, location: `${REDIRECT_URI}?code=c9&state=st` }) - ) - ) - - await authentikPasswordLogin('e2e@test.local', 'pw123') - - expect(cancels).toEqual(['flow-redirect', 'authorize']) - }) -}) +/** + * Unit tests for the server-side Authentik password login + * (services/authentikPassword.ts). + * + * The flow-executor conversation and the authorize redirect are simulated by + * mocking global.fetch — no network. The OIDC pieces (authorize URL, token + * exchange/user sync) are mocked at the oidcService boundary, mirroring how + * the redirect flow's tests isolate openid-client. + */ + +jest.mock('../src/services/oidc', () => { + // getOidcService() replaced the former `oidcService` singleton (the client is + // now resolved per tenant). Expose both, backed by the SAME object, so the + // assertions below still address what the code under test receives. + const mod: any = ({ + oidcService: { + isConfigured: jest.fn().mockReturnValue(true), + isInitialized: jest.fn().mockReturnValue(true), + generateAuthUrl: jest.fn().mockReturnValue({ + url: 'http://auth.example.test/application/o/authorize/?client_id=x&state=st', + codeVerifier: 'test-code-verifier', + }), + handleCallback: jest.fn().mockResolvedValue({ + id: 'user-1', + email: 'e2e@test.local', + firstName: 'E2E', + lastName: 'User', + roles: ['user'], + }), + }, +}) + mod.getOidcService = () => mod.oidcService + return mod +}) + +import { + authentikPasswordLogin, + InvalidCredentialsError, + AuthentikUnavailableError, + UnsupportedFlowStageError, +} from '../src/services/authentikPassword' +import { getOidcService } from '../src/services/oidc' + +/** The mocked client the code under test resolves via getOidcService(). */ +const oidcService: any = getOidcService() + +const REDIRECT_URI = 'http://fuzefront.test.local/api/auth/oidc/callback' + +/** Build a minimal fetch Response stand-in. */ +function mkRes(opts: { + status?: number + json?: unknown + setCookies?: string[] + location?: string +}) { + const headerMap = new Map() + if (opts.location) headerMap.set('location', opts.location) + if (opts.json !== undefined) headerMap.set('content-type', 'application/json') + return { + ok: (opts.status ?? 200) >= 200 && (opts.status ?? 200) < 300, + status: opts.status ?? 200, + headers: { + get: (name: string) => headerMap.get(name.toLowerCase()) ?? null, + getSetCookie: () => opts.setCookies ?? [], + }, + json: async () => opts.json ?? {}, + text: async () => JSON.stringify(opts.json ?? ''), + } as unknown as Response +} + +describe('authentikPasswordLogin()', () => { + const savedEnv = { ...process.env } + let fetchMock: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + process.env.AUTHENTIK_ISSUER_URL = + 'http://auth.example.test/application/o/fuzefront/' + process.env.AUTHENTIK_REDIRECT_URI = REDIRECT_URI + delete process.env.AUTHENTIK_BASE_URL + delete process.env.AUTHENTIK_AUTH_FLOW_SLUG + ;(oidcService.isConfigured as jest.Mock).mockReturnValue(true) + ;(oidcService.isInitialized as jest.Mock).mockReturnValue(true) + fetchMock = jest.fn() + ;(global as any).fetch = fetchMock + }) + + afterAll(() => { + process.env = savedEnv + }) + + it('drives identification → password → redirect, then exchanges the authorize code', async () => { + fetchMock + // 1. GET flow → identification stage (+ CSRF cookie) + .mockResolvedValueOnce( + mkRes({ + json: { component: 'ak-stage-identification', password_fields: false }, + setCookies: ['authentik_csrf=csrf-tok; Path=/'], + }) + ) + // 2. POST identification → password stage + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + // 3. POST password → flow complete (+ session cookie) + .mockResolvedValueOnce( + mkRes({ + json: { component: 'xak-flow-redirect', to: '/' }, + setCookies: ['authentik_session=sess-1; Path=/; HttpOnly'], + }) + ) + // 4. GET authorize → 302 straight to our callback with the code + .mockResolvedValueOnce( + mkRes({ + status: 302, + location: `${REDIRECT_URI}?code=the-code&state=st`, + }) + ) + + const user = await authentikPasswordLogin('e2e@test.local', 'pw123') + + expect(user.email).toBe('e2e@test.local') + expect(oidcService.handleCallback).toHaveBeenCalledWith( + 'the-code', + 'st', + 'test-code-verifier' + ) + + // Identification POST carried the uid_field + CSRF header + cookie jar. + const [, identInit] = fetchMock.mock.calls[1] + expect(JSON.parse(identInit.body)).toMatchObject({ + component: 'ak-stage-identification', + uid_field: 'e2e@test.local', + }) + expect(identInit.headers['X-CSRFToken']).toBe('csrf-tok') + + // Authorize GET presented the authenticated session cookie. + const [authorizeUrl, authorizeInit] = fetchMock.mock.calls[3] + expect(authorizeUrl).toContain('/application/o/authorize/') + expect(authorizeInit.headers.Cookie).toContain('authentik_session=sess-1') + }) + + it('follows the session-establishing 302 before the first challenge', async () => { + fetchMock + // initial GET -> 302 back into the flow, setting session + csrf cookies + .mockResolvedValueOnce( + mkRes({ + status: 302, + location: + 'http://auth.example.test/api/v3/flows/executor/default-authentication-flow/?query=', + setCookies: [ + 'authentik_session=pre-sess; Path=/', + 'authentik_csrf=csrf-tok; Path=/', + ], + }) + ) + // redirected GET -> identification challenge + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + .mockResolvedValueOnce( + mkRes({ status: 302, location: `${REDIRECT_URI}?code=c3&state=st` }) + ) + + const user = await authentikPasswordLogin('e2e@test.local', 'pw123') + expect(user.email).toBe('e2e@test.local') + + // The identification POST happened AFTER the redirect hop, with cookies. + const [identUrl, identInit] = fetchMock.mock.calls[2] + expect(identUrl).toContain('/flows/executor/') + expect(identInit.headers.Cookie).toContain('authentik_session=pre-sess') + expect(identInit.headers['X-CSRFToken']).toBe('csrf-tok') + }) + + it('supports a combined identification+password stage (password_fields: true)', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ + json: { component: 'ak-stage-identification', password_fields: true }, + }) + ) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + .mockResolvedValueOnce( + mkRes({ status: 302, location: `${REDIRECT_URI}?code=c2&state=st` }) + ) + + await authentikPasswordLogin('e2e@test.local', 'pw123') + + const [, identInit] = fetchMock.mock.calls[1] + expect(JSON.parse(identInit.body)).toMatchObject({ + uid_field: 'e2e@test.local', + password: 'pw123', + }) + }) + + it('throws InvalidCredentialsError when the password stage reports response_errors', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ + json: { + component: 'ak-stage-password', + response_errors: { + password: [{ string: 'Invalid password', code: 'invalid' }], + }, + }, + }) + ) + + await expect( + authentikPasswordLogin('e2e@test.local', 'wrong') + ).rejects.toBeInstanceOf(InvalidCredentialsError) + expect(oidcService.handleCallback).not.toHaveBeenCalled() + }) + + it('maps a 4xx JSON flow response carrying response_errors to InvalidCredentialsError', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + // Authentik rejects the credentials with an HTTP 400 + JSON errors body + .mockResolvedValueOnce( + mkRes({ + status: 400, + json: { + component: 'ak-stage-password', + response_errors: { + password: [{ string: 'Invalid password', code: 'invalid' }], + }, + }, + }) + ) + + await expect( + authentikPasswordLogin('e2e@test.local', 'wrong') + ).rejects.toBeInstanceOf(InvalidCredentialsError) + }) + + it('throws InvalidCredentialsError on an access-denied stage', async () => { + fetchMock.mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-access-denied' } }) + ) + + await expect( + authentikPasswordLogin('nobody@test.local', 'pw') + ).rejects.toBeInstanceOf(InvalidCredentialsError) + }) + + it('fails closed on stages it cannot drive (e.g. MFA)', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-authenticator-validate' } }) + ) + + await expect( + authentikPasswordLogin('mfa@test.local', 'pw') + ).rejects.toBeInstanceOf(UnsupportedFlowStageError) + }) + + it('refuses to follow an off-origin redirect with session cookies', async () => { + fetchMock.mockResolvedValueOnce( + mkRes({ + status: 302, + location: 'http://evil.example.net/steal', + setCookies: ['authentik_session=sess; Path=/'], + }) + ) + + await expect( + authentikPasswordLogin('e2e@test.local', 'pw') + ).rejects.toBeInstanceOf(AuthentikUnavailableError) + // No request was made to the off-origin host. + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('throws AuthentikUnavailableError when Authentik is unreachable', async () => { + fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED')) + + await expect( + authentikPasswordLogin('e2e@test.local', 'pw') + ).rejects.toBeInstanceOf(AuthentikUnavailableError) + }) + + it('throws AuthentikUnavailableError when OIDC is not initialized', async () => { + ;(oidcService.isInitialized as jest.Mock).mockReturnValue(false) + + await expect( + authentikPasswordLogin('e2e@test.local', 'pw') + ).rejects.toBeInstanceOf(AuthentikUnavailableError) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fails when authorize renders a flow UI instead of redirecting (consent required)', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + // authorize returns 200 HTML (no Location) — consent flow not implicit + .mockResolvedValueOnce(mkRes({ status: 200 })) + + await expect( + authentikPasswordLogin('e2e@test.local', 'pw') + ).rejects.toBeInstanceOf(UnsupportedFlowStageError) + }) + + // ── Whole-request budget ────────────────────────────────────────────────── + // + // The per-hop timeout bounds each individual fetch, but a login is a CHAIN. + // Every hop used to get a FRESH full-length budget, so the server's worst + // case ran far past the browser's own LOGIN_TIMEOUT_MS — the client aborted + // first and the user got a bare "timeout of 15000ms exceeded" with no status + // and no message, while the labelled server-side diagnostics never got to + // exist. These pin the chain-level bound that makes the server answer first. + + it('abandons the chain with a labelled error once the whole-request budget is spent', async () => { + process.env.AUTHENTIK_LOGIN_DEADLINE_MS = '150' + jest.resetModules() + const { authentikPasswordLogin: login, AuthentikUnavailableError: Unavailable } = + require('../src/services/authentikPassword') + + // Each hop is individually well under the per-hop cap; it is their SUM + // that blows the budget. Authentik keeps redirecting inside the flow. + fetchMock.mockImplementation( + async () => + new Promise(resolve => + setTimeout( + () => + resolve( + mkRes({ + status: 302, + location: + 'http://auth.example.test/api/v3/flows/executor/default-authentication-flow/?query=', + }) + ), + 60 + ) + ) + ) + + const err = await login('e2e@test.local', 'pw').catch((e: Error) => e) + + expect(err).toBeInstanceOf(Unavailable) + // The message names the budget and the stage, so prod logs point at the + // stall instead of the client reporting an anonymous abort. + expect((err as Error).message).toMatch(/150ms budget before flow\.step/) + delete process.env.AUTHENTIK_LOGIN_DEADLINE_MS + }) + + it('clamps a hop to the time remaining rather than giving it a fresh full timeout', async () => { + process.env.AUTHENTIK_LOGIN_DEADLINE_MS = '400' + process.env.AUTHENTIK_FLOW_TIMEOUT_MS = '10000' + jest.resetModules() + const { authentikPasswordLogin: login } = require('../src/services/authentikPassword') + + // First hop burns most of the budget, then a hop hangs forever. Without + // clamping, the hang would get the full 10s per-hop cap and outlive the + // browser; with it, the abort fires within what is LEFT of the 400ms. + fetchMock + .mockImplementationOnce( + async () => + new Promise(resolve => + setTimeout( + () => resolve(mkRes({ json: { component: 'ak-stage-identification' } })), + 250 + ) + ) + ) + .mockImplementationOnce( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + const e = new Error('aborted') + e.name = 'AbortError' + reject(e) + }) + }) + ) + + const started = Date.now() + await expect(login('e2e@test.local', 'pw')).rejects.toThrow(/timed out/) + // Comfortably below the 10s per-hop cap — proof the clamp, not the cap, won. + expect(Date.now() - started).toBeLessThan(3000) + + delete process.env.AUTHENTIK_LOGIN_DEADLINE_MS + delete process.env.AUTHENTIK_FLOW_TIMEOUT_MS + }) + + it('bounds the token exchange by the remaining budget, not openid-client’s own timeout', async () => { + // handleCallback is openid-client's stage: OIDC_HTTP_TIMEOUT_MS (15s) per + // call, twice (token + userinfo). Left alone it outlasts the whole login + // budget on its own and the browser aborts first again. + process.env.AUTHENTIK_LOGIN_DEADLINE_MS = '300' + jest.resetModules() + const { authentikPasswordLogin: login } = require('../src/services/authentikPassword') + const { oidcService: oidc } = require('../src/services/oidc') + + oidc.handleCallback.mockReturnValueOnce(new Promise(() => {})) // never settles + + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + .mockResolvedValueOnce( + mkRes({ status: 302, location: `${REDIRECT_URI}?code=c8&state=st` }) + ) + + const started = Date.now() + await expect(login('e2e@test.local', 'pw')).rejects.toThrow( + /oidc\.tokenExchange exceeded the remaining/ + ) + // Well inside the browser's 15s bound instead of openid-client's 2x15s. + expect(Date.now() - started).toBeLessThan(3000) + + delete process.env.AUTHENTIK_LOGIN_DEADLINE_MS + }) + + it('reports a slow hop at WARN, naming the stage, on a login that still succeeds', async () => { + // The per-hop timings existed only at logger.debug, and LOG_LEVEL defaults + // to info in prod — so the one piece of evidence needed to find the slow + // hop was switched off exactly when it mattered. A slow SUCCESS must be + // visible without a LOG_LEVEL change or a redeploy. + process.env.AUTHENTIK_SLOW_HOP_WARN_MS = '50' + jest.resetModules() + const { authentikPasswordLogin: login } = require('../src/services/authentikPassword') + const { logger } = require('../src/lib/logger') + const warn = jest.spyOn(logger, 'warn').mockImplementation(() => undefined) + + const slow = (res: Response) => + new Promise(resolve => setTimeout(() => resolve(res), 80)) + + fetchMock + .mockImplementationOnce(() => + slow(mkRes({ json: { component: 'ak-stage-identification' } })) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + .mockResolvedValueOnce( + mkRes({ status: 302, location: `${REDIRECT_URI}?code=c7&state=st` }) + ) + + // The login SUCCEEDS — the warning is the whole point, not an error path. + await expect(login('e2e@test.local', 'pw')).resolves.toBeDefined() + + const slowHops = warn.mock.calls.filter( + ([, msg]) => msg === 'authentikPassword: SLOW hop' + ) + expect(slowHops).toHaveLength(1) + // Names the exact stage, so prod logs point at the culprit directly. + expect((slowHops[0][0] as any).label).toMatch(/flow\.step .*hop=0 GET/) + expect((slowHops[0][0] as any).elapsedMs).toBeGreaterThanOrEqual(50) + + warn.mockRestore() + delete process.env.AUTHENTIK_SLOW_HOP_WARN_MS + }) + + it('releases the response body of every hop it only reads headers from', async () => { + // undici keeps the socket checked out until the body is consumed or + // cancelled. Redirect hops read only `location`, and authorize hops never + // read a body at all — so an un-cancelled body pins that connection for the + // rest of the request and later hops queue behind their own predecessors. + const cancels: string[] = [] + const withBody = (label: string, res: Response) => + Object.defineProperty(res, 'body', { + value: { + cancel: async () => { + cancels.push(label) + }, + }, + configurable: true, + }) + + fetchMock + // flow hop that only yields a Location + .mockResolvedValueOnce( + withBody( + 'flow-redirect', + mkRes({ + status: 302, + location: + 'http://auth.example.test/api/v3/flows/executor/default-authentication-flow/?query=', + }) + ) + ) + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + // authorize hop — body never read on any path + .mockResolvedValueOnce( + withBody( + 'authorize', + mkRes({ status: 302, location: `${REDIRECT_URI}?code=c9&state=st` }) + ) + ) + + await authentikPasswordLogin('e2e@test.local', 'pw123') + + expect(cancels).toEqual(['flow-redirect', 'authorize']) + }) +}) diff --git a/backend/security/tests/authentik-signup.test.ts b/backend/security/tests/authentik-signup.test.ts index 662b81a8..b51a91e6 100644 --- a/backend/security/tests/authentik-signup.test.ts +++ b/backend/security/tests/authentik-signup.test.ts @@ -1,139 +1,149 @@ -/** - * Unit tests for server-side Authentik ENROLLMENT signup - * (services/authentikPassword.ts → authentikSignup). - * - * The enrollment flow-executor conversation and the authorize redirect are - * simulated by mocking global.fetch — no network. OIDC pieces (authorize URL, - * token exchange / user sync) are mocked at the oidcService boundary, exactly - * like the password-login test. The point: signup drives AUTHENTIK enrollment - * (no local bcrypt user) then completes the SAME OIDC sync as login. - */ -jest.mock('../src/services/oidc', () => ({ - oidcService: { - isConfigured: jest.fn().mockReturnValue(true), - isInitialized: jest.fn().mockReturnValue(true), - generateAuthUrl: jest.fn().mockReturnValue({ - url: 'http://auth.example.test/application/o/authorize/?client_id=x&state=st', - codeVerifier: 'test-code-verifier', - }), - handleCallback: jest.fn().mockResolvedValue({ - id: 'new-user-1', - email: 'signup@test.local', - firstName: 'New', - lastName: 'User', - roles: ['user'], - }), - }, -})) - -import { - authentikSignup, - EnrollmentConflictError, - UnsupportedFlowStageError, - InvalidCredentialsError, -} from '../src/services/authentikPassword' -import { oidcService } from '../src/services/oidc' - -const REDIRECT_URI = 'http://fuzefront.test.local/api/auth/oidc/callback' - -function mkRes(opts: { status?: number; json?: unknown; setCookies?: string[]; location?: string }) { - const headerMap = new Map() - if (opts.location) headerMap.set('location', opts.location) - if (opts.json !== undefined) headerMap.set('content-type', 'application/json') - return { - ok: (opts.status ?? 200) >= 200 && (opts.status ?? 200) < 300, - status: opts.status ?? 200, - headers: { - get: (name: string) => headerMap.get(name.toLowerCase()) ?? null, - getSetCookie: () => opts.setCookies ?? [], - }, - json: async () => opts.json ?? {}, - text: async () => JSON.stringify(opts.json ?? ''), - } as unknown as Response -} - -describe('authentikSignup()', () => { - const savedEnv = { ...process.env } - let fetchMock: jest.Mock - - beforeEach(() => { - jest.clearAllMocks() - process.env.AUTHENTIK_ISSUER_URL = 'http://auth.example.test/application/o/fuzefront/' - process.env.AUTHENTIK_REDIRECT_URI = REDIRECT_URI - delete process.env.AUTHENTIK_BASE_URL - delete process.env.AUTHENTIK_ENROLLMENT_FLOW_SLUG - ;(oidcService.isConfigured as jest.Mock).mockReturnValue(true) - ;(oidcService.isInitialized as jest.Mock).mockReturnValue(true) - fetchMock = jest.fn() - ;(global as any).fetch = fetchMock - }) - - afterAll(() => { - process.env = savedEnv - }) - - it('drives the prompt stage then completes OIDC sync (no local bcrypt user)', async () => { - fetchMock - // 1. GET enrollment flow → prompt stage (+ CSRF cookie) - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-prompt' }, setCookies: ['authentik_csrf=csrf-tok; Path=/'] }) - ) - // 2. POST prompt → flow complete (auto user-write + user-login) - .mockResolvedValueOnce( - mkRes({ json: { component: 'xak-flow-redirect', to: '/' }, setCookies: ['authentik_session=sess-1; Path=/'] }) - ) - // 3. GET authorize → 302 to our callback with the code - .mockResolvedValueOnce(mkRes({ status: 302, location: `${REDIRECT_URI}?code=the-code&state=st` })) - - const user = await authentikSignup({ email: 'signup@test.local', password: 'Sup3rSecret!!', firstName: 'New', lastName: 'User' }) - - expect(user.email).toBe('signup@test.local') - expect(oidcService.handleCallback).toHaveBeenCalledWith('the-code', 'st', 'test-code-verifier') - - // The prompt POST carried the enrollment fields + CSRF + hit the enrollment slug. - const [promptUrl, promptInit] = fetchMock.mock.calls[1] - expect(promptUrl).toContain('/api/v3/flows/executor/fuzefront-enrollment/') - const body = JSON.parse(promptInit.body) - expect(body).toMatchObject({ - component: 'ak-stage-prompt', - email: 'signup@test.local', - password: 'Sup3rSecret!!', - password_repeat: 'Sup3rSecret!!', - }) - expect(body.username).toBeTruthy() - expect(promptInit.headers['X-CSRFToken']).toBe('csrf-tok') - }) - - it('maps an "already exists" response_error to EnrollmentConflictError', async () => { - fetchMock - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-prompt' } })) - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-prompt', response_errors: { username: [{ string: 'User with this username already exists.', code: 'unique' }] } } }) - ) - - await expect( - authentikSignup({ email: 'dup@test.local', password: 'Sup3rSecret!!' }) - ).rejects.toBeInstanceOf(EnrollmentConflictError) - expect(oidcService.handleCallback).not.toHaveBeenCalled() - }) - - it('maps a password-policy rejection to InvalidCredentialsError (not a conflict)', async () => { - fetchMock - .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-prompt' } })) - .mockResolvedValueOnce( - mkRes({ json: { component: 'ak-stage-prompt', response_errors: { password: [{ string: 'Password too short', code: 'invalid' }] } } }) - ) - - await expect( - authentikSignup({ email: 'weak@test.local', password: 'x' }) - ).rejects.toBeInstanceOf(InvalidCredentialsError) - }) - - it('fails closed on an unsupported stage (e.g. captcha / email-verify)', async () => { - fetchMock.mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-captcha' } })) - - await expect( - authentikSignup({ email: 'bot@test.local', password: 'Sup3rSecret!!' }) - ).rejects.toBeInstanceOf(UnsupportedFlowStageError) - }) -}) +/** + * Unit tests for server-side Authentik ENROLLMENT signup + * (services/authentikPassword.ts → authentikSignup). + * + * The enrollment flow-executor conversation and the authorize redirect are + * simulated by mocking global.fetch — no network. OIDC pieces (authorize URL, + * token exchange / user sync) are mocked at the oidcService boundary, exactly + * like the password-login test. The point: signup drives AUTHENTIK enrollment + * (no local bcrypt user) then completes the SAME OIDC sync as login. + */ +jest.mock('../src/services/oidc', () => { + // getOidcService() replaced the former `oidcService` singleton (the client is + // now resolved per tenant). Expose both, backed by the SAME object, so the + // assertions below still address what the code under test receives. + const mod: any = ({ + oidcService: { + isConfigured: jest.fn().mockReturnValue(true), + isInitialized: jest.fn().mockReturnValue(true), + generateAuthUrl: jest.fn().mockReturnValue({ + url: 'http://auth.example.test/application/o/authorize/?client_id=x&state=st', + codeVerifier: 'test-code-verifier', + }), + handleCallback: jest.fn().mockResolvedValue({ + id: 'new-user-1', + email: 'signup@test.local', + firstName: 'New', + lastName: 'User', + roles: ['user'], + }), + }, +}) + mod.getOidcService = () => mod.oidcService + return mod +}) + +import { + authentikSignup, + EnrollmentConflictError, + UnsupportedFlowStageError, + InvalidCredentialsError, +} from '../src/services/authentikPassword' +import { getOidcService } from '../src/services/oidc' + +/** The mocked client the code under test resolves via getOidcService(). */ +const oidcService: any = getOidcService() + +const REDIRECT_URI = 'http://fuzefront.test.local/api/auth/oidc/callback' + +function mkRes(opts: { status?: number; json?: unknown; setCookies?: string[]; location?: string }) { + const headerMap = new Map() + if (opts.location) headerMap.set('location', opts.location) + if (opts.json !== undefined) headerMap.set('content-type', 'application/json') + return { + ok: (opts.status ?? 200) >= 200 && (opts.status ?? 200) < 300, + status: opts.status ?? 200, + headers: { + get: (name: string) => headerMap.get(name.toLowerCase()) ?? null, + getSetCookie: () => opts.setCookies ?? [], + }, + json: async () => opts.json ?? {}, + text: async () => JSON.stringify(opts.json ?? ''), + } as unknown as Response +} + +describe('authentikSignup()', () => { + const savedEnv = { ...process.env } + let fetchMock: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + process.env.AUTHENTIK_ISSUER_URL = 'http://auth.example.test/application/o/fuzefront/' + process.env.AUTHENTIK_REDIRECT_URI = REDIRECT_URI + delete process.env.AUTHENTIK_BASE_URL + delete process.env.AUTHENTIK_ENROLLMENT_FLOW_SLUG + ;(oidcService.isConfigured as jest.Mock).mockReturnValue(true) + ;(oidcService.isInitialized as jest.Mock).mockReturnValue(true) + fetchMock = jest.fn() + ;(global as any).fetch = fetchMock + }) + + afterAll(() => { + process.env = savedEnv + }) + + it('drives the prompt stage then completes OIDC sync (no local bcrypt user)', async () => { + fetchMock + // 1. GET enrollment flow → prompt stage (+ CSRF cookie) + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-prompt' }, setCookies: ['authentik_csrf=csrf-tok; Path=/'] }) + ) + // 2. POST prompt → flow complete (auto user-write + user-login) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' }, setCookies: ['authentik_session=sess-1; Path=/'] }) + ) + // 3. GET authorize → 302 to our callback with the code + .mockResolvedValueOnce(mkRes({ status: 302, location: `${REDIRECT_URI}?code=the-code&state=st` })) + + const user = await authentikSignup({ email: 'signup@test.local', password: 'Sup3rSecret!!', firstName: 'New', lastName: 'User' }) + + expect(user.email).toBe('signup@test.local') + expect(oidcService.handleCallback).toHaveBeenCalledWith('the-code', 'st', 'test-code-verifier') + + // The prompt POST carried the enrollment fields + CSRF + hit the enrollment slug. + const [promptUrl, promptInit] = fetchMock.mock.calls[1] + expect(promptUrl).toContain('/api/v3/flows/executor/fuzefront-enrollment/') + const body = JSON.parse(promptInit.body) + expect(body).toMatchObject({ + component: 'ak-stage-prompt', + email: 'signup@test.local', + password: 'Sup3rSecret!!', + password_repeat: 'Sup3rSecret!!', + }) + expect(body.username).toBeTruthy() + expect(promptInit.headers['X-CSRFToken']).toBe('csrf-tok') + }) + + it('maps an "already exists" response_error to EnrollmentConflictError', async () => { + fetchMock + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-prompt' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-prompt', response_errors: { username: [{ string: 'User with this username already exists.', code: 'unique' }] } } }) + ) + + await expect( + authentikSignup({ email: 'dup@test.local', password: 'Sup3rSecret!!' }) + ).rejects.toBeInstanceOf(EnrollmentConflictError) + expect(oidcService.handleCallback).not.toHaveBeenCalled() + }) + + it('maps a password-policy rejection to InvalidCredentialsError (not a conflict)', async () => { + fetchMock + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-prompt' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-prompt', response_errors: { password: [{ string: 'Password too short', code: 'invalid' }] } } }) + ) + + await expect( + authentikSignup({ email: 'weak@test.local', password: 'x' }) + ).rejects.toBeInstanceOf(InvalidCredentialsError) + }) + + it('fails closed on an unsupported stage (e.g. captcha / email-verify)', async () => { + fetchMock.mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-captcha' } })) + + await expect( + authentikSignup({ email: 'bot@test.local', password: 'Sup3rSecret!!' }) + ).rejects.toBeInstanceOf(UnsupportedFlowStageError) + }) +}) diff --git a/backend/security/tests/oidc-code-exchange.test.ts b/backend/security/tests/oidc-code-exchange.test.ts index a1dbd135..50acc3d3 100644 --- a/backend/security/tests/oidc-code-exchange.test.ts +++ b/backend/security/tests/oidc-code-exchange.test.ts @@ -1,106 +1,113 @@ -/** - * Unit tests for OIDC code-exchange endpoint. - * Verifies that /oidc/callback issues a short-lived opaque code (not a token in URL), - * and that POST /api/auth/token-exchange redeems it exactly once within the TTL. - */ -import express from 'express' -import request from 'supertest' - -jest.mock('../src/services/oidc', () => ({ - oidcService: { - isConfigured: () => true, - isInitialized: () => true, - ensureInitialized: jest.fn().mockResolvedValue(undefined), - generateAuthUrl: jest.fn().mockReturnValue({ url: 'http://auth.example.com/auth?state=test-state', codeVerifier: 'mock-code-verifier' }), - handleCallback: jest.fn().mockResolvedValue({ id: 'u1', email: 'u@e.com', firstName: 'U', lastName: 'E', roles: ['user'] }) - } -})) - -jest.mock('../src/config/database', () => ({ - db: Object.assign( - jest.fn().mockReturnValue({ insert: jest.fn().mockResolvedValue([]) }), - { transaction: jest.fn(), insert: jest.fn().mockResolvedValue([]) } - ) -})) - -jest.mock('jsonwebtoken', () => ({ - sign: jest.fn().mockReturnValue('mock-jwt-token'), - verify: jest.fn() -})) - -jest.mock('../src/services/organizationProvisioning', () => ({ - runInternalProvision: jest.fn().mockResolvedValue(undefined) -})) - -jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('mock-session-uuid') })) - -import authRouter from '../src/routes/auth' - -const app = express() -app.use(express.json()) -app.use('/api/auth', authRouter) - -describe('OIDC code-exchange endpoint', () => { - it('callback redirects with ?code= not ?token=', async () => { - const STATE = 'test-state' - const res = await request(app) - .get(`/api/auth/oidc/callback?code=authcode&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=mock-code-verifier`) - expect(res.status).toBe(302) - expect(res.headers.location).toMatch(/[?&]code=/) - expect(res.headers.location).not.toMatch(/[?&]token=/) - expect(res.headers.location).not.toMatch(/[?&]sessionId=/) - }) - - it('POST /token-exchange returns token and sessionId for valid code', async () => { - const STATE = 'test-state' - const cbRes = await request(app) - .get(`/api/auth/oidc/callback?code=authcode&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=mock-code-verifier`) - const location = cbRes.headers.location - const code = new URL(location).searchParams.get('code') - - const exRes = await request(app).post('/api/auth/token-exchange').send({ code }) - expect(exRes.status).toBe(200) - expect(exRes.body).toHaveProperty('token') - expect(exRes.body).toHaveProperty('sessionId') - }) - - it('second token-exchange with same code returns 401', async () => { - const STATE = 'test-state' - const cbRes = await request(app) - .get(`/api/auth/oidc/callback?code=authcode&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=mock-code-verifier`) - const code = new URL(cbRes.headers.location).searchParams.get('code') - - await request(app).post('/api/auth/token-exchange').send({ code }) - const second = await request(app).post('/api/auth/token-exchange').send({ code }) - expect(second.status).toBe(401) - }) - - it('expired code returns 401', async () => { - jest.useFakeTimers() - const STATE = 'test-state' - const cbRes = await request(app) - .get(`/api/auth/oidc/callback?code=authcode&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=mock-code-verifier`) - const code = new URL(cbRes.headers.location).searchParams.get('code') - - // Advance time past 60s TTL - jest.advanceTimersByTime(61_000) - - const exRes = await request(app).post('/api/auth/token-exchange').send({ code }) - expect(exRes.status).toBe(401) - jest.useRealTimers() - }) - - it('unknown code returns 401', async () => { - const res = await request(app).post('/api/auth/token-exchange').send({ code: 'nonexistent' }) - expect(res.status).toBe(401) - }) - - it('missing code body returns 400', async () => { - const res = await request(app).post('/api/auth/token-exchange').send({}) - expect(res.status).toBe(400) - }) -}) +/** + * Unit tests for OIDC code-exchange endpoint. + * Verifies that /oidc/callback issues a short-lived opaque code (not a token in URL), + * and that POST /api/auth/token-exchange redeems it exactly once within the TTL. + */ +import express from 'express' +import request from 'supertest' + +jest.mock('../src/services/oidc', () => { + // getOidcService() replaced the former `oidcService` singleton (the client is + // now resolved per tenant). Expose both, backed by the SAME object, so the + // assertions below still address what the code under test receives. + const mod: any = ({ + oidcService: { + isConfigured: () => true, + isInitialized: () => true, + ensureInitialized: jest.fn().mockResolvedValue(undefined), + generateAuthUrl: jest.fn().mockReturnValue({ url: 'http://auth.example.com/auth?state=test-state', codeVerifier: 'mock-code-verifier' }), + handleCallback: jest.fn().mockResolvedValue({ id: 'u1', email: 'u@e.com', firstName: 'U', lastName: 'E', roles: ['user'] }) + } +}) + mod.getOidcService = () => mod.oidcService + return mod +}) + +jest.mock('../src/config/database', () => ({ + db: Object.assign( + jest.fn().mockReturnValue({ insert: jest.fn().mockResolvedValue([]) }), + { transaction: jest.fn(), insert: jest.fn().mockResolvedValue([]) } + ) +})) + +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn().mockReturnValue('mock-jwt-token'), + verify: jest.fn() +})) + +jest.mock('../src/services/organizationProvisioning', () => ({ + runInternalProvision: jest.fn().mockResolvedValue(undefined) +})) + +jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('mock-session-uuid') })) + +import authRouter from '../src/routes/auth' + +const app = express() +app.use(express.json()) +app.use('/api/auth', authRouter) + +describe('OIDC code-exchange endpoint', () => { + it('callback redirects with ?code= not ?token=', async () => { + const STATE = 'test-state' + const res = await request(app) + .get(`/api/auth/oidc/callback?code=authcode&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=mock-code-verifier`) + expect(res.status).toBe(302) + expect(res.headers.location).toMatch(/[?&]code=/) + expect(res.headers.location).not.toMatch(/[?&]token=/) + expect(res.headers.location).not.toMatch(/[?&]sessionId=/) + }) + + it('POST /token-exchange returns token and sessionId for valid code', async () => { + const STATE = 'test-state' + const cbRes = await request(app) + .get(`/api/auth/oidc/callback?code=authcode&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=mock-code-verifier`) + const location = cbRes.headers.location + const code = new URL(location).searchParams.get('code') + + const exRes = await request(app).post('/api/auth/token-exchange').send({ code }) + expect(exRes.status).toBe(200) + expect(exRes.body).toHaveProperty('token') + expect(exRes.body).toHaveProperty('sessionId') + }) + + it('second token-exchange with same code returns 401', async () => { + const STATE = 'test-state' + const cbRes = await request(app) + .get(`/api/auth/oidc/callback?code=authcode&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=mock-code-verifier`) + const code = new URL(cbRes.headers.location).searchParams.get('code') + + await request(app).post('/api/auth/token-exchange').send({ code }) + const second = await request(app).post('/api/auth/token-exchange').send({ code }) + expect(second.status).toBe(401) + }) + + it('expired code returns 401', async () => { + jest.useFakeTimers() + const STATE = 'test-state' + const cbRes = await request(app) + .get(`/api/auth/oidc/callback?code=authcode&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=mock-code-verifier`) + const code = new URL(cbRes.headers.location).searchParams.get('code') + + // Advance time past 60s TTL + jest.advanceTimersByTime(61_000) + + const exRes = await request(app).post('/api/auth/token-exchange').send({ code }) + expect(exRes.status).toBe(401) + jest.useRealTimers() + }) + + it('unknown code returns 401', async () => { + const res = await request(app).post('/api/auth/token-exchange').send({ code: 'nonexistent' }) + expect(res.status).toBe(401) + }) + + it('missing code body returns 400', async () => { + const res = await request(app).post('/api/auth/token-exchange').send({}) + expect(res.status).toBe(400) + }) +}) diff --git a/backend/security/tests/oidc-google-signin.test.ts b/backend/security/tests/oidc-google-signin.test.ts index 0b64bf9f..5c48df3e 100644 --- a/backend/security/tests/oidc-google-signin.test.ts +++ b/backend/security/tests/oidc-google-signin.test.ts @@ -1,766 +1,769 @@ -/** - * Backend unit and integration tests for the Google Sign-In (Authentik OIDC) flow. - * - * Architecture of the flow: - * User → FuzeFront /oidc/login → Authentik (OIDC/PKCE with code_challenge) - * → Authentik shows "Sign in with Google" → Google authenticates the user - * → Authentik receives Google tokens → Authentik issues code to FuzeFront callback - * → FuzeFront /oidc/callback → issues short-lived exchange code → frontend - * → frontend POST /token-exchange → JWT + sessionId - * - * FuzeFront never contacts Google directly. All Google auth is inside Authentik. - * - * Coverage: - * 1. OIDCService unit tests (mocked openid-client, db, eventPublisher) - * 2. syncUserToDatabase semantics — new user, existing user (link_by_email), Kafka fail - * 3. Route integration tests (spied oidcService, mocked db) - * - * Does NOT duplicate tests already in: - * - oidc-state.test.ts (PKCE/CSRF cookie state checks) - * - oidc-code-exchange.test.ts (single-use / expiry of exchange code) - */ -import express from 'express' -import request from 'supertest' - -// ─── Module-level mocks (hoisted by jest before imports) ─────────────────── - -// Prevent any outbound network calls to Authentik or Google -jest.mock('openid-client', () => ({ - Issuer: { - discover: jest.fn(), - }, - generators: { - codeVerifier: jest.fn().mockReturnValue('mock-code-verifier'), - codeChallenge: jest.fn().mockReturnValue('mock-code-challenge'), - state: jest.fn().mockReturnValue('mock-oidc-state'), - }, -})) - -// Prevent Kafka connection attempts (best-effort publish path in syncUserToDatabase) -jest.mock('../src/services/eventPublisher', () => ({ - defaultEventPublisher: { - publishIdentityUserCreated: jest.fn().mockResolvedValue(undefined), - publishNotifyEmailRequested: jest.fn().mockResolvedValue(undefined), - }, -})) - -// Run without a live Postgres instance -jest.mock('../src/config/database', () => ({ - db: Object.assign(jest.fn(), { - transaction: jest.fn(), - }), -})) - -// Predictable JWTs in route tests -jest.mock('jsonwebtoken', () => ({ - sign: jest.fn().mockReturnValue('mock.jwt.token'), - verify: jest.fn(), -})) - -// Predictable UUIDs (session IDs in route tests) -jest.mock('uuid', () => ({ - v4: jest.fn().mockReturnValue('mock-uuid-1234'), -})) - -// Fire-and-forget provisioning — skip DB work -jest.mock('../src/services/organizationProvisioning', () => ({ - runInternalProvision: jest.fn().mockResolvedValue(undefined), -})) - -// ─── Imports (after mock declarations) ───────────────────────────────────── -import { oidcService } from '../src/services/oidc' -import { db } from '../src/config/database' -import { defaultEventPublisher } from '../src/services/eventPublisher' -import authRouter from '../src/routes/auth' - -// Typed handle to the db mock function -const dbFn = db as jest.MockedFunction - -// Build a minimal Express app mounting the auth routes -function buildApp(): express.Application { - const app = express() - app.use(express.json()) - app.use('/api/auth', authRouter) - return app -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 1. OIDCService.isConfigured() -// ═══════════════════════════════════════════════════════════════════════════ - -describe('OIDCService.isConfigured()', () => { - let savedClientId: string - let savedClientSecret: string - - beforeEach(() => { - savedClientId = (oidcService as any).config.clientId - savedClientSecret = (oidcService as any).config.clientSecret - }) - - afterEach(() => { - ;(oidcService as any).config.clientId = savedClientId - ;(oidcService as any).config.clientSecret = savedClientSecret - }) - - it('returns false when AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET are absent', () => { - ;(oidcService as any).config.clientId = '' - ;(oidcService as any).config.clientSecret = '' - expect(oidcService.isConfigured()).toBe(false) - }) - - it('returns true when both AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET are set', () => { - ;(oidcService as any).config.clientId = 'test-client-id' - ;(oidcService as any).config.clientSecret = 'test-client-secret' - expect(oidcService.isConfigured()).toBe(true) - }) - - it('returns false when only client ID is set (secret missing)', () => { - ;(oidcService as any).config.clientId = 'test-client-id' - ;(oidcService as any).config.clientSecret = '' - expect(oidcService.isConfigured()).toBe(false) - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 2. OIDCService.generateAuthUrl() -// ═══════════════════════════════════════════════════════════════════════════ - -describe('OIDCService.generateAuthUrl()', () => { - const mockAuthUrl = - 'http://mock-authentik/application/o/fuzefront/authorize?' + - 'code_challenge=mock-code-challenge&code_challenge_method=S256&state=test-state' - - const mockClient = { - authorizationUrl: jest.fn().mockReturnValue(mockAuthUrl), - callback: jest.fn(), - userinfo: jest.fn(), - } - - beforeEach(() => { - ;(oidcService as any).client = mockClient - mockClient.authorizationUrl.mockReturnValue(mockAuthUrl) - }) - - afterEach(() => { - ;(oidcService as any).client = null - }) - - it('returns an object with both url and codeVerifier properties', () => { - const result = oidcService.generateAuthUrl('test-state') - expect(result).toHaveProperty('url') - expect(result).toHaveProperty('codeVerifier') - }) - - it('codeVerifier is a non-empty string (stateless — caller persists it in cookie)', () => { - const { codeVerifier } = oidcService.generateAuthUrl('test-state') - expect(typeof codeVerifier).toBe('string') - expect(codeVerifier.length).toBeGreaterThan(0) - }) - - it('url contains the PKCE code_challenge parameter', () => { - const { url } = oidcService.generateAuthUrl('test-state') - expect(url).toContain('code_challenge=') - }) - - it('calls client.authorizationUrl with S256 code_challenge_method', () => { - oidcService.generateAuthUrl('test-state') - expect(mockClient.authorizationUrl).toHaveBeenCalledWith( - expect.objectContaining({ code_challenge_method: 'S256' }) - ) - }) - - it('throws when the openid-client has not been initialized', () => { - ;(oidcService as any).client = null - expect(() => oidcService.generateAuthUrl('test-state')).toThrow( - /not initialized/i - ) - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 3. OIDCService.handleCallback() -// ═══════════════════════════════════════════════════════════════════════════ - -describe('OIDCService.handleCallback()', () => { - const mockUserInfo = { - sub: 'google-sub-handlecb-001', - email: 'handlecb@oidctest.example.com', - given_name: 'Handle', - family_name: 'Callback', - } - - const mockClient = { - authorizationUrl: jest.fn(), - callback: jest.fn().mockResolvedValue({ access_token: 'mock-at-001' }), - userinfo: jest.fn().mockResolvedValue(mockUserInfo), - } - - beforeEach(() => { - jest.clearAllMocks() - ;(oidcService as any).client = mockClient - mockClient.callback.mockResolvedValue({ access_token: 'mock-at-001' }) - mockClient.userinfo.mockResolvedValue(mockUserInfo) - - // syncUserToDatabase: new user path - const trxInsert = jest.fn().mockResolvedValue([]) - const trx = jest.fn().mockReturnValue({ insert: trxInsert }) - ;(dbFn as any).transaction = jest.fn().mockImplementation( - async (cb: Function) => cb(trx) - ) - const qb = { - where: jest.fn().mockReturnThis(), - first: jest.fn().mockResolvedValue(null), // no existing user - update: jest.fn().mockResolvedValue([]), - } - dbFn.mockReturnValue(qb) - }) - - afterEach(() => { - ;(oidcService as any).client = null - }) - - it('throws when codeVerifier is an empty string', async () => { - await expect( - oidcService.handleCallback('auth-code', 'some-state', '') - ).rejects.toThrow(/code verifier not found/i) - }) - - it('calls openid-client callback with the correct PKCE verifier', async () => { - await oidcService.handleCallback('auth-code-xyz', 'state-abc', 'verifier-123') - expect(mockClient.callback).toHaveBeenCalledWith( - expect.any(String), // redirectUri - { code: 'auth-code-xyz', state: 'state-abc' }, - { code_verifier: 'verifier-123', state: 'state-abc' } - ) - }) - - it('calls userinfo with the access_token from the token set', async () => { - mockClient.callback.mockResolvedValue({ access_token: 'at-specific' }) - await oidcService.handleCallback('auth-code-xyz', 'state-abc', 'verifier-123') - expect(mockClient.userinfo).toHaveBeenCalledWith('at-specific') - }) - - it('returns a User with the correct shape from the userinfo claims', async () => { - const user = await oidcService.handleCallback('auth-code-xyz', 'state-abc', 'verifier-123') - expect(user).toMatchObject({ - email: mockUserInfo.email, - firstName: mockUserInfo.given_name, - lastName: mockUserInfo.family_name, - }) - expect(typeof user.id).toBe('string') - expect(Array.isArray(user.roles)).toBe(true) - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 4. syncUserToDatabase() — new Google-authenticated user -// ═══════════════════════════════════════════════════════════════════════════ - -describe('OIDCService syncUserToDatabase() — new Google-authenticated user', () => { - const newUserInfo = { - sub: 'google-sub-newuser-001', - email: 'newgoogleuser@oidctest.example.com', - given_name: 'New', - family_name: 'GoogleUser', - } - - let trxUserInsert: jest.Mock - let trxOutboxInsert: jest.Mock - - beforeEach(() => { - jest.clearAllMocks() - - trxUserInsert = jest.fn().mockResolvedValue([]) - trxOutboxInsert = jest.fn().mockResolvedValue([]) - - const trx = jest.fn().mockImplementation((table: string) => { - if (table === 'users') return { insert: trxUserInsert } - if (table === 'event_outbox') return { insert: trxOutboxInsert } - return { insert: jest.fn().mockResolvedValue([]) } - }) - - ;(dbFn as any).transaction = jest.fn().mockImplementation( - async (cb: Function) => cb(trx) - ) - - // db('users').where('email', ...).first() → null (no existing user) - // db('event_outbox').where(...).update(...) → success (after publish) - const qbDefault = { - where: jest.fn().mockReturnThis(), - first: jest.fn().mockResolvedValue(null), - update: jest.fn().mockResolvedValue([]), - } - dbFn.mockReturnValue(qbDefault) - }) - - it('runs a transaction that inserts a new user row', async () => { - await (oidcService as any).syncUserToDatabase(newUserInfo) - expect((dbFn as any).transaction).toHaveBeenCalledTimes(1) - expect(trxUserInsert).toHaveBeenCalledTimes(1) - const [insertedRow] = trxUserInsert.mock.calls[0] - expect(insertedRow).toMatchObject({ - email: newUserInfo.email, - first_name: newUserInfo.given_name, - last_name: newUserInfo.family_name, - }) - }) - - it('inserts an event_outbox row with topic "identity.user.created" in the same transaction', async () => { - await (oidcService as any).syncUserToDatabase(newUserInfo) - expect(trxOutboxInsert).toHaveBeenCalledTimes(1) - const [outboxRow] = trxOutboxInsert.mock.calls[0] - expect(outboxRow.topic).toBe('identity.user.created') - expect(outboxRow.status).toBe('pending') - expect(outboxRow.correlation_id).toMatch(/^identity-/) - }) - - it('event_outbox payload encodes the correct user fields', async () => { - await (oidcService as any).syncUserToDatabase(newUserInfo) - const [outboxRow] = trxOutboxInsert.mock.calls[0] - const payload = JSON.parse(outboxRow.payload) - expect(payload).toMatchObject({ - email: newUserInfo.email, - firstName: newUserInfo.given_name, - lastName: newUserInfo.family_name, - intent: 'signup', - }) - }) - - it('publishes identity.user.created via the event publisher', async () => { - await (oidcService as any).syncUserToDatabase(newUserInfo) - expect(defaultEventPublisher.publishIdentityUserCreated).toHaveBeenCalledTimes(1) - }) - - it('returns a User with the correct shape', async () => { - const user = await (oidcService as any).syncUserToDatabase(newUserInfo) - expect(user).toMatchObject({ - email: newUserInfo.email, - firstName: newUserInfo.given_name, - lastName: newUserInfo.family_name, - roles: ['user'], - }) - expect(typeof user.id).toBe('string') - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 5. syncUserToDatabase() — existing user (link_by_email / Authentik merge semantics) -// -// Authentik's `user_matching_mode: link_by_email` means a Google login and a -// password login with the same email merge into one Authentik user. By the time -// FuzeFront's callback fires, the subject has one canonical email. The first -// FuzeFront login creates the local row; every subsequent one (regardless of -// identity provider) must update — not duplicate — that row. -// ═══════════════════════════════════════════════════════════════════════════ - -describe('OIDCService syncUserToDatabase() — existing user (link_by_email semantics)', () => { - const existingRow = { - id: 'existing-user-uuid-001', - email: 'existinguser@oidctest.example.com', - first_name: 'OldFirst', - last_name: 'OldLast', - roles: '["user"]', - } - - const googleUserInfo = { - sub: 'google-sub-existing-001', - email: existingRow.email, // same email → Authentik link_by_email - given_name: 'NewFirst', // name may differ after Google auth - family_name: 'NewLast', - } - - let updateMock: jest.Mock - - beforeEach(() => { - jest.clearAllMocks() - - updateMock = jest.fn().mockResolvedValue([]) - ;(dbFn as any).transaction = jest.fn() - - const qb = { - where: jest.fn().mockReturnThis(), - first: jest.fn().mockResolvedValue(existingRow), - update: updateMock, - } - dbFn.mockReturnValue(qb) - }) - - it('updates first_name and last_name from the new Google-provided claims', async () => { - await (oidcService as any).syncUserToDatabase(googleUserInfo) - expect(updateMock).toHaveBeenCalledTimes(1) - const [updatePayload] = updateMock.mock.calls[0] - expect(updatePayload).toMatchObject({ - first_name: googleUserInfo.given_name, - last_name: googleUserInfo.family_name, - }) - }) - - it('does NOT open a transaction (no new user row inserted)', async () => { - await (oidcService as any).syncUserToDatabase(googleUserInfo) - expect((dbFn as any).transaction).not.toHaveBeenCalled() - }) - - it('does NOT publish identity.user.created (not a new user)', async () => { - await (oidcService as any).syncUserToDatabase(googleUserInfo) - expect(defaultEventPublisher.publishIdentityUserCreated).not.toHaveBeenCalled() - }) - - it('returns the existing user id (no duplicate row created)', async () => { - const user = await (oidcService as any).syncUserToDatabase(googleUserInfo) - expect(user.id).toBe(existingRow.id) - expect(user.email).toBe(existingRow.email) - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 6. syncUserToDatabase() — Kafka publish fails (outbox durability guarantee) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('OIDCService syncUserToDatabase() — Kafka publish fails', () => { - const userInfo = { - sub: 'google-sub-kafkafail-001', - email: 'kafkafail@oidctest.example.com', - given_name: 'Kafka', - family_name: 'Fail', - } - - beforeEach(() => { - jest.clearAllMocks() - - const trx = jest.fn().mockReturnValue({ insert: jest.fn().mockResolvedValue([]) }) - ;(dbFn as any).transaction = jest.fn().mockImplementation( - async (cb: Function) => cb(trx) - ) - const qb = { - where: jest.fn().mockReturnThis(), - first: jest.fn().mockResolvedValue(null), - update: jest.fn().mockResolvedValue([]), - } - dbFn.mockReturnValue(qb) - - // Simulate Kafka being unavailable - ;(defaultEventPublisher.publishIdentityUserCreated as jest.Mock).mockRejectedValueOnce( - new Error('Kafka broker unreachable') - ) - }) - - it('does not throw even when the Kafka publish fails', async () => { - await expect( - (oidcService as any).syncUserToDatabase(userInfo) - ).resolves.toBeDefined() - }) - - it('still returns a valid User shape when publish fails (outbox retains the event)', async () => { - const user = await (oidcService as any).syncUserToDatabase(userInfo) - expect(user).toMatchObject({ - email: userInfo.email, - firstName: userInfo.given_name, - lastName: userInfo.family_name, - roles: ['user'], - }) - }) - - it('the outbox row was still inserted in the transaction before the publish attempt', async () => { - // Even though publish fails, the outbox row must have been inserted atomically - // with the user row (the transaction ran before the best-effort publish). - const trxInserts: jest.Mock[] = [] - const trx = jest.fn().mockImplementation((_table: string) => { - const ins = jest.fn().mockResolvedValue([]) - trxInserts.push(ins) - return { insert: ins } - }) - ;(dbFn as any).transaction = jest.fn().mockImplementation( - async (cb: Function) => cb(trx) - ) - await (oidcService as any).syncUserToDatabase(userInfo) - // Transaction ran and had at least 2 inserts (users + event_outbox) - expect((dbFn as any).transaction).toHaveBeenCalledTimes(1) - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 7. Route: GET /api/auth/method — when OIDC is configured -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Route GET /api/auth/method (OIDC configured)', () => { - let app: express.Application - let isConfiguredSpy: jest.SpyInstance - - beforeAll(() => { - app = buildApp() - }) - - beforeEach(() => { - isConfiguredSpy = jest.spyOn(oidcService, 'isConfigured').mockReturnValue(true) - }) - - afterEach(() => { - isConfiguredSpy.mockRestore() - }) - - it('includes "oidc" and "local" in the methods array', async () => { - const res = await request(app).get('/api/auth/method').expect(200) - expect(res.body.methods).toContain('oidc') - expect(res.body.methods).toContain('local') - }) - - it('returns oidcConfigured: true', async () => { - const res = await request(app).get('/api/auth/method').expect(200) - expect(res.body.oidcConfigured).toBe(true) - }) - - it('returns a non-null oidcLoginUrl when OIDC is configured', async () => { - const res = await request(app).get('/api/auth/method').expect(200) - expect(res.body.oidcLoginUrl).not.toBeNull() - expect(typeof res.body.oidcLoginUrl).toBe('string') - }) - - it('returns defaultMethod: "oidc" when configured', async () => { - const res = await request(app).get('/api/auth/method').expect(200) - expect(res.body.defaultMethod).toBe('oidc') - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 8. Route: GET /api/auth/oidc/login — when configured -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Route GET /api/auth/oidc/login (configured)', () => { - let app: express.Application - let isConfiguredSpy: jest.SpyInstance - let generateAuthUrlSpy: jest.SpyInstance - - beforeAll(() => { - app = buildApp() - }) - - beforeEach(() => { - isConfiguredSpy = jest.spyOn(oidcService, 'isConfigured').mockReturnValue(true) - jest.spyOn(oidcService, 'isInitialized').mockReturnValue(true) - jest.spyOn(oidcService, 'ensureInitialized').mockResolvedValue(undefined) - generateAuthUrlSpy = jest - .spyOn(oidcService, 'generateAuthUrl') - .mockReturnValue({ - url: 'http://mock-authentik/application/o/fuzefront/authorize?state=spy-state', - codeVerifier: 'spy-code-verifier', - }) - }) - - afterEach(() => { - isConfiguredSpy.mockRestore() - generateAuthUrlSpy.mockRestore() - }) - - it('responds with HTTP 302', async () => { - const res = await request(app).get('/api/auth/oidc/login') - expect(res.status).toBe(302) - }) - - it('sets an HttpOnly oidc_state cookie', async () => { - const res = await request(app).get('/api/auth/oidc/login') - const cookies: string = Array.isArray(res.headers['set-cookie']) - ? (res.headers['set-cookie'] as string[]).join(';') - : (res.headers['set-cookie'] as string) || '' - expect(cookies).toMatch(/oidc_state=/) - expect(cookies).toMatch(/HttpOnly/i) - }) - - it('sets an HttpOnly oidc_cv cookie (PKCE code_verifier)', async () => { - const res = await request(app).get('/api/auth/oidc/login') - const cookies: string = Array.isArray(res.headers['set-cookie']) - ? (res.headers['set-cookie'] as string[]).join(';') - : (res.headers['set-cookie'] as string) || '' - expect(cookies).toMatch(/oidc_cv=/) - expect(cookies).toMatch(/HttpOnly/i) - }) - - it('Location header is the Authentik authorization URL returned by generateAuthUrl', async () => { - const res = await request(app).get('/api/auth/oidc/login') - expect(res.headers.location).toContain('mock-authentik') - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 9. Route: GET /api/auth/oidc/callback -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Route GET /api/auth/oidc/callback', () => { - const MOCK_USER = { - id: 'mock-oidc-user-uuid', - email: 'google-callback-test@oidctest.example.com', - firstName: 'Google', - lastName: 'OIDCUser', - roles: ['user'], - } - - let app: express.Application - let handleCallbackSpy: jest.SpyInstance - - beforeAll(() => { - app = buildApp() - }) - - beforeEach(() => { - jest.clearAllMocks() - handleCallbackSpy = jest - .spyOn(oidcService, 'handleCallback') - .mockResolvedValue(MOCK_USER) - - // db('sessions').insert(...) called by the route on happy path - const sessionQb = { insert: jest.fn().mockResolvedValue([]) } - dbFn.mockReturnValue(sessionQb) - }) - - afterEach(() => { - handleCallbackSpy.mockRestore() - }) - - it('redirects with error=invalid_state when oidc_cv cookie is absent', async () => { - const STATE = 'csrf-test-state-abc' - const res = await request(app) - .get(`/api/auth/oidc/callback?code=some-code&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}`) // oidc_cv intentionally absent - expect(res.status).toBe(302) - expect(res.headers.location).toMatch(/error=invalid_state/) - }) - - it('redirects with error=invalid_state when state cookie does not match query param', async () => { - const STATE = 'real-state-12345' - const WRONG_STATE = 'wrong-state-99999' - // Note: states must be different length here (length check fires first) - const res = await request(app) - .get(`/api/auth/oidc/callback?code=some-code&state=${STATE}`) - .set('Cookie', `oidc_state=${WRONG_STATE}; oidc_cv=some-verifier`) - expect(res.status).toBe(302) - expect(res.headers.location).toMatch(/error=invalid_state/) - }) - - it('happy path: 302, Location contains ?code= (short-lived exchange token)', async () => { - const STATE = 'happy-path-state-abc123' - const res = await request(app) - .get(`/api/auth/oidc/callback?code=auth-code&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=pkce-verifier-xyz`) - expect(res.status).toBe(302) - expect(res.headers.location).toMatch(/[?&]code=/) - }) - - it('happy path: Location does NOT expose token= or sessionId= directly (avoids URL leakage)', async () => { - const STATE = 'happy-path-state-abc123' - const res = await request(app) - .get(`/api/auth/oidc/callback?code=auth-code&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=pkce-verifier-xyz`) - expect(res.headers.location).not.toMatch(/[?&]token=/) - expect(res.headers.location).not.toMatch(/[?&]sessionId=/) - }) - - it('happy path: a session row is inserted in the DB for the authenticated user', async () => { - const insertMock = jest.fn().mockResolvedValue([]) - dbFn.mockReturnValue({ insert: insertMock }) - - const STATE = 'session-insert-state-xyz' - await request(app) - .get(`/api/auth/oidc/callback?code=auth-code&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=pkce-verifier-xyz`) - - expect(insertMock).toHaveBeenCalledWith( - expect.objectContaining({ user_id: MOCK_USER.id }) - ) - }) - - it('happy path: handleCallback is called with code, state, and the codeVerifier from cookie', async () => { - const STATE = 'verify-args-state-abc' - const CV = 'pkce-verifier-from-cookie' - await request(app) - .get(`/api/auth/oidc/callback?code=the-auth-code&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=${CV}`) - expect(handleCallbackSpy).toHaveBeenCalledWith('the-auth-code', STATE, CV) - }) - - it('redirects with error=authentication_failed when handleCallback throws', async () => { - handleCallbackSpy.mockRejectedValue(new Error('Authentik token exchange failed')) - const STATE = 'error-path-state-abc' - const res = await request(app) - .get(`/api/auth/oidc/callback?code=bad-code&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=pkce-verifier-xyz`) - expect(res.status).toBe(302) - expect(res.headers.location).toMatch(/error=authentication_failed/) - }) -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// 10. Route: POST /api/auth/token-exchange (OIDC-specific cases) -// -// Core single-use / expiry semantics are already tested in oidc-code-exchange.test.ts. -// This block adds coverage for the full Google Sign-In integration path and for the -// missing-code body case which is shared but needed for completeness here. -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Route POST /api/auth/token-exchange', () => { - let app: express.Application - let handleCallbackSpy: jest.SpyInstance - const MOCK_USER = { - id: 'exchange-test-user-uuid', - email: 'exchange-test@oidctest.example.com', - firstName: 'Exchange', - lastName: 'Test', - roles: ['user'], - } - - // Counter to generate unique, non-colliding state strings per test - let stateCounter = 0 - function nextState() { - return `exchange-test-state-${++stateCounter}` - } - - async function issueFreshCode(a: express.Application): Promise { - const STATE = nextState() - const cbRes = await request(a) - .get(`/api/auth/oidc/callback?code=auth-code&state=${STATE}`) - .set('Cookie', `oidc_state=${STATE}; oidc_cv=test-verifier`) - if (cbRes.status !== 302) throw new Error(`Callback did not redirect: ${cbRes.status}`) - const code = new URL(cbRes.headers.location).searchParams.get('code') - if (!code) throw new Error(`No ?code= in redirect: ${cbRes.headers.location}`) - return code - } - - beforeAll(() => { - app = buildApp() - handleCallbackSpy = jest - .spyOn(oidcService, 'handleCallback') - .mockResolvedValue(MOCK_USER) - dbFn.mockReturnValue({ insert: jest.fn().mockResolvedValue([]) }) - }) - - afterAll(() => { - handleCallbackSpy.mockRestore() - }) - - it('returns { token, sessionId } for a valid exchange code issued via the OIDC callback', async () => { - const code = await issueFreshCode(app) - const res = await request(app).post('/api/auth/token-exchange').send({ code }) - expect(res.status).toBe(200) - expect(res.body).toHaveProperty('token') - expect(res.body).toHaveProperty('sessionId') - }) - - it('second redemption of the same code returns 401 (single-use guarantee)', async () => { - const code = await issueFreshCode(app) - await request(app).post('/api/auth/token-exchange').send({ code }) - const second = await request(app).post('/api/auth/token-exchange').send({ code }) - expect(second.status).toBe(401) - }) - - it('expired code (past 60 s TTL) returns 401', async () => { - jest.useFakeTimers() - const code = await issueFreshCode(app) - jest.advanceTimersByTime(61_000) - const res = await request(app).post('/api/auth/token-exchange').send({ code }) - expect(res.status).toBe(401) - jest.useRealTimers() - }) - - it('returns 400 when the code field is missing from the body', async () => { - const res = await request(app).post('/api/auth/token-exchange').send({}) - expect(res.status).toBe(400) - }) -}) +/** + * Backend unit and integration tests for the Google Sign-In (Authentik OIDC) flow. + * + * Architecture of the flow: + * User → FuzeFront /oidc/login → Authentik (OIDC/PKCE with code_challenge) + * → Authentik shows "Sign in with Google" → Google authenticates the user + * → Authentik receives Google tokens → Authentik issues code to FuzeFront callback + * → FuzeFront /oidc/callback → issues short-lived exchange code → frontend + * → frontend POST /token-exchange → JWT + sessionId + * + * FuzeFront never contacts Google directly. All Google auth is inside Authentik. + * + * Coverage: + * 1. OIDCService unit tests (mocked openid-client, db, eventPublisher) + * 2. syncUserToDatabase semantics — new user, existing user (link_by_email), Kafka fail + * 3. Route integration tests (spied oidcService, mocked db) + * + * Does NOT duplicate tests already in: + * - oidc-state.test.ts (PKCE/CSRF cookie state checks) + * - oidc-code-exchange.test.ts (single-use / expiry of exchange code) + */ +import express from 'express' +import request from 'supertest' + +// ─── Module-level mocks (hoisted by jest before imports) ─────────────────── + +// Prevent any outbound network calls to Authentik or Google +jest.mock('openid-client', () => ({ + Issuer: { + discover: jest.fn(), + }, + generators: { + codeVerifier: jest.fn().mockReturnValue('mock-code-verifier'), + codeChallenge: jest.fn().mockReturnValue('mock-code-challenge'), + state: jest.fn().mockReturnValue('mock-oidc-state'), + }, +})) + +// Prevent Kafka connection attempts (best-effort publish path in syncUserToDatabase) +jest.mock('../src/services/eventPublisher', () => ({ + defaultEventPublisher: { + publishIdentityUserCreated: jest.fn().mockResolvedValue(undefined), + publishNotifyEmailRequested: jest.fn().mockResolvedValue(undefined), + }, +})) + +// Run without a live Postgres instance +jest.mock('../src/config/database', () => ({ + db: Object.assign(jest.fn(), { + transaction: jest.fn(), + }), +})) + +// Predictable JWTs in route tests +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn().mockReturnValue('mock.jwt.token'), + verify: jest.fn(), +})) + +// Predictable UUIDs (session IDs in route tests) +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('mock-uuid-1234'), +})) + +// Fire-and-forget provisioning — skip DB work +jest.mock('../src/services/organizationProvisioning', () => ({ + runInternalProvision: jest.fn().mockResolvedValue(undefined), +})) + +// ─── Imports (after mock declarations) ───────────────────────────────────── +import { getOidcService } from '../src/services/oidc' + +/** Resolved from the registry; legacy single-tenant mode yields the one client. */ +const oidcService: any = getOidcService() +import { db } from '../src/config/database' +import { defaultEventPublisher } from '../src/services/eventPublisher' +import authRouter from '../src/routes/auth' + +// Typed handle to the db mock function +const dbFn = db as jest.MockedFunction + +// Build a minimal Express app mounting the auth routes +function buildApp(): express.Application { + const app = express() + app.use(express.json()) + app.use('/api/auth', authRouter) + return app +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. OIDCService.isConfigured() +// ═══════════════════════════════════════════════════════════════════════════ + +describe('OIDCService.isConfigured()', () => { + let savedClientId: string + let savedClientSecret: string + + beforeEach(() => { + savedClientId = (oidcService as any).config.clientId + savedClientSecret = (oidcService as any).config.clientSecret + }) + + afterEach(() => { + ;(oidcService as any).config.clientId = savedClientId + ;(oidcService as any).config.clientSecret = savedClientSecret + }) + + it('returns false when AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET are absent', () => { + ;(oidcService as any).config.clientId = '' + ;(oidcService as any).config.clientSecret = '' + expect(oidcService.isConfigured()).toBe(false) + }) + + it('returns true when both AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET are set', () => { + ;(oidcService as any).config.clientId = 'test-client-id' + ;(oidcService as any).config.clientSecret = 'test-client-secret' + expect(oidcService.isConfigured()).toBe(true) + }) + + it('returns false when only client ID is set (secret missing)', () => { + ;(oidcService as any).config.clientId = 'test-client-id' + ;(oidcService as any).config.clientSecret = '' + expect(oidcService.isConfigured()).toBe(false) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. OIDCService.generateAuthUrl() +// ═══════════════════════════════════════════════════════════════════════════ + +describe('OIDCService.generateAuthUrl()', () => { + const mockAuthUrl = + 'http://mock-authentik/application/o/fuzefront/authorize?' + + 'code_challenge=mock-code-challenge&code_challenge_method=S256&state=test-state' + + const mockClient = { + authorizationUrl: jest.fn().mockReturnValue(mockAuthUrl), + callback: jest.fn(), + userinfo: jest.fn(), + } + + beforeEach(() => { + ;(oidcService as any).client = mockClient + mockClient.authorizationUrl.mockReturnValue(mockAuthUrl) + }) + + afterEach(() => { + ;(oidcService as any).client = null + }) + + it('returns an object with both url and codeVerifier properties', () => { + const result = oidcService.generateAuthUrl('test-state') + expect(result).toHaveProperty('url') + expect(result).toHaveProperty('codeVerifier') + }) + + it('codeVerifier is a non-empty string (stateless — caller persists it in cookie)', () => { + const { codeVerifier } = oidcService.generateAuthUrl('test-state') + expect(typeof codeVerifier).toBe('string') + expect(codeVerifier.length).toBeGreaterThan(0) + }) + + it('url contains the PKCE code_challenge parameter', () => { + const { url } = oidcService.generateAuthUrl('test-state') + expect(url).toContain('code_challenge=') + }) + + it('calls client.authorizationUrl with S256 code_challenge_method', () => { + oidcService.generateAuthUrl('test-state') + expect(mockClient.authorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ code_challenge_method: 'S256' }) + ) + }) + + it('throws when the openid-client has not been initialized', () => { + ;(oidcService as any).client = null + expect(() => oidcService.generateAuthUrl('test-state')).toThrow( + /not initialized/i + ) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. OIDCService.handleCallback() +// ═══════════════════════════════════════════════════════════════════════════ + +describe('OIDCService.handleCallback()', () => { + const mockUserInfo = { + sub: 'google-sub-handlecb-001', + email: 'handlecb@oidctest.example.com', + given_name: 'Handle', + family_name: 'Callback', + } + + const mockClient = { + authorizationUrl: jest.fn(), + callback: jest.fn().mockResolvedValue({ access_token: 'mock-at-001' }), + userinfo: jest.fn().mockResolvedValue(mockUserInfo), + } + + beforeEach(() => { + jest.clearAllMocks() + ;(oidcService as any).client = mockClient + mockClient.callback.mockResolvedValue({ access_token: 'mock-at-001' }) + mockClient.userinfo.mockResolvedValue(mockUserInfo) + + // syncUserToDatabase: new user path + const trxInsert = jest.fn().mockResolvedValue([]) + const trx = jest.fn().mockReturnValue({ insert: trxInsert }) + ;(dbFn as any).transaction = jest.fn().mockImplementation( + async (cb: Function) => cb(trx) + ) + const qb = { + where: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue(null), // no existing user + update: jest.fn().mockResolvedValue([]), + } + dbFn.mockReturnValue(qb) + }) + + afterEach(() => { + ;(oidcService as any).client = null + }) + + it('throws when codeVerifier is an empty string', async () => { + await expect( + oidcService.handleCallback('auth-code', 'some-state', '') + ).rejects.toThrow(/code verifier not found/i) + }) + + it('calls openid-client callback with the correct PKCE verifier', async () => { + await oidcService.handleCallback('auth-code-xyz', 'state-abc', 'verifier-123') + expect(mockClient.callback).toHaveBeenCalledWith( + expect.any(String), // redirectUri + { code: 'auth-code-xyz', state: 'state-abc' }, + { code_verifier: 'verifier-123', state: 'state-abc' } + ) + }) + + it('calls userinfo with the access_token from the token set', async () => { + mockClient.callback.mockResolvedValue({ access_token: 'at-specific' }) + await oidcService.handleCallback('auth-code-xyz', 'state-abc', 'verifier-123') + expect(mockClient.userinfo).toHaveBeenCalledWith('at-specific') + }) + + it('returns a User with the correct shape from the userinfo claims', async () => { + const user = await oidcService.handleCallback('auth-code-xyz', 'state-abc', 'verifier-123') + expect(user).toMatchObject({ + email: mockUserInfo.email, + firstName: mockUserInfo.given_name, + lastName: mockUserInfo.family_name, + }) + expect(typeof user.id).toBe('string') + expect(Array.isArray(user.roles)).toBe(true) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. syncUserToDatabase() — new Google-authenticated user +// ═══════════════════════════════════════════════════════════════════════════ + +describe('OIDCService syncUserToDatabase() — new Google-authenticated user', () => { + const newUserInfo = { + sub: 'google-sub-newuser-001', + email: 'newgoogleuser@oidctest.example.com', + given_name: 'New', + family_name: 'GoogleUser', + } + + let trxUserInsert: jest.Mock + let trxOutboxInsert: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + + trxUserInsert = jest.fn().mockResolvedValue([]) + trxOutboxInsert = jest.fn().mockResolvedValue([]) + + const trx = jest.fn().mockImplementation((table: string) => { + if (table === 'users') return { insert: trxUserInsert } + if (table === 'event_outbox') return { insert: trxOutboxInsert } + return { insert: jest.fn().mockResolvedValue([]) } + }) + + ;(dbFn as any).transaction = jest.fn().mockImplementation( + async (cb: Function) => cb(trx) + ) + + // db('users').where('email', ...).first() → null (no existing user) + // db('event_outbox').where(...).update(...) → success (after publish) + const qbDefault = { + where: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue([]), + } + dbFn.mockReturnValue(qbDefault) + }) + + it('runs a transaction that inserts a new user row', async () => { + await (oidcService as any).syncUserToDatabase(newUserInfo) + expect((dbFn as any).transaction).toHaveBeenCalledTimes(1) + expect(trxUserInsert).toHaveBeenCalledTimes(1) + const [insertedRow] = trxUserInsert.mock.calls[0] + expect(insertedRow).toMatchObject({ + email: newUserInfo.email, + first_name: newUserInfo.given_name, + last_name: newUserInfo.family_name, + }) + }) + + it('inserts an event_outbox row with topic "identity.user.created" in the same transaction', async () => { + await (oidcService as any).syncUserToDatabase(newUserInfo) + expect(trxOutboxInsert).toHaveBeenCalledTimes(1) + const [outboxRow] = trxOutboxInsert.mock.calls[0] + expect(outboxRow.topic).toBe('identity.user.created') + expect(outboxRow.status).toBe('pending') + expect(outboxRow.correlation_id).toMatch(/^identity-/) + }) + + it('event_outbox payload encodes the correct user fields', async () => { + await (oidcService as any).syncUserToDatabase(newUserInfo) + const [outboxRow] = trxOutboxInsert.mock.calls[0] + const payload = JSON.parse(outboxRow.payload) + expect(payload).toMatchObject({ + email: newUserInfo.email, + firstName: newUserInfo.given_name, + lastName: newUserInfo.family_name, + intent: 'signup', + }) + }) + + it('publishes identity.user.created via the event publisher', async () => { + await (oidcService as any).syncUserToDatabase(newUserInfo) + expect(defaultEventPublisher.publishIdentityUserCreated).toHaveBeenCalledTimes(1) + }) + + it('returns a User with the correct shape', async () => { + const user = await (oidcService as any).syncUserToDatabase(newUserInfo) + expect(user).toMatchObject({ + email: newUserInfo.email, + firstName: newUserInfo.given_name, + lastName: newUserInfo.family_name, + roles: ['user'], + }) + expect(typeof user.id).toBe('string') + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. syncUserToDatabase() — existing user (link_by_email / Authentik merge semantics) +// +// Authentik's `user_matching_mode: link_by_email` means a Google login and a +// password login with the same email merge into one Authentik user. By the time +// FuzeFront's callback fires, the subject has one canonical email. The first +// FuzeFront login creates the local row; every subsequent one (regardless of +// identity provider) must update — not duplicate — that row. +// ═══════════════════════════════════════════════════════════════════════════ + +describe('OIDCService syncUserToDatabase() — existing user (link_by_email semantics)', () => { + const existingRow = { + id: 'existing-user-uuid-001', + email: 'existinguser@oidctest.example.com', + first_name: 'OldFirst', + last_name: 'OldLast', + roles: '["user"]', + } + + const googleUserInfo = { + sub: 'google-sub-existing-001', + email: existingRow.email, // same email → Authentik link_by_email + given_name: 'NewFirst', // name may differ after Google auth + family_name: 'NewLast', + } + + let updateMock: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + + updateMock = jest.fn().mockResolvedValue([]) + ;(dbFn as any).transaction = jest.fn() + + const qb = { + where: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue(existingRow), + update: updateMock, + } + dbFn.mockReturnValue(qb) + }) + + it('updates first_name and last_name from the new Google-provided claims', async () => { + await (oidcService as any).syncUserToDatabase(googleUserInfo) + expect(updateMock).toHaveBeenCalledTimes(1) + const [updatePayload] = updateMock.mock.calls[0] + expect(updatePayload).toMatchObject({ + first_name: googleUserInfo.given_name, + last_name: googleUserInfo.family_name, + }) + }) + + it('does NOT open a transaction (no new user row inserted)', async () => { + await (oidcService as any).syncUserToDatabase(googleUserInfo) + expect((dbFn as any).transaction).not.toHaveBeenCalled() + }) + + it('does NOT publish identity.user.created (not a new user)', async () => { + await (oidcService as any).syncUserToDatabase(googleUserInfo) + expect(defaultEventPublisher.publishIdentityUserCreated).not.toHaveBeenCalled() + }) + + it('returns the existing user id (no duplicate row created)', async () => { + const user = await (oidcService as any).syncUserToDatabase(googleUserInfo) + expect(user.id).toBe(existingRow.id) + expect(user.email).toBe(existingRow.email) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. syncUserToDatabase() — Kafka publish fails (outbox durability guarantee) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('OIDCService syncUserToDatabase() — Kafka publish fails', () => { + const userInfo = { + sub: 'google-sub-kafkafail-001', + email: 'kafkafail@oidctest.example.com', + given_name: 'Kafka', + family_name: 'Fail', + } + + beforeEach(() => { + jest.clearAllMocks() + + const trx = jest.fn().mockReturnValue({ insert: jest.fn().mockResolvedValue([]) }) + ;(dbFn as any).transaction = jest.fn().mockImplementation( + async (cb: Function) => cb(trx) + ) + const qb = { + where: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue([]), + } + dbFn.mockReturnValue(qb) + + // Simulate Kafka being unavailable + ;(defaultEventPublisher.publishIdentityUserCreated as jest.Mock).mockRejectedValueOnce( + new Error('Kafka broker unreachable') + ) + }) + + it('does not throw even when the Kafka publish fails', async () => { + await expect( + (oidcService as any).syncUserToDatabase(userInfo) + ).resolves.toBeDefined() + }) + + it('still returns a valid User shape when publish fails (outbox retains the event)', async () => { + const user = await (oidcService as any).syncUserToDatabase(userInfo) + expect(user).toMatchObject({ + email: userInfo.email, + firstName: userInfo.given_name, + lastName: userInfo.family_name, + roles: ['user'], + }) + }) + + it('the outbox row was still inserted in the transaction before the publish attempt', async () => { + // Even though publish fails, the outbox row must have been inserted atomically + // with the user row (the transaction ran before the best-effort publish). + const trxInserts: jest.Mock[] = [] + const trx = jest.fn().mockImplementation((_table: string) => { + const ins = jest.fn().mockResolvedValue([]) + trxInserts.push(ins) + return { insert: ins } + }) + ;(dbFn as any).transaction = jest.fn().mockImplementation( + async (cb: Function) => cb(trx) + ) + await (oidcService as any).syncUserToDatabase(userInfo) + // Transaction ran and had at least 2 inserts (users + event_outbox) + expect((dbFn as any).transaction).toHaveBeenCalledTimes(1) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. Route: GET /api/auth/method — when OIDC is configured +// ═══════════════════════════════════════════════════════════════════════════ + +describe('Route GET /api/auth/method (OIDC configured)', () => { + let app: express.Application + let isConfiguredSpy: jest.SpyInstance + + beforeAll(() => { + app = buildApp() + }) + + beforeEach(() => { + isConfiguredSpy = jest.spyOn(oidcService, 'isConfigured').mockReturnValue(true) + }) + + afterEach(() => { + isConfiguredSpy.mockRestore() + }) + + it('includes "oidc" and "local" in the methods array', async () => { + const res = await request(app).get('/api/auth/method').expect(200) + expect(res.body.methods).toContain('oidc') + expect(res.body.methods).toContain('local') + }) + + it('returns oidcConfigured: true', async () => { + const res = await request(app).get('/api/auth/method').expect(200) + expect(res.body.oidcConfigured).toBe(true) + }) + + it('returns a non-null oidcLoginUrl when OIDC is configured', async () => { + const res = await request(app).get('/api/auth/method').expect(200) + expect(res.body.oidcLoginUrl).not.toBeNull() + expect(typeof res.body.oidcLoginUrl).toBe('string') + }) + + it('returns defaultMethod: "oidc" when configured', async () => { + const res = await request(app).get('/api/auth/method').expect(200) + expect(res.body.defaultMethod).toBe('oidc') + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. Route: GET /api/auth/oidc/login — when configured +// ═══════════════════════════════════════════════════════════════════════════ + +describe('Route GET /api/auth/oidc/login (configured)', () => { + let app: express.Application + let isConfiguredSpy: jest.SpyInstance + let generateAuthUrlSpy: jest.SpyInstance + + beforeAll(() => { + app = buildApp() + }) + + beforeEach(() => { + isConfiguredSpy = jest.spyOn(oidcService, 'isConfigured').mockReturnValue(true) + jest.spyOn(oidcService, 'isInitialized').mockReturnValue(true) + jest.spyOn(oidcService, 'ensureInitialized').mockResolvedValue(undefined) + generateAuthUrlSpy = jest + .spyOn(oidcService, 'generateAuthUrl') + .mockReturnValue({ + url: 'http://mock-authentik/application/o/fuzefront/authorize?state=spy-state', + codeVerifier: 'spy-code-verifier', + }) + }) + + afterEach(() => { + isConfiguredSpy.mockRestore() + generateAuthUrlSpy.mockRestore() + }) + + it('responds with HTTP 302', async () => { + const res = await request(app).get('/api/auth/oidc/login') + expect(res.status).toBe(302) + }) + + it('sets an HttpOnly oidc_state cookie', async () => { + const res = await request(app).get('/api/auth/oidc/login') + const cookies: string = Array.isArray(res.headers['set-cookie']) + ? (res.headers['set-cookie'] as string[]).join(';') + : (res.headers['set-cookie'] as string) || '' + expect(cookies).toMatch(/oidc_state=/) + expect(cookies).toMatch(/HttpOnly/i) + }) + + it('sets an HttpOnly oidc_cv cookie (PKCE code_verifier)', async () => { + const res = await request(app).get('/api/auth/oidc/login') + const cookies: string = Array.isArray(res.headers['set-cookie']) + ? (res.headers['set-cookie'] as string[]).join(';') + : (res.headers['set-cookie'] as string) || '' + expect(cookies).toMatch(/oidc_cv=/) + expect(cookies).toMatch(/HttpOnly/i) + }) + + it('Location header is the Authentik authorization URL returned by generateAuthUrl', async () => { + const res = await request(app).get('/api/auth/oidc/login') + expect(res.headers.location).toContain('mock-authentik') + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 9. Route: GET /api/auth/oidc/callback +// ═══════════════════════════════════════════════════════════════════════════ + +describe('Route GET /api/auth/oidc/callback', () => { + const MOCK_USER = { + id: 'mock-oidc-user-uuid', + email: 'google-callback-test@oidctest.example.com', + firstName: 'Google', + lastName: 'OIDCUser', + roles: ['user'], + } + + let app: express.Application + let handleCallbackSpy: jest.SpyInstance + + beforeAll(() => { + app = buildApp() + }) + + beforeEach(() => { + jest.clearAllMocks() + handleCallbackSpy = jest + .spyOn(oidcService, 'handleCallback') + .mockResolvedValue(MOCK_USER) + + // db('sessions').insert(...) called by the route on happy path + const sessionQb = { insert: jest.fn().mockResolvedValue([]) } + dbFn.mockReturnValue(sessionQb) + }) + + afterEach(() => { + handleCallbackSpy.mockRestore() + }) + + it('redirects with error=invalid_state when oidc_cv cookie is absent', async () => { + const STATE = 'csrf-test-state-abc' + const res = await request(app) + .get(`/api/auth/oidc/callback?code=some-code&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}`) // oidc_cv intentionally absent + expect(res.status).toBe(302) + expect(res.headers.location).toMatch(/error=invalid_state/) + }) + + it('redirects with error=invalid_state when state cookie does not match query param', async () => { + const STATE = 'real-state-12345' + const WRONG_STATE = 'wrong-state-99999' + // Note: states must be different length here (length check fires first) + const res = await request(app) + .get(`/api/auth/oidc/callback?code=some-code&state=${STATE}`) + .set('Cookie', `oidc_state=${WRONG_STATE}; oidc_cv=some-verifier`) + expect(res.status).toBe(302) + expect(res.headers.location).toMatch(/error=invalid_state/) + }) + + it('happy path: 302, Location contains ?code= (short-lived exchange token)', async () => { + const STATE = 'happy-path-state-abc123' + const res = await request(app) + .get(`/api/auth/oidc/callback?code=auth-code&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=pkce-verifier-xyz`) + expect(res.status).toBe(302) + expect(res.headers.location).toMatch(/[?&]code=/) + }) + + it('happy path: Location does NOT expose token= or sessionId= directly (avoids URL leakage)', async () => { + const STATE = 'happy-path-state-abc123' + const res = await request(app) + .get(`/api/auth/oidc/callback?code=auth-code&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=pkce-verifier-xyz`) + expect(res.headers.location).not.toMatch(/[?&]token=/) + expect(res.headers.location).not.toMatch(/[?&]sessionId=/) + }) + + it('happy path: a session row is inserted in the DB for the authenticated user', async () => { + const insertMock = jest.fn().mockResolvedValue([]) + dbFn.mockReturnValue({ insert: insertMock }) + + const STATE = 'session-insert-state-xyz' + await request(app) + .get(`/api/auth/oidc/callback?code=auth-code&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=pkce-verifier-xyz`) + + expect(insertMock).toHaveBeenCalledWith( + expect.objectContaining({ user_id: MOCK_USER.id }) + ) + }) + + it('happy path: handleCallback is called with code, state, and the codeVerifier from cookie', async () => { + const STATE = 'verify-args-state-abc' + const CV = 'pkce-verifier-from-cookie' + await request(app) + .get(`/api/auth/oidc/callback?code=the-auth-code&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=${CV}`) + expect(handleCallbackSpy).toHaveBeenCalledWith('the-auth-code', STATE, CV) + }) + + it('redirects with error=authentication_failed when handleCallback throws', async () => { + handleCallbackSpy.mockRejectedValue(new Error('Authentik token exchange failed')) + const STATE = 'error-path-state-abc' + const res = await request(app) + .get(`/api/auth/oidc/callback?code=bad-code&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=pkce-verifier-xyz`) + expect(res.status).toBe(302) + expect(res.headers.location).toMatch(/error=authentication_failed/) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 10. Route: POST /api/auth/token-exchange (OIDC-specific cases) +// +// Core single-use / expiry semantics are already tested in oidc-code-exchange.test.ts. +// This block adds coverage for the full Google Sign-In integration path and for the +// missing-code body case which is shared but needed for completeness here. +// ═══════════════════════════════════════════════════════════════════════════ + +describe('Route POST /api/auth/token-exchange', () => { + let app: express.Application + let handleCallbackSpy: jest.SpyInstance + const MOCK_USER = { + id: 'exchange-test-user-uuid', + email: 'exchange-test@oidctest.example.com', + firstName: 'Exchange', + lastName: 'Test', + roles: ['user'], + } + + // Counter to generate unique, non-colliding state strings per test + let stateCounter = 0 + function nextState() { + return `exchange-test-state-${++stateCounter}` + } + + async function issueFreshCode(a: express.Application): Promise { + const STATE = nextState() + const cbRes = await request(a) + .get(`/api/auth/oidc/callback?code=auth-code&state=${STATE}`) + .set('Cookie', `oidc_state=${STATE}; oidc_cv=test-verifier`) + if (cbRes.status !== 302) throw new Error(`Callback did not redirect: ${cbRes.status}`) + const code = new URL(cbRes.headers.location).searchParams.get('code') + if (!code) throw new Error(`No ?code= in redirect: ${cbRes.headers.location}`) + return code + } + + beforeAll(() => { + app = buildApp() + handleCallbackSpy = jest + .spyOn(oidcService, 'handleCallback') + .mockResolvedValue(MOCK_USER) + dbFn.mockReturnValue({ insert: jest.fn().mockResolvedValue([]) }) + }) + + afterAll(() => { + handleCallbackSpy.mockRestore() + }) + + it('returns { token, sessionId } for a valid exchange code issued via the OIDC callback', async () => { + const code = await issueFreshCode(app) + const res = await request(app).post('/api/auth/token-exchange').send({ code }) + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('token') + expect(res.body).toHaveProperty('sessionId') + }) + + it('second redemption of the same code returns 401 (single-use guarantee)', async () => { + const code = await issueFreshCode(app) + await request(app).post('/api/auth/token-exchange').send({ code }) + const second = await request(app).post('/api/auth/token-exchange').send({ code }) + expect(second.status).toBe(401) + }) + + it('expired code (past 60 s TTL) returns 401', async () => { + jest.useFakeTimers() + const code = await issueFreshCode(app) + jest.advanceTimersByTime(61_000) + const res = await request(app).post('/api/auth/token-exchange').send({ code }) + expect(res.status).toBe(401) + jest.useRealTimers() + }) + + it('returns 400 when the code field is missing from the body', async () => { + const res = await request(app).post('/api/auth/token-exchange').send({}) + expect(res.status).toBe(400) + }) +}) diff --git a/backend/security/tests/oidc-lazy-reinit.test.ts b/backend/security/tests/oidc-lazy-reinit.test.ts index 012fedbc..ec93327c 100644 --- a/backend/security/tests/oidc-lazy-reinit.test.ts +++ b/backend/security/tests/oidc-lazy-reinit.test.ts @@ -1,156 +1,160 @@ -/** - * Unit tests for OIDCService's self-heal resilience. - * - * Bug being guarded against: boot-time OIDC init retried a BOUNDED number of - * times (30 attempts / 5 min); once exhausted with Authentik still down, - * every subsequent signup/login 401'd with "OIDC is not configured/initialized" - * for the life of the process — requiring a manual `kubectl rollout restart`. - * This took prod auth down twice. - * - * ensureInitialized() now: - * (a) dedupes concurrent callers onto exactly ONE in-flight discovery call - * (no stampede against a struggling/recovering Authentik), - * (b) lets a LATER request succeed once Authentik recovers, without a - * process restart, - * (c) respects a cooldown between attempts so a hard-down Authentik isn't - * hammered once per request. - * - * Each test gets a fresh OIDCService instance (via jest.resetModules() + - * re-require) so in-flight-promise/cooldown state never leaks between cases. - */ - -jest.mock('../src/config/database', () => ({ - db: Object.assign(jest.fn(), { transaction: jest.fn() }), -})) - -jest.mock('../src/services/eventPublisher', () => ({ - defaultEventPublisher: { - publishIdentityUserCreated: jest.fn().mockResolvedValue(undefined), - publishNotifyEmailRequested: jest.fn().mockResolvedValue(undefined), - }, -})) - -/** A minimal fake `Issuer` shape sufficient for `new effectiveIssuer.Client(...)`. */ -function fakeIssuer() { - return { - metadata: { issuer: 'https://auth.example.com/application/o/fuzefront/' }, - Client: function FakeClient(this: any, cfg: any) { - Object.assign(this, cfg) - }, - } -} - -/** Mocks `openid-client` with a caller-supplied `Issuer.discover` implementation. */ -function mockOpenidClient(discoverImpl: () => Promise) { - jest.doMock('openid-client', () => ({ - Issuer: { discover: jest.fn(discoverImpl) }, - generators: { - codeVerifier: jest.fn().mockReturnValue('mock-verifier'), - codeChallenge: jest.fn().mockReturnValue('mock-challenge'), - state: jest.fn().mockReturnValue('mock-state'), - }, - custom: { setHttpOptionsDefaults: jest.fn() }, - })) -} - -describe('OIDCService.ensureInitialized — lazy re-init resilience', () => { - const ORIGINAL_ENV = process.env - - beforeEach(() => { - jest.resetModules() - process.env = { - ...ORIGINAL_ENV, - AUTHENTIK_CLIENT_ID: 'test-client-id', - AUTHENTIK_CLIENT_SECRET: 'test-client-secret', - } - }) - - afterEach(() => { - process.env = ORIGINAL_ENV - jest.dontMock('openid-client') - }) - - it('(a) N concurrent requests while uninitialized trigger exactly ONE init attempt', async () => { - let discoverCalls = 0 - mockOpenidClient(async () => { - discoverCalls++ - // Simulate a real network round-trip so concurrent callers actually - // overlap in time (a synchronous resolve wouldn't exercise the race). - await new Promise(resolve => setTimeout(resolve, 25)) - return fakeIssuer() - }) - - const { oidcService } = require('../src/services/oidc') - expect(oidcService.isInitialized()).toBe(false) - - const concurrentCallers = Array.from({ length: 8 }, () => oidcService.ensureInitialized()) - await Promise.all(concurrentCallers) - - expect(discoverCalls).toBe(1) - expect(oidcService.isInitialized()).toBe(true) - }) - - it('(b) fail-then-succeed discovery — a later request succeeds without a restart', async () => { - let attempt = 0 - mockOpenidClient(async () => { - attempt++ - if (attempt === 1) { - throw new Error('discovery unreachable: Authentik down') - } - return fakeIssuer() - }) - - process.env.OIDC_INIT_COOLDOWN_MS = '10' // short, so the 2nd attempt below isn't blocked - const { oidcService } = require('../src/services/oidc') - - // First request lands while Authentik is down — fails, but the process - // stays up and does NOT permanently latch a "never try again" state. - await expect(oidcService.ensureInitialized()).rejects.toThrow( - 'discovery unreachable: Authentik down' - ) - expect(oidcService.isInitialized()).toBe(false) - - // Wait out the (short, test-only) cooldown — this stands in for - // Authentik recovering some time later with zero code change needed. - await new Promise(resolve => setTimeout(resolve, 30)) - - // A later request (no process restart) now succeeds. - await expect(oidcService.ensureInitialized()).resolves.toBeUndefined() - expect(oidcService.isInitialized()).toBe(true) - expect(attempt).toBe(2) - }) - - it('(c) cooldown prevents per-request hammering of a hard-down Authentik', async () => { - let discoverCalls = 0 - mockOpenidClient(async () => { - discoverCalls++ - throw new Error('discovery unreachable') - }) - - process.env.OIDC_INIT_COOLDOWN_MS = '10000' // long cooldown for this case - const { oidcService } = require('../src/services/oidc') - - await expect(oidcService.ensureInitialized()).rejects.toThrow('discovery unreachable') - expect(discoverCalls).toBe(1) - - // Three more requests arrive immediately after, still within the cooldown - // window — none of them should re-invoke discovery. - await expect(oidcService.ensureInitialized()).rejects.toThrow( - 'OIDC client not initialized' - ) - await expect(oidcService.ensureInitialized()).rejects.toThrow( - 'OIDC client not initialized' - ) - await expect(oidcService.ensureInitialized()).rejects.toThrow( - 'OIDC client not initialized' - ) - expect(discoverCalls).toBe(1) - expect(oidcService.isInitialized()).toBe(false) - }) - - it('preserves the fail-fast contract: generateAuthUrl still throws while uninitialized', () => { - mockOpenidClient(async () => fakeIssuer()) - const { oidcService } = require('../src/services/oidc') - expect(() => oidcService.generateAuthUrl()).toThrow('OIDC client not initialized') - }) -}) +/** + * Unit tests for OIDCService's self-heal resilience. + * + * Bug being guarded against: boot-time OIDC init retried a BOUNDED number of + * times (30 attempts / 5 min); once exhausted with Authentik still down, + * every subsequent signup/login 401'd with "OIDC is not configured/initialized" + * for the life of the process — requiring a manual `kubectl rollout restart`. + * This took prod auth down twice. + * + * ensureInitialized() now: + * (a) dedupes concurrent callers onto exactly ONE in-flight discovery call + * (no stampede against a struggling/recovering Authentik), + * (b) lets a LATER request succeed once Authentik recovers, without a + * process restart, + * (c) respects a cooldown between attempts so a hard-down Authentik isn't + * hammered once per request. + * + * Each test gets a fresh OIDCService instance (via jest.resetModules() + + * re-require) so in-flight-promise/cooldown state never leaks between cases. + */ + +jest.mock('../src/config/database', () => ({ + db: Object.assign(jest.fn(), { transaction: jest.fn() }), +})) + +jest.mock('../src/services/eventPublisher', () => ({ + defaultEventPublisher: { + publishIdentityUserCreated: jest.fn().mockResolvedValue(undefined), + publishNotifyEmailRequested: jest.fn().mockResolvedValue(undefined), + }, +})) + +/** A minimal fake `Issuer` shape sufficient for `new effectiveIssuer.Client(...)`. */ +function fakeIssuer() { + return { + metadata: { issuer: 'https://auth.example.com/application/o/fuzefront/' }, + Client: function FakeClient(this: any, cfg: any) { + Object.assign(this, cfg) + }, + } +} + +/** Mocks `openid-client` with a caller-supplied `Issuer.discover` implementation. */ +function mockOpenidClient(discoverImpl: () => Promise) { + jest.doMock('openid-client', () => ({ + Issuer: { discover: jest.fn(discoverImpl) }, + generators: { + codeVerifier: jest.fn().mockReturnValue('mock-verifier'), + codeChallenge: jest.fn().mockReturnValue('mock-challenge'), + state: jest.fn().mockReturnValue('mock-state'), + }, + custom: { setHttpOptionsDefaults: jest.fn() }, + })) +} + +describe('OIDCService.ensureInitialized — lazy re-init resilience', () => { + const ORIGINAL_ENV = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { + ...ORIGINAL_ENV, + AUTHENTIK_CLIENT_ID: 'test-client-id', + AUTHENTIK_CLIENT_SECRET: 'test-client-secret', + } + }) + + afterEach(() => { + process.env = ORIGINAL_ENV + jest.dontMock('openid-client') + }) + + it('(a) N concurrent requests while uninitialized trigger exactly ONE init attempt', async () => { + let discoverCalls = 0 + mockOpenidClient(async () => { + discoverCalls++ + // Simulate a real network round-trip so concurrent callers actually + // overlap in time (a synchronous resolve wouldn't exercise the race). + await new Promise(resolve => setTimeout(resolve, 25)) + return fakeIssuer() + }) + + const { getOidcService } = require('../src/services/oidc') + const oidcService = getOidcService() + expect(oidcService.isInitialized()).toBe(false) + + const concurrentCallers = Array.from({ length: 8 }, () => oidcService.ensureInitialized()) + await Promise.all(concurrentCallers) + + expect(discoverCalls).toBe(1) + expect(oidcService.isInitialized()).toBe(true) + }) + + it('(b) fail-then-succeed discovery — a later request succeeds without a restart', async () => { + let attempt = 0 + mockOpenidClient(async () => { + attempt++ + if (attempt === 1) { + throw new Error('discovery unreachable: Authentik down') + } + return fakeIssuer() + }) + + process.env.OIDC_INIT_COOLDOWN_MS = '10' // short, so the 2nd attempt below isn't blocked + const { getOidcService } = require('../src/services/oidc') + const oidcService = getOidcService() + + // First request lands while Authentik is down — fails, but the process + // stays up and does NOT permanently latch a "never try again" state. + await expect(oidcService.ensureInitialized()).rejects.toThrow( + 'discovery unreachable: Authentik down' + ) + expect(oidcService.isInitialized()).toBe(false) + + // Wait out the (short, test-only) cooldown — this stands in for + // Authentik recovering some time later with zero code change needed. + await new Promise(resolve => setTimeout(resolve, 30)) + + // A later request (no process restart) now succeeds. + await expect(oidcService.ensureInitialized()).resolves.toBeUndefined() + expect(oidcService.isInitialized()).toBe(true) + expect(attempt).toBe(2) + }) + + it('(c) cooldown prevents per-request hammering of a hard-down Authentik', async () => { + let discoverCalls = 0 + mockOpenidClient(async () => { + discoverCalls++ + throw new Error('discovery unreachable') + }) + + process.env.OIDC_INIT_COOLDOWN_MS = '10000' // long cooldown for this case + const { getOidcService } = require('../src/services/oidc') + const oidcService = getOidcService() + + await expect(oidcService.ensureInitialized()).rejects.toThrow('discovery unreachable') + expect(discoverCalls).toBe(1) + + // Three more requests arrive immediately after, still within the cooldown + // window — none of them should re-invoke discovery. + await expect(oidcService.ensureInitialized()).rejects.toThrow( + 'OIDC client not initialized' + ) + await expect(oidcService.ensureInitialized()).rejects.toThrow( + 'OIDC client not initialized' + ) + await expect(oidcService.ensureInitialized()).rejects.toThrow( + 'OIDC client not initialized' + ) + expect(discoverCalls).toBe(1) + expect(oidcService.isInitialized()).toBe(false) + }) + + it('preserves the fail-fast contract: generateAuthUrl still throws while uninitialized', () => { + mockOpenidClient(async () => fakeIssuer()) + const { getOidcService } = require('../src/services/oidc') + const oidcService = getOidcService() + expect(() => oidcService.generateAuthUrl()).toThrow('OIDC client not initialized') + }) +}) diff --git a/backend/security/tests/oidc-state.test.ts b/backend/security/tests/oidc-state.test.ts index c00fef17..77e6662b 100644 --- a/backend/security/tests/oidc-state.test.ts +++ b/backend/security/tests/oidc-state.test.ts @@ -1,127 +1,135 @@ -/** - * Unit tests for OIDC state cookie CSRF protection. - * Verifies that /oidc/login sets an oidc_state HttpOnly cookie, and - * /oidc/callback rejects requests whose state query param does not match the cookie. - */ -import express from 'express' -import request from 'supertest' - -jest.mock('../src/services/oidc', () => ({ - oidcService: { - isConfigured: () => true, - isInitialized: () => true, - ensureInitialized: jest.fn().mockResolvedValue(undefined), - generateAuthUrl: jest.fn().mockReturnValue({ url: 'http://auth.example.com/auth?state=test-state', codeVerifier: 'mock-code-verifier' }), - handleCallback: jest.fn().mockResolvedValue({ id: 'u1', email: 'u@e.com', roles: ['user'] }), - }, -})) - -jest.mock('../src/config/database', () => ({ - db: Object.assign(jest.fn(), { - transaction: jest.fn(), - insert: jest.fn().mockResolvedValue([]), - }), -})) - -jest.mock('../src/middleware/auth', () => ({ - authenticateToken: (_req: any, _res: any, next: any) => next(), - requireRole: () => (_req: any, _res: any, next: any) => next(), -})) - -jest.mock('jsonwebtoken', () => ({ - sign: jest.fn().mockReturnValue('mock-jwt-token'), - verify: jest.fn(), -})) - -jest.mock('../src/services/organizationProvisioning', () => ({ - runInternalProvision: jest.fn().mockResolvedValue(undefined), -})) - -jest.mock('uuid', () => ({ - v4: jest.fn().mockReturnValue('mock-uuid-1234'), -})) - -import { db } from '../src/config/database' -import authRouter from '../src/routes/auth' - -const dbMock = db as jest.MockedFunction - -const app = express() -app.use(express.json()) -app.use('/api/auth', authRouter) - -beforeEach(() => { - jest.clearAllMocks() - - // Default db mock: sessions.insert returns a resolved promise - dbMock.mockImplementation((table: string) => { - if (table === 'sessions') { - return { - insert: jest.fn().mockResolvedValue([]), - where: jest.fn().mockReturnThis(), - first: jest.fn().mockResolvedValue(null), - } - } - return { - where: jest.fn().mockReturnThis(), - first: jest.fn().mockResolvedValue(null), - insert: jest.fn().mockResolvedValue([]), - } - }) -}) - -describe('OIDC state cookie CSRF protection', () => { - it('sets oidc_state cookie on /oidc/login', async () => { - const res = await request(app).get('/api/auth/oidc/login') - expect(res.status).toBe(302) - const cookies = res.headers['set-cookie'] - expect(Array.isArray(cookies) ? cookies.join(';') : cookies).toMatch(/oidc_state=/) - }) - - it('rejects callback when oidc_state cookie is missing', async () => { - const res = await request(app).get('/api/auth/oidc/callback?code=abc&state=xyz') - expect(res.status).toBe(302) - expect(res.headers.location).toMatch(/error=invalid_state/) - }) - - it('rejects callback when oidc_state cookie does not match state param', async () => { - const res = await request(app) - .get('/api/auth/oidc/callback?code=abc&state=xyz') - .set('Cookie', 'oidc_state=DIFFERENT_VALUE') - expect(res.status).toBe(302) - expect(res.headers.location).toMatch(/error=invalid_state/) - }) - - it('rejects callback when oidc_state cookie is same length as state param but different content (exercises timingSafeEqual)', async () => { - // Two distinct UUID-shaped strings of equal length — the length pre-check passes, - // so timingSafeEqual is the only guard that can fire here. - const cookieState = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' - const queryState = 'z9y8x7w6-v5u4-3210-fedc-ba9876543210' - expect(cookieState.length).toBe(queryState.length) // sanity - const res = await request(app) - .get(`/api/auth/oidc/callback?code=abc&state=${queryState}`) - .set('Cookie', `oidc_state=${cookieState}`) - expect(res.status).toBe(302) - expect(res.headers.location).toMatch(/error=invalid_state/) - }) - - it('accepts callback when oidc_state cookie matches state param', async () => { - // First get the state from a login redirect - const loginRes = await request(app).get('/api/auth/oidc/login') - // Extract both oidc_state and oidc_cv from set-cookie headers - const setCookieRaw = loginRes.headers['set-cookie'] - const setCookie = Array.isArray(setCookieRaw) ? setCookieRaw : [setCookieRaw as string] - const stateCookieStr = setCookie.find(c => c.startsWith('oidc_state='))! - const cvCookieStr = setCookie.find(c => c.startsWith('oidc_cv='))! - const stateValue = stateCookieStr.split(';')[0].split('=')[1] - const cvValue = cvCookieStr.split(';')[0].split('=')[1] - - const { oidcService } = require('../src/services/oidc') - const callbackRes = await request(app) - .get(`/api/auth/oidc/callback?code=authcode&state=${stateValue}`) - .set('Cookie', `oidc_state=${stateValue}; oidc_cv=${cvValue}`) - // Should NOT redirect to invalid_state - expect(callbackRes.headers.location).not.toMatch(/error=invalid_state/) - expect(oidcService.handleCallback).toHaveBeenCalled() - }) -}) +/** + * Unit tests for OIDC state cookie CSRF protection. + * Verifies that /oidc/login sets an oidc_state HttpOnly cookie, and + * /oidc/callback rejects requests whose state query param does not match the cookie. + */ +import express from 'express' +import request from 'supertest' + +jest.mock('../src/services/oidc', () => { + // getOidcService() replaced the former `oidcService` singleton (the client is + // now resolved per tenant). Expose both, backed by the SAME object, so the + // assertions below still address what the code under test receives. + const mod: any = ({ + oidcService: { + isConfigured: () => true, + isInitialized: () => true, + ensureInitialized: jest.fn().mockResolvedValue(undefined), + generateAuthUrl: jest.fn().mockReturnValue({ url: 'http://auth.example.com/auth?state=test-state', codeVerifier: 'mock-code-verifier' }), + handleCallback: jest.fn().mockResolvedValue({ id: 'u1', email: 'u@e.com', roles: ['user'] }), + }, +}) + mod.getOidcService = () => mod.oidcService + return mod +}) + +jest.mock('../src/config/database', () => ({ + db: Object.assign(jest.fn(), { + transaction: jest.fn(), + insert: jest.fn().mockResolvedValue([]), + }), +})) + +jest.mock('../src/middleware/auth', () => ({ + authenticateToken: (_req: any, _res: any, next: any) => next(), + requireRole: () => (_req: any, _res: any, next: any) => next(), +})) + +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn().mockReturnValue('mock-jwt-token'), + verify: jest.fn(), +})) + +jest.mock('../src/services/organizationProvisioning', () => ({ + runInternalProvision: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('mock-uuid-1234'), +})) + +import { db } from '../src/config/database' +import authRouter from '../src/routes/auth' + +const dbMock = db as jest.MockedFunction + +const app = express() +app.use(express.json()) +app.use('/api/auth', authRouter) + +beforeEach(() => { + jest.clearAllMocks() + + // Default db mock: sessions.insert returns a resolved promise + dbMock.mockImplementation((table: string) => { + if (table === 'sessions') { + return { + insert: jest.fn().mockResolvedValue([]), + where: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue(null), + } + } + return { + where: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue(null), + insert: jest.fn().mockResolvedValue([]), + } + }) +}) + +describe('OIDC state cookie CSRF protection', () => { + it('sets oidc_state cookie on /oidc/login', async () => { + const res = await request(app).get('/api/auth/oidc/login') + expect(res.status).toBe(302) + const cookies = res.headers['set-cookie'] + expect(Array.isArray(cookies) ? cookies.join(';') : cookies).toMatch(/oidc_state=/) + }) + + it('rejects callback when oidc_state cookie is missing', async () => { + const res = await request(app).get('/api/auth/oidc/callback?code=abc&state=xyz') + expect(res.status).toBe(302) + expect(res.headers.location).toMatch(/error=invalid_state/) + }) + + it('rejects callback when oidc_state cookie does not match state param', async () => { + const res = await request(app) + .get('/api/auth/oidc/callback?code=abc&state=xyz') + .set('Cookie', 'oidc_state=DIFFERENT_VALUE') + expect(res.status).toBe(302) + expect(res.headers.location).toMatch(/error=invalid_state/) + }) + + it('rejects callback when oidc_state cookie is same length as state param but different content (exercises timingSafeEqual)', async () => { + // Two distinct UUID-shaped strings of equal length — the length pre-check passes, + // so timingSafeEqual is the only guard that can fire here. + const cookieState = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const queryState = 'z9y8x7w6-v5u4-3210-fedc-ba9876543210' + expect(cookieState.length).toBe(queryState.length) // sanity + const res = await request(app) + .get(`/api/auth/oidc/callback?code=abc&state=${queryState}`) + .set('Cookie', `oidc_state=${cookieState}`) + expect(res.status).toBe(302) + expect(res.headers.location).toMatch(/error=invalid_state/) + }) + + it('accepts callback when oidc_state cookie matches state param', async () => { + // First get the state from a login redirect + const loginRes = await request(app).get('/api/auth/oidc/login') + // Extract both oidc_state and oidc_cv from set-cookie headers + const setCookieRaw = loginRes.headers['set-cookie'] + const setCookie = Array.isArray(setCookieRaw) ? setCookieRaw : [setCookieRaw as string] + const stateCookieStr = setCookie.find(c => c.startsWith('oidc_state='))! + const cvCookieStr = setCookie.find(c => c.startsWith('oidc_cv='))! + const stateValue = stateCookieStr.split(';')[0].split('=')[1] + const cvValue = cvCookieStr.split(';')[0].split('=')[1] + + const { getOidcService } = require('../src/services/oidc') + const oidcService = getOidcService() + const callbackRes = await request(app) + .get(`/api/auth/oidc/callback?code=authcode&state=${stateValue}`) + .set('Cookie', `oidc_state=${stateValue}; oidc_cv=${cvValue}`) + // Should NOT redirect to invalid_state + expect(callbackRes.headers.location).not.toMatch(/error=invalid_state/) + expect(oidcService.handleCallback).toHaveBeenCalled() + }) +})