diff --git a/.env.web.example b/.env.web.example index df4e7f2ce..1accd21d7 100644 --- a/.env.web.example +++ b/.env.web.example @@ -88,3 +88,26 @@ APP_PATH_PREFIX=/ VARLENS_ADMIN_USERNAME= VARLENS_ADMIN_PASSWORD_HASH= VARLENS_ADMIN_DISPLAY_NAME= + +# --- Optional platform OIDC authentication ------------------------------- +# +# Unset or `local` keeps the existing username/password flow. `platform` +# redirects web sign-in through an OIDC provider such as Keycloak. Desktop +# builds do not read these settings. +# +# Before the first platform login, bind the expected OIDC subject to this +# VarLens instance using the built operator command: +# +# node out/web/provision-platform-user.cjs \ +# --subject --display-name --role admin + +# VARLENS_AUTH_MODE=platform +# VARLENS_PLATFORM_ISSUER_URL=https://identity.example/realms/varlens +# VARLENS_PLATFORM_CLIENT_ID=varlens +# VARLENS_PLATFORM_AUDIENCE=varlens +# VARLENS_PLATFORM_CALLBACK_PATH=/auth/platform/callback +# VARLENS_PLATFORM_REQUIRED_ACR=urn:example:acr:password-plus-totp +# VARLENS_PLATFORM_REQUIRED_AMR=pwd,otp +# VARLENS_PLATFORM_ENTITLEMENTS_URL=https://platform.example/api/varlens/entitlements +# VARLENS_PLATFORM_ENTITLEMENTS_TOKEN= +# VARLENS_PLATFORM_VERIFY_ACCESS_TOKEN=false diff --git a/Dockerfile b/Dockerfile index 385eb4ddb..3d0dd70b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,8 @@ RUN node -e "(async () => { \ require('./out/web/server.cjs'); \ require('node:fs').accessSync('./out/web/postgres-import-worker.cjs'); \ require('./out/web/postgres-import-worker.cjs'); \ + require('node:fs').accessSync('./out/web/provision-platform-user.cjs'); \ + require('./out/web/provision-platform-user.cjs'); \ const Database = require('better-sqlite3-multiple-ciphers'); \ new Database(':memory:').prepare('SELECT 1').get(); \ const argon2 = require('@node-rs/argon2'); \ diff --git a/Makefile b/Makefile index 87a44e546..e52a31bf7 100644 --- a/Makefile +++ b/Makefile @@ -210,7 +210,9 @@ web-gate-postgres: build-web ## Run fail-loud Postgres-backed web integration te @if [ -z "$$VARLENS_PG_URL" ]; then echo "VARLENS_PG_URL is required for web-gate-postgres. This is intentionally opt-in and never part of default desktop CI."; exit 2; fi npx vitest run --project web-gate tests/web-gate/integration VARLENS_RUN_POSTGRES_E2E=1 npx vitest run --project main \ - tests/main/storage/postgres-cases-query-repository.e2e.test.ts + tests/main/storage/postgres-cases-query-repository.e2e.test.ts \ + tests/main/storage/postgres-migrations-idempotent.test.ts \ + tests/main/web/auth/provision-platform-user-postgres.test.ts web-gate-parity: web-data-verify ## Run Layer 3 parity scenarios (opt-in; boots Electron, switches native ABI) @echo "=== web-gate-parity (opt-in; switches native module to Electron ABI) ===" diff --git a/src/main/storage/postgres/migrations/definitions.ts b/src/main/storage/postgres/migrations/definitions.ts index 4f1a71e0e..86353eafa 100644 --- a/src/main/storage/postgres/migrations/definitions.ts +++ b/src/main/storage/postgres/migrations/definitions.ts @@ -78,6 +78,11 @@ const MIGRATION_FILES: readonly MigrationFile[] = [ version: '0015', name: 'import_visibility', fileName: '0015_import_visibility.sql' + }, + { + version: '0016', + name: 'platform_identity', + fileName: '0016_platform_identity.sql' } ] diff --git a/src/main/storage/postgres/migrations/sql/0016_platform_identity.sql b/src/main/storage/postgres/migrations/sql/0016_platform_identity.sql new file mode 100644 index 000000000..5b75975ac --- /dev/null +++ b/src/main/storage/postgres/migrations/sql/0016_platform_identity.sql @@ -0,0 +1,28 @@ +-- Optional web OIDC identity binding. Instance and database lifecycle remains +-- external; one VarLens database may be bound to at most one platform subject. + +ALTER TABLE "__schema__"."users" + ADD COLUMN IF NOT EXISTS auth_source TEXT NOT NULL DEFAULT 'local'; + +UPDATE "__schema__"."users" +SET auth_source = 'platform' +WHERE password_hash = 'platform-identity-disabled-local-password'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'users_auth_source_check' + AND conrelid = '"__schema__"."users"'::regclass + ) THEN + ALTER TABLE "__schema__"."users" + ADD CONSTRAINT users_auth_source_check + CHECK (auth_source IN ('local', 'platform')); + END IF; +END +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS users_single_platform_identity + ON "__schema__"."users" (auth_source) + WHERE auth_source = 'platform'; diff --git a/src/web/auth/PostgresPlatformUserStore.ts b/src/web/auth/PostgresPlatformUserStore.ts new file mode 100644 index 000000000..607d404ce --- /dev/null +++ b/src/web/auth/PostgresPlatformUserStore.ts @@ -0,0 +1,74 @@ +import type { Pool } from 'pg' + +import type { UserRole } from '../../shared/auth/auth-constants' + +const DISABLED_LOCAL_PASSWORD_HASH = 'platform-identity-disabled-local-password' + +interface PostgresError extends Error { + code?: string + constraint?: string +} + +function quoteSchema(schema: string): string { + return `"${schema.replace(/"/g, '""')}"` +} + +export class PostgresPlatformUserStore { + private readonly schemaQuoted: string + + constructor( + private readonly pool: Pool, + schema: string + ) { + this.schemaQuoted = quoteSchema(schema) + } + + async upsert(input: { + subject: string + displayName: string + role: UserRole + }): Promise<{ id: number; subject: string; role: UserRole }> { + let result + try { + result = await this.pool.query<{ id: string; username: string; role: UserRole }>( + `INSERT INTO ${this.schemaQuoted}."users" AS platform_target + (username, display_name, password_hash, role, must_change_password, is_active, + password_changed_at, auth_source) + VALUES ($1, $2, $3, $4, FALSE, TRUE, now(), 'platform') + ON CONFLICT (username) + DO UPDATE SET + display_name = EXCLUDED.display_name, + role = EXCLUDED.role, + is_active = TRUE, + must_change_password = FALSE, + updated_at = now() + WHERE platform_target.auth_source = 'platform' + AND platform_target.password_hash = $5 + RETURNING id, username, role`, + [ + input.subject, + input.displayName, + DISABLED_LOCAL_PASSWORD_HASH, + input.role, + DISABLED_LOCAL_PASSWORD_HASH + ] + ) + } catch (error) { + const postgresError = error as PostgresError + if ( + postgresError.code === '23505' && + postgresError.constraint === 'users_single_platform_identity' + ) { + throw new Error('VarLens instance is already bound to another platform subject', { + cause: error + }) + } + throw error + } + if ((result.rowCount ?? 0) === 0) { + throw new Error(`Platform identity cannot overwrite local user: ${input.subject}`) + } + const row = result.rows[0] + return { id: Number(row.id), subject: row.username, role: row.role } + } +} diff --git a/src/web/auth/PostgresWebAuthService.ts b/src/web/auth/PostgresWebAuthService.ts index 388beeca4..0978b0dd8 100644 --- a/src/web/auth/PostgresWebAuthService.ts +++ b/src/web/auth/PostgresWebAuthService.ts @@ -437,6 +437,17 @@ export class PostgresWebAuthService { return mapPgRowToUser(sel.rows[0]) } + async getPlatformUser(subject: string): Promise { + const sch = this.schemaQuoted + const sel = await this.pool.query>( + `SELECT * FROM ${sch}."users" + WHERE username = $1 AND auth_source = 'platform'`, + [subject] + ) + if ((sel.rowCount ?? 0) === 0) return undefined + return mapPgRowToUser(sel.rows[0]) + } + async listUsers(): Promise[]> { const sch = this.schemaQuoted const sel = await this.pool.query>( diff --git a/src/web/provision-platform-user.ts b/src/web/provision-platform-user.ts new file mode 100644 index 000000000..ce399f42f --- /dev/null +++ b/src/web/provision-platform-user.ts @@ -0,0 +1,86 @@ +import { getPostgresStorageConfig } from '../main/storage/config' +import { createPostgresStorageSession } from '../main/storage/postgres/createPostgresStorageSession' +import { ROLE_ADMIN, ROLE_USER, type UserRole } from '../shared/auth/auth-constants' +import { PostgresPlatformUserStore } from './auth/PostgresPlatformUserStore' + +interface Options { + subject: string + displayName: string + role: UserRole +} + +const VALUE_ARGS = new Set(['--subject', '--display-name', '--role']) + +function readArg(args: string[], name: string): string | undefined { + const index = args.indexOf(name) + if (index < 0) return undefined + const value = args[index + 1] + if (value === undefined || value.startsWith('--')) { + throw new Error(`${name} requires a value`) + } + return value +} + +function assertKnownArgs(args: string[]): void { + for (let index = 0; index < args.length; index += 2) { + const name = args[index] + if (!VALUE_ARGS.has(name)) { + throw new Error(`Unknown argument: ${name}`) + } + if (args[index + 1] === undefined) { + throw new Error(`${name} requires a value`) + } + } +} + +export function parseOptions(args: string[]): Options { + assertKnownArgs(args) + const subject = readArg(args, '--subject')?.trim() + const displayName = readArg(args, '--display-name')?.trim() + const role = readArg(args, '--role')?.trim() ?? ROLE_USER + + if (subject === undefined || subject === '') { + throw new Error('--subject is required') + } + if (displayName === undefined || displayName === '') { + throw new Error('--display-name is required') + } + if (role !== ROLE_USER && role !== ROLE_ADMIN) { + throw new Error('--role must be either user or admin') + } + + return { subject, displayName, role } +} + +async function main(): Promise { + const options = parseOptions(process.argv.slice(2)) + const config = getPostgresStorageConfig(process.env) + if (config === null) { + throw new Error('VARLENS_PG_URL is required') + } + + const session = await createPostgresStorageSession(config) + try { + const users = new PostgresPlatformUserStore(session.getPool(), config.schema) + const result = await users.upsert({ + subject: options.subject, + displayName: options.displayName, + role: options.role + }) + process.stdout.write( + JSON.stringify({ ok: true, subject: result.subject, role: result.role }) + '\n' + ) + } finally { + await session.close() + } +} + +declare const require: NodeJS.Require +declare const module: NodeJS.Module +if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) { + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(JSON.stringify({ ok: false, error: message }) + '\n') + process.exit(1) + }) +} diff --git a/src/web/server.ts b/src/web/server.ts index edf000401..bb7508941 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -30,11 +30,15 @@ import { createPostgresStorageSession } from '../main/storage/postgres/createPos import type { PostgresStorageSession } from '../main/storage/postgres/PostgresStorageSession' import type { StorageSession } from '../main/storage/session' import { AdminAlreadyExistsError, PostgresWebAuthService } from './auth/PostgresWebAuthService' +import { recordAuthAudit } from './server/audit' import { buildDispatcher, registerDispatcher } from './server/dispatcher' import { registerSessions } from './server/auth' import { registerEventStream, WebEventHub } from './server/events' import { registerLoginRoute, resolveAppPathPrefix } from './server/login-route' import { registerPageGate } from './server/page-gate' +import { PlatformIdentityService } from './server/platform-identity' +import { readPlatformIdentityConfig } from './server/platform-identity-config' +import { registerPlatformIdentityRoutes } from './server/platform-identity-routes' import { registerWebRateLimit } from './server/rate-limit' import { registerImportUploadRoutes } from './server/routes/upload-staging' import { registerOpenApi } from './server/routes/openapi' @@ -82,6 +86,13 @@ export interface BuildAppOptions { } export async function buildApp(options: BuildAppOptions = {}): Promise { + const appPathPrefix = resolveAppPathPrefix() + const platformIdentityConfig = readPlatformIdentityConfig(process.env) + const platformIdentity = + platformIdentityConfig === null + ? undefined + : new PlatformIdentityService(platformIdentityConfig) + // Validate Postgres config BEFORE building the app; any later // failure path means we'd hold a partially-spun Fastify instance, // which the SIGTERM tests can't cleanly tear down. @@ -95,6 +106,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise randomUUID(), + // Pinned, not inherited. Fastify 5 already defaults this to false, but the + // default has changed across majors and `true` resolves to the `request-id` + // header — which would let a client choose its own request ID and forge or + // collide log correlation. Stating it explicitly means a future Fastify + // default flip cannot silently re-enable header trust. + requestIdHeader: false, logController: new LogController({ requestIdLogLabel: 'request_id' }), logger: { level: process.env.VARLENS_LOG_LEVEL ?? 'info' @@ -122,7 +139,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + await recordAuthAudit( + { session: session as StorageSession } as Parameters[0], + { + action_type: event.action, + username: event.subject ?? 'platform-login-attempt', + ...(event.subject !== undefined ? { actor: event.subject } : {}), + ...(event.role !== undefined ? { role: event.role } : {}), + success: event.action === 'auth_login_success', + ...(event.reason !== undefined ? { reason: event.reason } : {}) + } + ) + } + }) + } + registerLoginRoute(app, { platformAuthEnabled: platformIdentity !== undefined }) + registerPageGate(app, { + appPathPrefix, + loginPath: platformIdentity !== undefined ? '/auth/platform/start' : '/login', + ...(platformIdentity !== undefined + ? { + platformCallbackPath: platformIdentity.config.callbackPath, + requirePlatformAuth: true + } + : {}) + }) const dispatcherDeps = { session: session as StorageSession, diff --git a/src/web/server/auth.ts b/src/web/server/auth.ts index 7a07f5842..129f86b81 100644 --- a/src/web/server/auth.ts +++ b/src/web/server/auth.ts @@ -36,11 +36,23 @@ import type { FastifyInstance } from 'fastify' import secureSession from '@fastify/secure-session' import type { PostgresWebAuthService } from '../auth/PostgresWebAuthService' +import type { PlatformIdentityService } from './platform-identity' import { registerAuthLoginRateLimit } from './rate-limit' declare module '@fastify/secure-session' { interface SessionData { user: { id: number; username: string; role: string; passwordChangedAt: string | null } + authMode?: 'local' | 'platform' + platformOidc?: Record< + string, + { + nonce: string + codeVerifier: string + next: string + createdAt: number + mfaRetry?: boolean + } + > /** * Sticky bit set on login when the authenticated user has * must_change_password=TRUE in the DB; cleared by the @@ -180,10 +192,11 @@ function loadOrCreateSessionKey(): Buffer { export async function registerSessions( app: FastifyInstance, - options: { authService: PostgresWebAuthService } + options: { authService: PostgresWebAuthService; platformIdentity?: PlatformIdentityService } ): Promise { const key = loadOrCreateSessionKey() const production = isProductionMode() + const platformMode = options.platformIdentity !== undefined await app.register(secureSession, { key, @@ -191,12 +204,10 @@ export async function registerSessions( cookie: { path: '/', httpOnly: true, - // SameSite=Strict for an admin-only single-tenant tool with no - // cross-site flows (no SSO redirect, no embeds, no third-party - // links coming back into authenticated pages). Lax was a - // legacy-from-desktop default that opened a window for - // cross-site GET-triggered side effects; Strict closes it. - sameSite: 'strict', + // Local login has no cross-site flow, so Strict is viable. + // Platform OIDC needs Lax so the top-level GET callback from + // the IdP carries the transient state/nonce session. + sameSite: platformMode ? 'lax' : 'strict', // Production: Secure is non-negotiable — `__Host-` prefix // *requires* Secure, and we never want a session cookie // travelling over HTTP. Dev / test: drop Secure so localhost @@ -263,6 +274,48 @@ export async function registerSessions( }) } + if (platformMode && request.session.authMode !== 'platform') { + request.session.delete() + reply.code(401) + return reply.send({ + code: 'UNAUTHENTICATED', + message: 'platform login is required', + userMessage: 'Please log in again.' + }) + } + + if (request.session.authMode === 'platform') { + if (options.platformIdentity === undefined) { + request.session.delete() + reply.code(401) + return reply.send({ + code: 'UNAUTHENTICATED', + message: 'platform identity is not configured', + userMessage: 'Please log in again.' + }) + } + try { + const platformUser = await options.platformIdentity.resolveSessionUser( + options.authService, + sessionUser.username + ) + if (platformUser.id !== sessionUser.id) { + throw new Error('platform user id changed') + } + request.session.mustChangePassword = false + request.session.user = platformUser + } catch { + request.session.delete() + reply.code(401) + return reply.send({ + code: 'UNAUTHENTICATED', + message: 'platform session no longer valid', + userMessage: 'Please log in again.' + }) + } + return + } + request.session.mustChangePassword = liveUser.must_change_password === 1 request.session.user = { id: liveUser.id, diff --git a/src/web/server/login-route.ts b/src/web/server/login-route.ts index 297135799..987218907 100644 --- a/src/web/server/login-route.ts +++ b/src/web/server/login-route.ts @@ -148,7 +148,10 @@ export function renderLoginPage(appPathPrefix: string, redirectTo: string): stri .join(escapeForJsString(redirectTo)) } -export function registerLoginRoute(app: FastifyInstance): void { +export function registerLoginRoute( + app: FastifyInstance, + options: { platformAuthEnabled?: boolean } = {} +): void { const appPathPrefix = resolveAppPathPrefix() const loginPageRateLimit = buildLoginPageRateLimitConfig() const loginPageRateLimiter = app.rateLimit(loginPageRateLimit) @@ -157,12 +160,22 @@ export function registerLoginRoute(app: FastifyInstance): void { request: { query: unknown }, reply: { header: (k: string, v: string) => unknown + code: (c: number) => unknown type: (t: string) => unknown send: (b: string) => unknown } ): Promise => { const query = (request.query ?? {}) as Record const redirectTo = sanitizeNextParam(query.next, appPathPrefix) + if (options.platformAuthEnabled === true) { + reply.header('cache-control', 'no-store') + reply.code(302) + reply.header( + 'location', + `${appPathPrefix}/auth/platform/start?next=${encodeURIComponent(redirectTo)}` + ) + return reply.send('') + } const html = renderLoginPage(appPathPrefix, redirectTo) reply.header('cache-control', 'no-store') reply.header('content-security-policy', LOGIN_PAGE_CSP) diff --git a/src/web/server/page-gate.ts b/src/web/server/page-gate.ts index c54fce346..3e10d51ef 100644 --- a/src/web/server/page-gate.ts +++ b/src/web/server/page-gate.ts @@ -47,10 +47,6 @@ const PUBLIC_ROOT_ASSETS = new Set([ '/icon-maskable-512.png' ]) -function isPublicPath(path: string): boolean { - return ALWAYS_PUBLIC_PATHS.has(path) || PUBLIC_ROOT_ASSETS.has(path) -} - /** * Build the `?next=` value for the post-login redirect. Returns an * empty string when the request path isn't a safe relative path; the @@ -75,10 +71,19 @@ export interface PageGateOptions { * `Location` header for the 302. Defaults are resolved by login-route.ts. */ appPathPrefix: string + loginPath?: string + platformCallbackPath?: string + requirePlatformAuth?: boolean } export function registerPageGate(app: FastifyInstance, options: PageGateOptions): void { const { appPathPrefix } = options + const loginPath = options.loginPath ?? '/login' + const publicPaths = new Set(ALWAYS_PUBLIC_PATHS) + if (options.platformCallbackPath !== undefined) { + publicPaths.add('/auth/platform/start') + publicPaths.add(options.platformCallbackPath) + } app.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => { // Only intercept GETs. POST/PUT/DELETE traffic is API-only and is @@ -91,10 +96,7 @@ export function registerPageGate(app: FastifyInstance, options: PageGateOptions) // `/api/*` is auth.ts's territory — never short-circuit it here, or // the API would start redirecting instead of returning JSON 401s. if (path.startsWith('/api/')) return - if (isPublicPath(path)) return - - const user = request.session?.user - if (user !== undefined) return + if (publicPaths.has(path) || PUBLIC_ROOT_ASSETS.has(path)) return // Build the redirect target, prepending the app prefix because a // prefix-stripping proxy forwards `/login` to Fastify while the @@ -102,9 +104,18 @@ export function registerPageGate(app: FastifyInstance, options: PageGateOptions) const next = buildNextParam(fullUrl) const location = appPathPrefix + - '/login' + + loginPath + (next !== '' ? '?next=' + encodeURIComponent(appPathPrefix + next) : '') + const user = request.session?.user + if (user !== undefined) { + if (options.requirePlatformAuth === true && request.session.authMode !== 'platform') { + request.session.delete() + } else { + return + } + } + reply.header('cache-control', 'no-store') reply.code(302) reply.header('location', location) diff --git a/src/web/server/platform-identity-config.ts b/src/web/server/platform-identity-config.ts new file mode 100644 index 000000000..bd0d3ac20 --- /dev/null +++ b/src/web/server/platform-identity-config.ts @@ -0,0 +1,134 @@ +export type PlatformAuthMode = 'local' | 'platform' + +export interface PlatformIdentityConfig { + mode: 'platform' + issuerUrl: string + clientId: string + audience: string + callbackPath: string + requiredAcr: string + requiredAmr: string[] + entitlementsUrl: string + entitlementsToken?: string + verifyAccessToken: boolean +} + +function hasValue(value: string | undefined): value is string { + return typeof value === 'string' && value.trim() !== '' +} + +function normalizeMode(raw: string | undefined): PlatformAuthMode { + if (!hasValue(raw)) return 'local' + const normalized = raw.trim().toLowerCase() + if (normalized === 'local' || normalized === 'platform') return normalized + throw new Error('VARLENS_AUTH_MODE must be either "local" or "platform"') +} + +function requireEnv(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name] + if (!hasValue(value)) { + throw new Error(`${name} is required when VARLENS_AUTH_MODE=platform`) + } + return value.trim() +} + +function requireHttpsOrLocalHttpUrl(name: string, raw: string): string { + let parsed: URL + try { + parsed = new URL(raw) + } catch { + throw new Error(`${name} must be a valid URL`) + } + const isLocalHttp = + parsed.protocol === 'http:' && + (parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '::1' || + parsed.hostname.endsWith('.svc.cluster.local')) + if (parsed.protocol !== 'https:' && !isLocalHttp) { + throw new Error(`${name} must use https, localhost http, or cluster-internal http`) + } + parsed.hash = '' + parsed.search = '' + return parsed.toString().replace(/\/$/, '') +} + +function requirePath(name: string, raw: string): string { + if (!raw.startsWith('/')) { + throw new Error(`${name} must start with /`) + } + if (raw.includes('\\') || raw.includes('..') || raw.startsWith('//')) { + throw new Error(`${name} must be a safe absolute path`) + } + return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw +} + +function assertStrongPlatformToken(name: string, value: string): void { + const looksHex = /^[0-9a-fA-F]+$/.test(value) + const decodedBytes = looksHex && value.length % 2 === 0 ? value.length / 2 : null + const strongEnough = value.length >= 32 && (decodedBytes === null || decodedBytes >= 32) + if (!strongEnough) { + throw new Error( + `${name} must be at least 32 characters (or, if hex-encoded, decode to at least 32 bytes)` + ) + } +} + +function parseRequiredAmr(raw: string): string[] { + const values = raw + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '') + if (values.length === 0) { + throw new Error('VARLENS_PLATFORM_REQUIRED_AMR must include at least one amr value') + } + return values +} + +export function readPlatformIdentityConfig( + env: NodeJS.ProcessEnv = process.env +): PlatformIdentityConfig | null { + const mode = normalizeMode(env.VARLENS_AUTH_MODE) + if (mode === 'local') return null + + const issuerUrl = requireHttpsOrLocalHttpUrl( + 'VARLENS_PLATFORM_ISSUER_URL', + requireEnv(env, 'VARLENS_PLATFORM_ISSUER_URL') + ) + const entitlementsUrl = requireHttpsOrLocalHttpUrl( + 'VARLENS_PLATFORM_ENTITLEMENTS_URL', + requireEnv(env, 'VARLENS_PLATFORM_ENTITLEMENTS_URL') + ) + const clientId = requireEnv(env, 'VARLENS_PLATFORM_CLIENT_ID') + const audience = requireEnv(env, 'VARLENS_PLATFORM_AUDIENCE') + const requiredAcr = requireEnv(env, 'VARLENS_PLATFORM_REQUIRED_ACR') + const requiredAmr = parseRequiredAmr(requireEnv(env, 'VARLENS_PLATFORM_REQUIRED_AMR')) + const rawCallbackPath = env.VARLENS_PLATFORM_CALLBACK_PATH?.trim() + const callbackPath = requirePath( + 'VARLENS_PLATFORM_CALLBACK_PATH', + rawCallbackPath !== undefined && rawCallbackPath !== '' + ? rawCallbackPath + : '/auth/platform/callback' + ) + const entitlementsToken = env.VARLENS_PLATFORM_ENTITLEMENTS_TOKEN?.trim() + if (entitlementsToken !== undefined && entitlementsToken !== '') { + assertStrongPlatformToken('VARLENS_PLATFORM_ENTITLEMENTS_TOKEN', entitlementsToken) + } + + return { + mode: 'platform', + issuerUrl, + clientId, + audience, + callbackPath, + requiredAcr, + requiredAmr, + entitlementsUrl: entitlementsUrl.replace(/\/$/, ''), + ...(entitlementsToken !== undefined && entitlementsToken !== '' ? { entitlementsToken } : {}), + verifyAccessToken: env.VARLENS_PLATFORM_VERIFY_ACCESS_TOKEN === 'true' + } +} + +export function isPlatformIdentityEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return normalizeMode(env.VARLENS_AUTH_MODE) === 'platform' +} diff --git a/src/web/server/platform-identity-routes.ts b/src/web/server/platform-identity-routes.ts new file mode 100644 index 000000000..e7155159a --- /dev/null +++ b/src/web/server/platform-identity-routes.ts @@ -0,0 +1,292 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' + +import type { PostgresWebAuthService } from '../auth/PostgresWebAuthService' +import { sanitizeNextParam } from './login-route' +import { + PlatformIdentityService, + PlatformMfaClaimError, + type PlatformIdentityAuditInput +} from './platform-identity' + +const OIDC_STATE_TTL_MS = 10 * 60 * 1000 +const MAX_PENDING_OIDC_STATES = 5 + +interface PendingOidcState { + nonce: string + codeVerifier: string + next: string + createdAt: number + mfaRetry?: boolean +} + +function redirectWithNoStore(reply: FastifyReply, location: string): FastifyReply { + reply.header('cache-control', 'no-store') + reply.code(302) + reply.header('location', location) + return reply +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function platformLoginErrorHtml(retryLocationValue: string): string { + const retryLocation = escapeHtml(retryLocationValue) + return ` + + + + + Sign-in failed + + + +
+

Sign-in could not be completed

+

Please start sign-in again. If the problem continues, contact your VarLens operator.

+ Try again +
+ +` +} + +function sendPlatformLoginError(reply: FastifyReply, retryLocation: string): FastifyReply { + reply.header('cache-control', 'no-store') + reply.type('text/html; charset=utf-8') + reply.code(401) + return reply.send(platformLoginErrorHtml(retryLocation)) +} + +function activePendingOidcStates( + states: Record | undefined, + now: number +): Record { + if (states === undefined) return {} + return Object.fromEntries( + Object.entries(states).filter(([, pending]) => now - pending.createdAt <= OIDC_STATE_TTL_MS) + ) +} + +function rememberPendingOidcState(params: { + request: FastifyRequest + state: string + pending: PendingOidcState +}): void { + const pendingStates = activePendingOidcStates(params.request.session.platformOidc, Date.now()) + pendingStates[params.state] = params.pending + params.request.session.platformOidc = Object.fromEntries( + Object.entries(pendingStates) + .sort(([, left], [, right]) => right.createdAt - left.createdAt) + .slice(0, MAX_PENDING_OIDC_STATES) + ) +} + +function consumePendingOidcState( + request: FastifyRequest, + state: string +): PendingOidcState | undefined { + const pendingStates = { ...(request.session.platformOidc ?? {}) } + const pending = pendingStates[state] + delete pendingStates[state] + const activeStates = activePendingOidcStates(pendingStates, Date.now()) + request.session.platformOidc = Object.keys(activeStates).length > 0 ? activeStates : undefined + return pending +} + +function clearAuthenticatedSession(request: FastifyRequest): void { + delete request.session.user + delete request.session.authMode + request.session.mustChangePassword = false +} + +function callbackQuery(request: FastifyRequest): { code?: string; state?: string; error?: string } { + const query = (request.query ?? {}) as Record + return { + code: typeof query.code === 'string' ? query.code : undefined, + state: typeof query.state === 'string' ? query.state : undefined, + error: typeof query.error === 'string' ? query.error : undefined + } +} + +export function registerPlatformIdentityRoutes( + app: FastifyInstance, + options: { + identity: PlatformIdentityService + authService: PostgresWebAuthService + appPathPrefix: string + audit?: (input: PlatformIdentityAuditInput) => Promise + } +): void { + const auditBestEffort = async (input: PlatformIdentityAuditInput): Promise => { + try { + await options.audit?.(input) + } catch (error) { + app.log.warn({ err: error, action: input.action }, 'platform identity audit failed') + } + } + + app.get('/auth/platform/start', { schema: { hide: true } }, async (request, reply) => { + const query = (request.query ?? {}) as Record + const next = sanitizeNextParam(query.next, options.appPathPrefix) + clearAuthenticatedSession(request) + const authorization = await options.identity.createAuthorizationUrl({ + request, + appPathPrefix: options.appPathPrefix, + next, + forceFreshLogin: true + }) + rememberPendingOidcState({ + request, + state: authorization.state, + pending: { + nonce: authorization.nonce, + codeVerifier: authorization.codeVerifier, + next, + createdAt: Date.now() + } + }) + return redirectWithNoStore(reply, authorization.authorizationUrl).send() + }) + + app.get( + options.identity.config.callbackPath, + { schema: { hide: true } }, + async (request, reply) => { + const query = callbackQuery(request) + if (query.error !== undefined) { + await auditBestEffort({ action: 'auth_login_failure', reason: 'oidc-error' }) + request.session.delete() + return sendPlatformLoginError( + reply, + options.identity.buildStartLocation(options.appPathPrefix, '') + ) + } + if (query.code === undefined || query.state === undefined) { + await auditBestEffort({ action: 'auth_login_failure', reason: 'invalid-callback' }) + request.session.delete() + return redirectWithNoStore( + reply, + options.identity.buildStartLocation(options.appPathPrefix, '') + ).send() + } + const pending = consumePendingOidcState(request, query.state) + if (pending === undefined) { + await auditBestEffort({ action: 'auth_login_failure', reason: 'invalid-state' }) + if (request.session.user !== undefined) { + return redirectWithNoStore(reply, options.appPathPrefix || '/').send() + } + request.session.delete() + return redirectWithNoStore( + reply, + options.identity.buildStartLocation(options.appPathPrefix, '') + ).send() + } + if (Date.now() - pending.createdAt > OIDC_STATE_TTL_MS) { + await auditBestEffort({ action: 'auth_login_failure', reason: 'expired-state' }) + request.session.delete() + return redirectWithNoStore( + reply, + options.identity.buildStartLocation(options.appPathPrefix, '') + ).send() + } + + try { + const { subject } = await options.identity.completeCallback({ + request, + appPathPrefix: options.appPathPrefix, + code: query.code, + expectedNonce: pending.nonce, + codeVerifier: pending.codeVerifier + }) + const sessionUser = await options.identity.resolveSessionUser(options.authService, subject) + request.session.user = sessionUser + request.session.authMode = 'platform' + request.session.mustChangePassword = false + await auditBestEffort({ + action: 'auth_login_success', + subject, + role: sessionUser.role + }) + return redirectWithNoStore(reply, pending.next).send() + } catch (error) { + if ( + error instanceof PlatformMfaClaimError && + error.kind === 'amr' && + error.missingAmr === 'otp' && + pending.mfaRetry !== true + ) { + const authorization = await options.identity.createAuthorizationUrl({ + request, + appPathPrefix: options.appPathPrefix, + next: pending.next, + forceFreshLogin: false + }) + rememberPendingOidcState({ + request, + state: authorization.state, + pending: { + nonce: authorization.nonce, + codeVerifier: authorization.codeVerifier, + next: pending.next, + createdAt: Date.now(), + mfaRetry: true + } + }) + await auditBestEffort({ action: 'auth_login_failure', reason: 'missing-otp-amr-retry' }) + return redirectWithNoStore(reply, authorization.authorizationUrl).send() + } + request.session.delete() + const reason = + error instanceof PlatformMfaClaimError && error.kind === 'amr' + ? 'missing-required-amr' + : 'platform-denied' + request.log.warn({ err: error }, 'platform identity callback denied') + await auditBestEffort({ action: 'auth_login_failure', reason }) + return sendPlatformLoginError( + reply, + options.identity.buildStartLocation(options.appPathPrefix, pending.next) + ) + } + } + ) +} diff --git a/src/web/server/platform-identity.ts b/src/web/server/platform-identity.ts new file mode 100644 index 000000000..48c352a7b --- /dev/null +++ b/src/web/server/platform-identity.ts @@ -0,0 +1,528 @@ +import { createHash, createPublicKey, createVerify, randomBytes } from 'node:crypto' + +import type { FastifyRequest } from 'fastify' + +import type { UserRole } from '../../shared/auth/auth-constants' +import type { PostgresWebAuthService } from '../auth/PostgresWebAuthService' +import type { PlatformIdentityConfig } from './platform-identity-config' + +const JWT_CLOCK_SKEW_SECONDS = 60 +const JWKS_CACHE_TTL_MS = 5 * 60 * 1000 +const ENTITLEMENT_CACHE_TTL_MS = 30 * 1000 +const ENTITLEMENT_CACHE_MAX_ENTRIES = 500 +const OUTBOUND_FETCH_TIMEOUT_MS = 10_000 +const SUPPORTED_JWT_ALG = 'RS256' + +interface OidcDiscovery { + issuer: string + authorization_endpoint: string + token_endpoint: string + jwks_uri: string +} + +interface Jwk { + kid?: string + kty?: string + alg?: string + use?: string + n?: string + e?: string + [key: string]: unknown +} + +interface TokenResponse { + id_token: string + access_token: string + token_type?: string +} + +interface EntitlementResponse { + active?: boolean + allowed?: boolean + role?: string + status?: string + reason?: string +} + +interface VerifiedJwt { + header: Record + payload: Record +} + +export interface PlatformSessionUser { + id: number + username: string + role: UserRole + passwordChangedAt: string | null +} + +export interface PlatformIdentityAuditInput { + action: 'auth_login_success' | 'auth_login_failure' + subject?: string + role?: UserRole + reason?: string +} + +function encodeBase64Url(buffer: Buffer): string { + return buffer.toString('base64url') +} + +function decodeBase64UrlJson(value: string): Record { + const decoded = Buffer.from(value, 'base64url').toString('utf8') + const parsed = JSON.parse(decoded) as unknown + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('JWT segment must decode to a JSON object') + } + return parsed as Record +} + +function randomUrlSafeString(bytes = 32): string { + return encodeBase64Url(randomBytes(bytes)) +} + +function buildPkceChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +function claimIncludes(value: unknown, expected: string): boolean { + if (typeof value === 'string') return value === expected + if (Array.isArray(value)) return value.includes(expected) + return false +} + +function claimStringArray(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((part): part is string => typeof part === 'string') + if (typeof value === 'string') return [value] + return [] +} + +async function fetchWithTimeout(url: string, init: RequestInit = {}): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), OUTBOUND_FETCH_TIMEOUT_MS) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timeout) + } +} + +function requireStringClaim(payload: Record, name: string): string { + const value = payload[name] + if (typeof value !== 'string' || value === '') { + throw new Error(`JWT ${name} claim is required`) + } + return value +} + +function assertTemporalClaims(payload: Record, nowSeconds: number): void { + const exp = payload.exp + if (typeof exp !== 'number' || !Number.isFinite(exp)) { + throw new Error('JWT exp claim is required') + } + if (exp + JWT_CLOCK_SKEW_SECONDS < nowSeconds) { + throw new Error('JWT is expired') + } + + const nbf = payload.nbf + if (typeof nbf === 'number' && nbf - JWT_CLOCK_SKEW_SECONDS > nowSeconds) { + throw new Error('JWT is not yet valid') + } + + const iat = payload.iat + if (typeof iat !== 'number' || !Number.isFinite(iat)) { + throw new Error('JWT iat claim is required') + } + if (iat - JWT_CLOCK_SKEW_SECONDS > nowSeconds) { + throw new Error('JWT iat is in the future') + } +} + +export function verifyPlatformJwt(params: { + token: string + issuer: string + audience: string + jwks: Jwk[] + nowSeconds?: number +}): VerifiedJwt { + const segments = params.token.split('.') + if (segments.length !== 3 || segments.some((part) => part === '')) { + throw new Error('JWT must have three non-empty segments') + } + + const [encodedHeader, encodedPayload, encodedSignature] = segments + const header = decodeBase64UrlJson(encodedHeader) + const payload = decodeBase64UrlJson(encodedPayload) + const alg = header.alg + const kid = header.kid + if (alg !== SUPPORTED_JWT_ALG) { + throw new Error(`JWT alg must be ${SUPPORTED_JWT_ALG}`) + } + if (typeof kid !== 'string' || kid === '') { + throw new Error('JWT kid header is required') + } + + const jwk = params.jwks.find( + (candidate) => + candidate.kid === kid && + candidate.kty === 'RSA' && + (candidate.alg === undefined || candidate.alg === SUPPORTED_JWT_ALG) + ) + if (jwk === undefined) { + throw new Error(`JWKS key not found for kid ${kid}`) + } + + const verifier = createVerify('RSA-SHA256') + verifier.update(`${encodedHeader}.${encodedPayload}`) + verifier.end() + const publicKey = createPublicKey({ key: jwk as JsonWebKey, format: 'jwk' }) + if (!verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url'))) { + throw new Error('JWT signature is invalid') + } + + if (payload.iss !== params.issuer) { + throw new Error('JWT issuer does not match platform issuer') + } + if (!claimIncludes(payload.aud, params.audience)) { + throw new Error('JWT audience does not match platform audience') + } + if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== params.audience) { + throw new Error('JWT azp does not match platform audience') + } + assertTemporalClaims(payload, params.nowSeconds ?? Math.floor(Date.now() / 1000)) + + return { header, payload } +} + +export class PlatformMfaClaimError extends Error { + constructor( + message: string, + readonly kind: 'nonce' | 'acr' | 'amr', + readonly missingAmr?: string + ) { + super(message) + this.name = 'PlatformMfaClaimError' + } +} + +export function assertPlatformMfaClaims(params: { + payload: Record + requiredAcr: string + requiredAmr: string[] + expectedNonce: string + nowSeconds?: number +}): void { + if (params.payload.nonce !== params.expectedNonce) { + throw new PlatformMfaClaimError('OIDC nonce does not match', 'nonce') + } + if (params.payload.acr !== params.requiredAcr) { + throw new PlatformMfaClaimError('required MFA acr is missing', 'acr') + } + const amr = claimStringArray(params.payload.amr) + for (const required of params.requiredAmr) { + if (!amr.includes(required)) { + throw new PlatformMfaClaimError(`required MFA amr is missing: ${required}`, 'amr', required) + } + } + const nowSeconds = params.nowSeconds ?? Math.floor(Date.now() / 1000) + const authTime = params.payload.auth_time + if (typeof authTime !== 'number' || !Number.isFinite(authTime)) { + throw new PlatformMfaClaimError('OIDC auth_time claim is required', 'acr') + } + if ( + authTime - JWT_CLOCK_SKEW_SECONDS > nowSeconds || + nowSeconds - authTime > 10 * 60 + JWT_CLOCK_SKEW_SECONDS + ) { + throw new PlatformMfaClaimError('OIDC authentication is not fresh', 'acr') + } +} + +function isUserRole(value: string): value is UserRole { + return value === 'admin' || value === 'user' +} + +function assertObjectResponse(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${label} response must be a JSON object`) + } + return value as Record +} + +function requestOrigin(request: FastifyRequest): string { + const forwardedProto = request.headers['x-forwarded-proto'] + const proto = + typeof forwardedProto === 'string' && forwardedProto !== '' ? forwardedProto : request.protocol + const forwardedHost = request.headers['x-forwarded-host'] + const host = + typeof forwardedHost === 'string' && forwardedHost !== '' ? forwardedHost : request.headers.host + if (typeof host !== 'string' || host.trim() === '') { + throw new Error('Host header is required for OIDC redirect URI construction') + } + return `${proto}://${host}` +} + +function callbackRedirectUri( + request: FastifyRequest, + appPathPrefix: string, + callbackPath: string +): string { + return `${requestOrigin(request)}${appPathPrefix}${callbackPath}` +} + +export class PlatformIdentityService { + private discoveryCache: Promise | null = null + private jwksCache: { expiresAt: number; keys: Jwk[] } | null = null + private entitlementCache = new Map() + + constructor(readonly config: PlatformIdentityConfig) {} + + buildStartLocation(appPathPrefix: string, next: string): string { + const query = next !== '' ? `?next=${encodeURIComponent(next)}` : '' + return `${appPathPrefix}/auth/platform/start${query}` + } + + async resolveSessionUser( + authService: PostgresWebAuthService, + subject: string + ): Promise { + const entitlement = await this.requireActiveEntitlement(subject) + const liveUser = await authService.getPlatformUser(subject) + if (liveUser === undefined || liveUser.is_active !== 1) { + throw new Error('platform user is not provisioned or active in VarLens') + } + return { + id: liveUser.id, + username: subject, + role: entitlement.role, + passwordChangedAt: liveUser.password_changed_at + } + } + + async createAuthorizationUrl(params: { + request: FastifyRequest + appPathPrefix: string + next: string + forceFreshLogin?: boolean + }): Promise<{ authorizationUrl: string; state: string; nonce: string; codeVerifier: string }> { + const discovery = await this.discovery() + const state = randomUrlSafeString() + const nonce = randomUrlSafeString() + const codeVerifier = randomUrlSafeString() + const url = new URL(discovery.authorization_endpoint) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', this.config.clientId) + url.searchParams.set( + 'redirect_uri', + callbackRedirectUri(params.request, params.appPathPrefix, this.config.callbackPath) + ) + url.searchParams.set('scope', 'openid profile email') + url.searchParams.set('state', state) + url.searchParams.set('nonce', nonce) + url.searchParams.set('acr_values', this.config.requiredAcr) + if (params.forceFreshLogin !== false) { + url.searchParams.set('prompt', 'login') + url.searchParams.set('max_age', '0') + } + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('code_challenge', buildPkceChallenge(codeVerifier)) + return { authorizationUrl: url.toString(), state, nonce, codeVerifier } + } + + async completeCallback(params: { + request: FastifyRequest + appPathPrefix: string + code: string + expectedNonce: string + codeVerifier: string + }): Promise<{ subject: string }> { + const discovery = await this.discovery() + const tokenResponse = await this.exchangeCode({ + discovery, + request: params.request, + appPathPrefix: params.appPathPrefix, + code: params.code, + codeVerifier: params.codeVerifier + }) + const idToken = await this.verifyJwtWithJwks({ + token: tokenResponse.id_token, + issuer: this.config.issuerUrl, + audience: this.config.clientId, + discovery + }) + assertPlatformMfaClaims({ + payload: idToken.payload, + requiredAcr: this.config.requiredAcr, + requiredAmr: this.config.requiredAmr, + expectedNonce: params.expectedNonce + }) + if (this.config.verifyAccessToken) { + await this.verifyJwtWithJwks({ + token: tokenResponse.access_token, + issuer: this.config.issuerUrl, + audience: this.config.audience, + discovery + }) + } + return { subject: requireStringClaim(idToken.payload, 'sub') } + } + + private async requireActiveEntitlement(subject: string): Promise<{ role: UserRole }> { + const cached = this.entitlementCache.get(subject) + if (cached !== undefined && cached.expiresAt > Date.now()) { + return { role: cached.role } + } + const url = `${this.config.entitlementsUrl}/${encodeURIComponent(subject)}` + const headers: Record = { + accept: 'application/json' + } + if (this.config.entitlementsToken !== undefined) { + headers.authorization = `Bearer ${this.config.entitlementsToken}` + } + let response: Response + try { + response = await fetchWithTimeout(url, { headers }) + } catch (error) { + throw new Error('platform entitlement check failed', { cause: error }) + } + if (!response.ok) { + throw new Error(`platform entitlement check returned HTTP ${response.status}`) + } + const body = assertObjectResponse((await response.json()) as unknown, 'entitlement') + const wrapped = body.entitlement + const entitlement = ( + typeof wrapped === 'object' && wrapped !== null && !Array.isArray(wrapped) ? wrapped : body + ) as EntitlementResponse + if (entitlement.active !== true && entitlement.allowed !== true) { + throw new Error(`platform entitlement denied: ${entitlement.reason ?? 'not-allowed'}`) + } + if (entitlement.status !== 'active') { + throw new Error('platform entitlement is not active') + } + if (typeof entitlement.role !== 'string' || !isUserRole(entitlement.role)) { + throw new Error('platform entitlement role is not valid for VarLens') + } + const result = { role: entitlement.role } + if (this.entitlementCache.size >= ENTITLEMENT_CACHE_MAX_ENTRIES) { + const firstKey = this.entitlementCache.keys().next().value + if (typeof firstKey === 'string') { + this.entitlementCache.delete(firstKey) + } + } + this.entitlementCache.set(subject, { + ...result, + expiresAt: Date.now() + ENTITLEMENT_CACHE_TTL_MS + }) + return result + } + + private async exchangeCode(params: { + discovery: OidcDiscovery + request: FastifyRequest + appPathPrefix: string + code: string + codeVerifier: string + }): Promise { + const body = new URLSearchParams() + body.set('grant_type', 'authorization_code') + body.set('client_id', this.config.clientId) + body.set('code', params.code) + body.set('code_verifier', params.codeVerifier) + body.set( + 'redirect_uri', + callbackRedirectUri(params.request, params.appPathPrefix, this.config.callbackPath) + ) + + const response = await fetchWithTimeout(params.discovery.token_endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded' + }, + body + }) + if (!response.ok) { + throw new Error(`OIDC token endpoint returned HTTP ${response.status}`) + } + const json = assertObjectResponse((await response.json()) as unknown, 'OIDC token') + if (typeof json.id_token !== 'string' || typeof json.access_token !== 'string') { + throw new Error('OIDC token response must include id_token and access_token') + } + return { + id_token: json.id_token, + access_token: json.access_token, + token_type: typeof json.token_type === 'string' ? json.token_type : undefined + } + } + + private async discovery(): Promise { + if (this.discoveryCache !== null) return await this.discoveryCache + this.discoveryCache = this.fetchDiscovery().catch((error: unknown) => { + this.discoveryCache = null + throw error + }) + return await this.discoveryCache + } + + private async fetchDiscovery(): Promise { + const response = await fetchWithTimeout( + `${this.config.issuerUrl}/.well-known/openid-configuration`, + { + headers: { accept: 'application/json' } + } + ) + if (!response.ok) { + throw new Error(`OIDC discovery returned HTTP ${response.status}`) + } + const json = assertObjectResponse((await response.json()) as unknown, 'OIDC discovery') + if (json.issuer !== this.config.issuerUrl) { + throw new Error('OIDC discovery issuer does not match configured issuer') + } + for (const field of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) { + if (typeof json[field] !== 'string' || json[field] === '') { + throw new Error(`OIDC discovery ${field} is required`) + } + } + return { + issuer: json.issuer, + authorization_endpoint: json.authorization_endpoint, + token_endpoint: json.token_endpoint, + jwks_uri: json.jwks_uri + } + } + + private async jwks(discovery: OidcDiscovery): Promise { + const now = Date.now() + if (this.jwksCache !== null && this.jwksCache.expiresAt > now) return this.jwksCache.keys + const response = await fetchWithTimeout(discovery.jwks_uri, { + headers: { accept: 'application/json' } + }) + if (!response.ok) { + throw new Error(`JWKS endpoint returned HTTP ${response.status}`) + } + const json = assertObjectResponse((await response.json()) as unknown, 'JWKS') + if (!Array.isArray(json.keys)) { + throw new Error('JWKS keys array is required') + } + const keys = json.keys.filter((key): key is Jwk => typeof key === 'object' && key !== null) + this.jwksCache = { keys, expiresAt: now + JWKS_CACHE_TTL_MS } + return keys + } + + private async verifyJwtWithJwks(params: { + token: string + issuer: string + audience: string + discovery: OidcDiscovery + }): Promise { + const firstKeys = await this.jwks(params.discovery) + try { + return verifyPlatformJwt({ ...params, jwks: firstKeys }) + } catch (error) { + if (!(error instanceof Error) || !error.message.startsWith('JWKS key not found')) { + throw error + } + this.jwksCache = null + const refreshedKeys = await this.jwks(params.discovery) + return verifyPlatformJwt({ ...params, jwks: refreshedKeys }) + } + } +} diff --git a/src/web/server/routes/auth.ts b/src/web/server/routes/auth.ts index beeeaec45..278d6fa56 100644 --- a/src/web/server/routes/auth.ts +++ b/src/web/server/routes/auth.ts @@ -7,14 +7,36 @@ import { } from '../../../shared/api/schemas/auth' import { PasswordPolicyError } from '../../auth/PostgresWebAuthService' import { recordAuthAudit } from '../audit' +import { isPlatformIdentityEnabled } from '../platform-identity-config' import { requireAdmin } from './guards' import type { OverrideHandler } from './types' +function platformMutationDenied(reply: { code: (statusCode: number) => unknown }): { + success: false + error: string + message: string +} { + reply.code(403) + return { + success: false, + error: 'platform-auth-required', + message: 'Local password and user mutations are disabled while platform identity is active.' + } +} + export function buildAuthOverrides(): Record { return { 'auth:login': { public: true, async handle(args, request, reply, deps) { + if (isPlatformIdentityEnabled()) { + reply.code(403) + return { + success: false, + error: 'platform-auth-required', + message: 'Username/password login is disabled while platform identity is active.' + } + } const { authService } = deps const parsed = LoginArgsSchema.safeParse(args) if (!parsed.success) { @@ -85,6 +107,9 @@ export function buildAuthOverrides(): Record { }, 'auth:changePassword': { async handle(args, request, reply, deps) { + if (isPlatformIdentityEnabled()) { + return platformMutationDenied(reply) + } const { authService } = deps const session = request.session const sessionUser = session?.user @@ -171,6 +196,9 @@ export function buildAuthOverrides(): Record { }, 'auth:createUser': { async handle(args, request, reply) { + if (isPlatformIdentityEnabled()) { + return platformMutationDenied(reply) + } const admin = requireAdmin(request, reply) if (admin === undefined) return { error: 'admin-required' } @@ -195,6 +223,9 @@ export function buildAuthOverrides(): Record { }, 'auth:deactivateUser': { async handle(args, request, reply, deps) { + if (isPlatformIdentityEnabled()) { + return platformMutationDenied(reply) + } const { authService } = deps const admin = requireAdmin(request, reply) if (admin === undefined) return { error: 'admin-required' } @@ -222,6 +253,9 @@ export function buildAuthOverrides(): Record { }, 'auth:resetPassword': { async handle(args, request, reply, deps) { + if (isPlatformIdentityEnabled()) { + return platformMutationDenied(reply) + } const { authService } = deps const admin = requireAdmin(request, reply) if (admin === undefined) return { error: 'admin-required' } diff --git a/tests/main/storage/postgres-migration-definitions.test.ts b/tests/main/storage/postgres-migration-definitions.test.ts index 2058c43b1..24ef8eb0b 100644 --- a/tests/main/storage/postgres-migration-definitions.test.ts +++ b/tests/main/storage/postgres-migration-definitions.test.ts @@ -4,7 +4,7 @@ import { POSTGRES_MIGRATIONS } from '../../../src/main/storage/postgres/migratio describe('Postgres migration definitions', () => { it('loads the PostgreSQL migrations with SQL and sha256 checksums', () => { - expect(POSTGRES_MIGRATIONS).toHaveLength(15) + expect(POSTGRES_MIGRATIONS).toHaveLength(16) expect(POSTGRES_MIGRATIONS.map((migration) => migration.version)).toEqual([ '0001', '0002', @@ -20,7 +20,8 @@ describe('Postgres migration definitions', () => { '0012', '0013', '0014', - '0015' + '0015', + '0016' ]) expect(POSTGRES_MIGRATIONS.map((migration) => migration.name)).toEqual([ 'create_cases', @@ -37,7 +38,8 @@ describe('Postgres migration definitions', () => { 'extend_audit_contract', 'central_audit_schema', 'variant_transcripts_func', - 'import_visibility' + 'import_visibility', + 'platform_identity' ]) for (const migration of POSTGRES_MIGRATIONS) { @@ -99,5 +101,12 @@ describe('Postgres migration definitions', () => { 'vt.is_selected = 1 AND v.transcript = vt.transcript_id' ) expect(transcriptFuncMigration?.sql).toContain('FROM "__schema__"."variants" AS v') + + const platformIdentityMigration = POSTGRES_MIGRATIONS.find( + (migration) => migration.version === '0016' + ) + expect(platformIdentityMigration?.name).toBe('platform_identity') + expect(platformIdentityMigration?.sql).toContain('auth_source') + expect(platformIdentityMigration?.sql).toContain('users_single_platform_identity') }) }) diff --git a/tests/main/storage/postgres-migrations-idempotent.test.ts b/tests/main/storage/postgres-migrations-idempotent.test.ts index 0195d608d..5002d8f0a 100644 --- a/tests/main/storage/postgres-migrations-idempotent.test.ts +++ b/tests/main/storage/postgres-migrations-idempotent.test.ts @@ -120,6 +120,12 @@ describe.skipIf(!RUN)('Postgres migrations: real-instance idempotency', () => { (action_type, entity_type, entity_key, new_value, user_name) VALUES ('star', 'variant_annotation', '1:100:A:G', '{"starred":1}', 'legacy-user')` ) + await probeClient.query( + `INSERT INTO "${schema}".users + (username, display_name, password_hash, role, must_change_password) + VALUES ('legacy-platform-subject', 'Legacy platform user', + 'platform-identity-disabled-local-password', 'user', FALSE)` + ) const throughCentral = POSTGRES_MIGRATIONS.filter((m) => m.version < '0014') const centralResult = await new PostgresMigrationRunner(pool, schema, throughCentral).migrate() @@ -146,7 +152,7 @@ describe.skipIf(!RUN)('Postgres migrations: real-instance idempotency', () => { ) const result = await new PostgresMigrationRunner(pool, schema, POSTGRES_MIGRATIONS).migrate() - expect(result.applied).toEqual(['0014', '0015']) + expect(result.applied).toEqual(['0014', '0015', '0016']) const migratedTranscript = await probeClient.query<{ consequence: string; func: string }>( `SELECT consequence, func @@ -210,6 +216,21 @@ describe.skipIf(!RUN)('Postgres migrations: real-instance idempotency', () => { expectColType('created_at', 'timestamptz', 'NO', true) expectColType('created_by', 'int8', 'YES', false) expectColType('updated_at', 'timestamptz', 'YES', false) + expectColType('auth_source', 'text', 'NO', true) + + await expect( + probeClient.query( + `SELECT auth_source FROM "${schema}".users WHERE username = 'legacy-platform-subject'` + ) + ).resolves.toMatchObject({ rows: [{ auth_source: 'platform' }] }) + await expect( + probeClient.query( + `INSERT INTO "${schema}".users + (username, display_name, password_hash, role, auth_source) + VALUES ('second-platform-subject', 'Second platform user', + 'platform-identity-disabled-local-password', 'user', 'platform')` + ) + ).rejects.toThrow() // Role CHECK must enumerate exactly admin + user, the same enum as SQLite // migrations.ts v12. The shared constants module is the cross-backend diff --git a/tests/main/web/auth/postgres-platform-user-store.test.ts b/tests/main/web/auth/postgres-platform-user-store.test.ts new file mode 100644 index 000000000..4f5be6ea2 --- /dev/null +++ b/tests/main/web/auth/postgres-platform-user-store.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' + +import { ROLE_ADMIN, ROLE_USER } from '../../../../src/shared/auth/auth-constants' +import { PostgresPlatformUserStore } from '../../../../src/web/auth/PostgresPlatformUserStore' + +interface QueryResponse { + rows: Array<{ id: string; username: string; role: 'admin' | 'user' }> + rowCount: number +} + +class FakePool { + queries: Array<{ text: string; values: unknown[] }> = [] + + constructor(private readonly response: QueryResponse | Error) {} + + async query(text: string, values: unknown[]): Promise { + this.queries.push({ text, values }) + if (this.response instanceof Error) throw this.response + return this.response + } +} + +describe('PostgresPlatformUserStore', () => { + it('creates a disabled-password local binding for an OIDC subject', async () => { + const pool = new FakePool({ + rows: [{ id: '9', username: 'oidc-subject-1', role: ROLE_USER }], + rowCount: 1 + }) + const users = new PostgresPlatformUserStore(pool as never, 'instance_alice') + + await expect( + users.upsert({ subject: 'oidc-subject-1', displayName: 'Alice', role: ROLE_USER }) + ).resolves.toEqual({ id: 9, subject: 'oidc-subject-1', role: ROLE_USER }) + expect(pool.queries[0].values).toEqual([ + 'oidc-subject-1', + 'Alice', + 'platform-identity-disabled-local-password', + ROLE_USER, + 'platform-identity-disabled-local-password' + ]) + expect(pool.queries[0].text).toContain('"instance_alice"."users"') + expect(pool.queries[0].text).toContain("auth_source = 'platform'") + expect(pool.queries[0].text).not.toMatch(/private_db|workspace|secret/i) + }) + + it('refuses to overwrite a local-password user', async () => { + const users = new PostgresPlatformUserStore( + new FakePool({ rows: [], rowCount: 0 }) as never, + 'public' + ) + + await expect( + users.upsert({ subject: 'oidc-subject-1', displayName: 'Alice', role: ROLE_USER }) + ).rejects.toThrow(/cannot overwrite local user/i) + }) + + it('updates an existing OIDC binding role', async () => { + const pool = new FakePool({ + rows: [{ id: '9', username: 'oidc-subject-1', role: ROLE_ADMIN }], + rowCount: 1 + }) + const users = new PostgresPlatformUserStore(pool as never, 'public') + + await expect( + users.upsert({ subject: 'oidc-subject-1', displayName: 'Alice', role: ROLE_ADMIN }) + ).resolves.toMatchObject({ role: ROLE_ADMIN }) + expect(pool.queries[0].text).toMatch(/ON CONFLICT \(username\)/) + }) + + it('rejects a second platform subject at the database singleton boundary', async () => { + const conflict = Object.assign(new Error('duplicate key'), { + code: '23505', + constraint: 'users_single_platform_identity' + }) + const users = new PostgresPlatformUserStore(new FakePool(conflict) as never, 'public') + + await expect( + users.upsert({ subject: 'second-subject', displayName: 'Mallory', role: ROLE_USER }) + ).rejects.toThrow(/already bound to another platform subject/i) + }) +}) diff --git a/tests/main/web/auth/postgres-web-auth-service.test.ts b/tests/main/web/auth/postgres-web-auth-service.test.ts index c9cf6aee7..036b2b341 100644 --- a/tests/main/web/auth/postgres-web-auth-service.test.ts +++ b/tests/main/web/auth/postgres-web-auth-service.test.ts @@ -550,6 +550,18 @@ describe('PostgresWebAuthService — getUser / listUsers / isAccountsEnabled', ( expect(await svc.getUser('ghost')).toBeUndefined() }) + it('getPlatformUser only resolves an explicitly provisioned platform identity', async () => { + const pool = new FakePool() + const svc = newSvc(pool) + pool.enqueueResponse({ rows: [pgUserRow({ username: 'oidc-subject' })], rowCount: 1 }) + + await expect(svc.getPlatformUser('oidc-subject')).resolves.toEqual( + expect.objectContaining({ username: 'oidc-subject' }) + ) + expect(pool.queries[0].text).toContain("auth_source = 'platform'") + expect(pool.queries[0].values).toEqual(['oidc-subject']) + }) + it('listUsers strips password_hash from every row', async () => { const pool = new FakePool() const svc = newSvc(pool) diff --git a/tests/main/web/auth/provision-platform-user-postgres.test.ts b/tests/main/web/auth/provision-platform-user-postgres.test.ts new file mode 100644 index 000000000..ccde58459 --- /dev/null +++ b/tests/main/web/auth/provision-platform-user-postgres.test.ts @@ -0,0 +1,112 @@ +import { randomBytes } from 'node:crypto' +import { spawn } from 'node:child_process' +import { resolve } from 'node:path' + +import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { Client, Pool } from 'pg' + +import { POSTGRES_MIGRATIONS } from '../../../../src/main/storage/postgres/migrations/definitions' +import { PostgresMigrationRunner } from '../../../../src/main/storage/postgres/migrations/PostgresMigrationRunner' + +const RUN = process.env.VARLENS_RUN_POSTGRES_E2E === '1' +const PG_URL = + process.env.VARLENS_PG_URL ?? + 'postgres://varlens:varlens_dev_password@127.0.0.1:55432/varlens_dev' + +interface CliResult { + code: number | null + stdout: string + stderr: string +} + +function runCli(schema: string, subject: string, role = 'user'): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn( + process.execPath, + [ + resolve('out/web/provision-platform-user.cjs'), + '--subject', + subject, + '--display-name', + `Display ${subject}`, + '--role', + role + ], + { + cwd: process.cwd(), + env: { + ...process.env, + VARLENS_PG_URL: PG_URL, + VARLENS_PG_SCHEMA: schema + }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk)) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)) + child.once('error', reject) + child.once('close', (code) => resolveResult({ code, stdout, stderr })) + }) +} + +describe.skipIf(!RUN)('provision-platform-user built CLI — real PostgreSQL', () => { + const schema = `platform_cli_${Date.now()}_${randomBytes(4).toString('hex')}` + let pool: Pool + + beforeAll(async () => { + const client = new Client({ connectionString: PG_URL }) + await client.connect() + await client.query(`CREATE SCHEMA "${schema}"`) + await client.end() + pool = new Pool({ connectionString: PG_URL, max: 2 }) + await new PostgresMigrationRunner(pool, schema, POSTGRES_MIGRATIONS).migrate() + }, 60_000) + + afterAll(async () => { + if (pool) await pool.end() + const client = new Client({ connectionString: PG_URL }) + await client.connect() + await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`) + await client.end() + }, 60_000) + + test('allows one subject, updates it, and rejects a concurrent second binding', async () => { + const firstAttempts = await Promise.all([ + runCli(schema, 'platform-subject-a'), + runCli(schema, 'platform-subject-b') + ]) + const successes = firstAttempts.filter((result) => result.code === 0) + const failures = firstAttempts.filter((result) => result.code !== 0) + + expect(successes).toHaveLength(1) + expect(failures).toHaveLength(1) + expect(JSON.parse(successes[0].stdout)).toMatchObject({ ok: true, role: 'user' }) + expect(JSON.parse(failures[0].stderr)).toMatchObject({ ok: false }) + expect(failures[0].stderr).toMatch(/already bound to another platform subject/i) + + const subject = JSON.parse(successes[0].stdout).subject as string + const update = await runCli(schema, subject, 'admin') + expect(update.code).toBe(0) + expect(JSON.parse(update.stdout)).toEqual({ ok: true, subject, role: 'admin' }) + + const rows = await pool.query<{ + username: string + role: string + auth_source: string + password_hash: string + }>( + `SELECT username, role, auth_source, password_hash + FROM "${schema}".users WHERE auth_source = 'platform'` + ) + expect(rows.rows).toEqual([ + { + username: subject, + role: 'admin', + auth_source: 'platform', + password_hash: 'platform-identity-disabled-local-password' + } + ]) + }, 60_000) +}) diff --git a/tests/web-gate/dispatcher-adapters-auth-import.test.ts b/tests/web-gate/dispatcher-adapters-auth-import.test.ts index 77fa9f9a5..ec301b87f 100644 --- a/tests/web-gate/dispatcher-adapters-auth-import.test.ts +++ b/tests/web-gate/dispatcher-adapters-auth-import.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { jobRunner } from '../../src/main/services/jobs/runner' import { ErrorCode } from '../../src/shared/types/errors' @@ -17,6 +17,10 @@ const ZIP_WITH_ONE_JSON_BASE64 = 'IqT01SquUCAFBLAQIUAxQAAAgIAJRRwVwz5c4EEQAAAA8AAAARAAAAAAAAAAAAAACkgQAAAAB3' + 'ZWItemlwLWNhc2UuanNvblBLBQYAAAAAAQABAD8AAABAAAAAAAA=' +afterEach(() => { + vi.unstubAllEnvs() +}) + describe('web dispatcher adapters: auth and import', () => { test('auth.isAccountsEnabled delegates to the web auth service', async () => { const { deps, reply } = makeDeps() @@ -230,6 +234,34 @@ describe('web dispatcher adapters: auth and import', () => { expect(JSON.stringify(writeExecute.mock.calls)).not.toContain('bad-old') }) + test('platform identity disables local password and user mutation handlers', async () => { + vi.stubEnv('VARLENS_AUTH_MODE', 'platform') + const { deps, reply } = makeDeps() + deps.authService.changePassword = vi.fn() + deps.authService.resetPassword = vi.fn() + deps.authService.deactivateUser = vi.fn() + const { overrides } = buildDispatcher(deps) + const request = { + session: { + user: { id: 1, username: 'admin', role: 'admin', passwordChangedAt: null } + } + } + + for (const action of [ + 'auth:changePassword', + 'auth:createUser', + 'auth:deactivateUser', + 'auth:resetPassword' + ]) { + const result = await overrides[action].handle([], request as never, reply as never, deps) + expect(result).toMatchObject({ success: false, error: 'platform-auth-required' }) + } + + expect(deps.authService.changePassword).not.toHaveBeenCalled() + expect(deps.authService.resetPassword).not.toHaveBeenCalled() + expect(deps.authService.deactivateUser).not.toHaveBeenCalled() + }) + test('auth.resetPassword and auth.deactivateUser record admin actions without new passwords', async () => { const { deps, reply, writeExecute } = makeDeps() const { overrides } = buildDispatcher(deps) diff --git a/tests/web-gate/integration/platform-identity-mode.test.ts b/tests/web-gate/integration/platform-identity-mode.test.ts new file mode 100644 index 000000000..01b4fd6d6 --- /dev/null +++ b/tests/web-gate/integration/platform-identity-mode.test.ts @@ -0,0 +1,60 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +import { describe, expect, test } from 'vitest' +import type { FastifyInstance } from 'fastify' + +import { SAME_ORIGIN_HEADERS, startIsolatedWebSchema } from '../helpers/web-driver' + +const isWebBuilt = existsSync(resolve(process.cwd(), 'out/web/server.cjs')) +const hasPostgres = + typeof process.env.VARLENS_PG_URL === 'string' && process.env.VARLENS_PG_URL !== '' + +const PLATFORM_ENV = { + APP_PATH_PREFIX: '/', + VARLENS_AUTH_MODE: 'platform', + VARLENS_PLATFORM_ISSUER_URL: 'https://identity.example.test/realms/varlens', + VARLENS_PLATFORM_CLIENT_ID: 'varlens-test', + VARLENS_PLATFORM_AUDIENCE: 'varlens-test', + VARLENS_PLATFORM_REQUIRED_ACR: 'urn:example:acr:password-plus-totp', + VARLENS_PLATFORM_REQUIRED_AMR: 'pwd,otp', + VARLENS_PLATFORM_ENTITLEMENTS_URL: 'https://platform.example.test/entitlements' +} as const + +describe.skipIf(!isWebBuilt || !hasPostgres)('platform identity mode integration', () => { + test('redirects web sign-in to OIDC and disables the local login endpoint', async () => { + const isolated = await startIsolatedWebSchema('platform_identity_mode') + const previous = Object.fromEntries( + Object.keys(PLATFORM_ENV).map((name) => [name, process.env[name]]) + ) + Object.assign(process.env, PLATFORM_ENV) + + let app: FastifyInstance | undefined + try { + const { buildApp } = await import('../../../src/web/server') + app = await buildApp() + + const login = await app.inject({ method: 'GET', url: '/login?next=/cases' }) + expect(login.statusCode).toBe(302) + expect(login.headers.location).toBe('/auth/platform/start?next=%2Fcases') + + const localLogin = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: SAME_ORIGIN_HEADERS, + payload: { args: ['local-user', 'local-password'] } + }) + expect(localLogin.statusCode).toBe(403) + expect(localLogin.json()).toMatchObject({ + details: { error: 'platform-auth-required' } + }) + } finally { + await app?.close() + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + await isolated.close() + } + }) +}) diff --git a/tests/web-gate/integration/request-id.test.ts b/tests/web-gate/integration/request-id.test.ts index c4d3ae43d..90899c3a9 100644 --- a/tests/web-gate/integration/request-id.test.ts +++ b/tests/web-gate/integration/request-id.test.ts @@ -23,6 +23,18 @@ describe.skipIf(!isWebBuilt || !hasPostgres)('web request IDs', () => { expect(firstId).not.toBe(spoofed) expect(firstId).toMatch(UUID_RE) + // `request-id` is the header Fastify actually honours when + // `requestIdHeader` is truthy — `x-request-id` is never consulted, so + // spoofing only that one would pass even with header trust switched on. + // This case is what pins `requestIdHeader: false` in src/web/server.ts. + const viaFastifyHeader = await app.inject({ + method: 'GET', + url: '/healthz', + headers: { 'request-id': spoofed } + }) + expect(viaFastifyHeader.headers['x-request-id']).not.toBe(spoofed) + expect(viaFastifyHeader.headers['x-request-id']).toMatch(UUID_RE) + const second = await app.inject({ method: 'GET', url: '/healthz' }) expect(second.headers['x-request-id']).toMatch(UUID_RE) expect(second.headers['x-request-id']).not.toBe(firstId) diff --git a/tests/web-gate/page-gate.test.ts b/tests/web-gate/page-gate.test.ts index 2a66b35cb..a413a18c4 100644 --- a/tests/web-gate/page-gate.test.ts +++ b/tests/web-gate/page-gate.test.ts @@ -22,4 +22,102 @@ describe('web page gate', () => { await app.close() } }) + + test('does not expose platform auth paths in local mode', async () => { + const app = fastify() + try { + registerPageGate(app, { appPathPrefix: '/varlens' }) + app.setNotFoundHandler(async (_request, reply) => { + reply.type('text/html') + return 'SPA shell' + }) + + for (const url of ['/auth/platform/start', '/auth/platform/callback']) { + const response = await app.inject({ method: 'GET', url }) + expect(response.statusCode, url).toBe(302) + expect(response.headers.location, url).toMatch(/^\/varlens\/login\?next=/) + expect(response.body, url).not.toContain('SPA shell') + } + } finally { + await app.close() + } + }) + + test('redirects anonymous shell requests to platform auth start when configured', async () => { + const app = fastify() + try { + registerPageGate(app, { appPathPrefix: '/varlens', loginPath: '/auth/platform/start' }) + app.setNotFoundHandler(async (_request, reply) => { + reply.type('text/html') + return 'SPA shell' + }) + + const response = await app.inject({ method: 'GET', url: '/cases?case=1' }) + + expect(response.statusCode, response.body).toBe(302) + expect(response.headers.location).toBe( + '/varlens/auth/platform/start?next=%2Fvarlens%2Fcases%3Fcase%3D1' + ) + } finally { + await app.close() + } + }) + + test('allows the configured platform callback path through anonymously', async () => { + const app = fastify() + try { + registerPageGate(app, { + appPathPrefix: '/varlens', + loginPath: '/auth/platform/start', + platformCallbackPath: '/oidc/callback' + }) + app.get('/oidc/callback', async () => ({ callback: true })) + + const response = await app.inject({ method: 'GET', url: '/oidc/callback?code=1' }) + + expect(response.statusCode, response.body).toBe(200) + expect(response.json()).toEqual({ callback: true }) + } finally { + await app.close() + } + }) + + test('redirects stale local sessions when platform auth is required', async () => { + const app = fastify() + const deleted = { value: false } + try { + app.addHook('preHandler', async (request) => { + ;(request as typeof request & { session: unknown }).session = { + user: { + id: 1, + username: 'alice', + role: 'user', + passwordChangedAt: '2026-01-01T00:00:00.000Z' + }, + authMode: 'local', + delete: () => { + deleted.value = true + } + } + }) + registerPageGate(app, { + appPathPrefix: '/varlens', + loginPath: '/auth/platform/start', + requirePlatformAuth: true + }) + app.setNotFoundHandler(async (_request, reply) => { + reply.type('text/html') + return 'SPA shell' + }) + + const response = await app.inject({ method: 'GET', url: '/cases' }) + + expect(response.statusCode, response.body).toBe(302) + expect(response.headers.location).toBe('/varlens/auth/platform/start?next=%2Fvarlens%2Fcases') + expect(response.body).not.toContain('SPA shell') + expect(deleted.value).toBe(true) + } finally { + await app.close() + } + }) }) diff --git a/tests/web-gate/platform-identity-config.test.ts b/tests/web-gate/platform-identity-config.test.ts new file mode 100644 index 000000000..67b6a7b11 --- /dev/null +++ b/tests/web-gate/platform-identity-config.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vitest' + +import { + isPlatformIdentityEnabled, + readPlatformIdentityConfig +} from '../../src/web/server/platform-identity-config' + +function baseEnv(): NodeJS.ProcessEnv { + return { + VARLENS_AUTH_MODE: 'platform', + VARLENS_PLATFORM_ISSUER_URL: 'https://identity.example.test/realms/varlens-platform', + VARLENS_PLATFORM_CLIENT_ID: 'varlens-dev', + VARLENS_PLATFORM_AUDIENCE: 'varlens-platform:app:varlens:dev', + VARLENS_PLATFORM_CALLBACK_PATH: '/auth/platform/callback', + VARLENS_PLATFORM_REQUIRED_ACR: 'urn:varlens-platform:acr:password-plus-totp', + VARLENS_PLATFORM_REQUIRED_AMR: 'pwd,otp', + VARLENS_PLATFORM_ENTITLEMENTS_URL: + 'http://varlens-platform-operations.varlens-platform-operations-dev.svc.cluster.local/api/identity/entitlements/varlens/dev', + VARLENS_PLATFORM_ENTITLEMENTS_TOKEN: `opaque-${'x'.repeat(40)}` + } +} + +describe('platform identity config', () => { + test('defaults to local auth when VARLENS_AUTH_MODE is unset', () => { + expect(readPlatformIdentityConfig({})).toBeNull() + expect(isPlatformIdentityEnabled({})).toBe(false) + }) + + test('loads required platform auth values', () => { + const config = readPlatformIdentityConfig(baseEnv()) + + expect(config).toMatchObject({ + mode: 'platform', + issuerUrl: 'https://identity.example.test/realms/varlens-platform', + clientId: 'varlens-dev', + audience: 'varlens-platform:app:varlens:dev', + callbackPath: '/auth/platform/callback', + requiredAcr: 'urn:varlens-platform:acr:password-plus-totp', + requiredAmr: ['pwd', 'otp'], + entitlementsToken: `opaque-${'x'.repeat(40)}`, + verifyAccessToken: false + }) + }) + + test('fails loud when required platform values are missing', () => { + expect(() => readPlatformIdentityConfig({ VARLENS_AUTH_MODE: 'platform' })).toThrow( + /VARLENS_PLATFORM_ISSUER_URL/ + ) + }) + + test('requires a safe callback path', () => { + expect(() => + readPlatformIdentityConfig({ + ...baseEnv(), + VARLENS_PLATFORM_CALLBACK_PATH: 'https://evil.example/callback' + }) + ).toThrow(/CALLBACK_PATH/) + }) + + test('rejects a weak entitlements bearer token', () => { + expect(() => + readPlatformIdentityConfig({ + ...baseEnv(), + VARLENS_PLATFORM_ENTITLEMENTS_TOKEN: 'short-token' + }) + ).toThrow(/at least 32 characters/i) + }) + + test('enables access-token JWT verification only when explicitly requested', () => { + expect( + readPlatformIdentityConfig({ + ...baseEnv(), + VARLENS_PLATFORM_VERIFY_ACCESS_TOKEN: 'true' + }) + ).toMatchObject({ verifyAccessToken: true }) + }) +}) diff --git a/tests/web-gate/platform-identity-runtime-boundary.test.ts b/tests/web-gate/platform-identity-runtime-boundary.test.ts new file mode 100644 index 000000000..a2bdf0d98 --- /dev/null +++ b/tests/web-gate/platform-identity-runtime-boundary.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest' +import fastify from 'fastify' + +import { PlatformIdentityService } from '../../src/web/server/platform-identity' +import { registerPlatformIdentityRoutes } from '../../src/web/server/platform-identity-routes' + +describe('platform identity runtime boundary', () => { + test('does not expose an infrastructure or user-provisioning HTTP endpoint', async () => { + const app = fastify() + const identity = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: 'https://identity.example.test/realms/varlens-platform', + clientId: 'varlens-dev', + audience: 'varlens-platform:app:varlens:dev', + callbackPath: '/auth/platform/callback', + requiredAcr: 'urn:varlens-platform:acr:password-plus-totp', + requiredAmr: ['pwd', 'otp'], + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + registerPlatformIdentityRoutes(app, { + identity, + authService: {} as never, + appPathPrefix: '' + }) + + const response = await app.inject({ + method: 'POST', + url: '/platform/provisioning/users', + payload: { subject: 'oidc-subject-1' } + }) + + expect(response.statusCode).toBe(404) + await app.close() + }) +}) diff --git a/tests/web-gate/platform-identity.test.ts b/tests/web-gate/platform-identity.test.ts new file mode 100644 index 000000000..e344f1d62 --- /dev/null +++ b/tests/web-gate/platform-identity.test.ts @@ -0,0 +1,902 @@ +import { createSign, generateKeyPairSync } from 'node:crypto' + +import { afterEach, describe, expect, test, vi } from 'vitest' +import fastify from 'fastify' +import type { InjectResult } from 'light-my-request' + +import { + assertPlatformMfaClaims, + PlatformMfaClaimError, + PlatformIdentityService, + verifyPlatformJwt +} from '../../src/web/server/platform-identity' +import { registerPlatformIdentityRoutes } from '../../src/web/server/platform-identity-routes' +import { registerSessions } from '../../src/web/server/auth' +import { registerWebRateLimit } from '../../src/web/server/rate-limit' + +const ISSUER = 'https://identity.example.test/realms/varlens-platform' +const CLIENT_ID = 'varlens-dev' +const AUDIENCE = 'varlens-platform:app:varlens:dev' +const REQUIRED_ACR = 'urn:varlens-platform:acr:password-plus-totp' +const REQUIRED_AMR = ['pwd', 'otp'] + +const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const publicJwk = { + ...keyPair.publicKey.export({ format: 'jwk' }), + kid: 'active-key', + alg: 'RS256', + use: 'sig' +} +const rotatedKeyPair = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const rotatedPublicJwk = { + ...rotatedKeyPair.publicKey.export({ format: 'jwk' }), + kid: 'rotated-key', + alg: 'RS256', + use: 'sig' +} + +afterEach(() => { + vi.unstubAllGlobals() + delete process.env.VARLENS_SESSION_SECRET_HEX + delete process.env.NODE_ENV +}) + +function encodeJson(value: Record): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +function signJwt(payload: Record, header: Record = {}): string { + const encodedHeader = encodeJson({ alg: 'RS256', kid: 'active-key', typ: 'JWT', ...header }) + const encodedPayload = encodeJson(payload) + const signer = createSign('RSA-SHA256') + signer.update(`${encodedHeader}.${encodedPayload}`) + signer.end() + const signature = signer.sign(keyPair.privateKey).toString('base64url') + return `${encodedHeader}.${encodedPayload}.${signature}` +} + +function signRotatedJwt(payload: Record): string { + const encodedHeader = encodeJson({ alg: 'RS256', kid: 'rotated-key', typ: 'JWT' }) + const encodedPayload = encodeJson(payload) + const signer = createSign('RSA-SHA256') + signer.update(`${encodedHeader}.${encodedPayload}`) + signer.end() + const signature = signer.sign(rotatedKeyPair.privateKey).toString('base64url') + return `${encodedHeader}.${encodedPayload}.${signature}` +} + +function basePayload(audience: string | string[] = AUDIENCE): Record { + return { + iss: ISSUER, + sub: 'platform-subject-1', + aud: audience, + exp: 2_000_000_000, + iat: 1_900_000_000, + auth_time: 1_949_999_940, + nonce: 'nonce-1', + acr: REQUIRED_ACR, + amr: REQUIRED_AMR + } +} + +function extractCookie(res: InjectResult): string { + const setCookie = res.headers['set-cookie'] + const values = Array.isArray(setCookie) ? setCookie : setCookie !== undefined ? [setCookie] : [] + return values.map((cookie) => String(cookie).split(';', 1)[0]).join('; ') +} + +describe('platform identity JWT validation', () => { + test('accepts an RS256 token with matching issuer, kid, audience and MFA claims', () => { + const token = signJwt(basePayload(CLIENT_ID)) + const verified = verifyPlatformJwt({ + token, + issuer: ISSUER, + audience: CLIENT_ID, + jwks: [publicJwk], + nowSeconds: 1_950_000_000 + }) + + expect(verified.payload.sub).toBe('platform-subject-1') + assertPlatformMfaClaims({ + payload: verified.payload, + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + expectedNonce: 'nonce-1', + nowSeconds: 1_950_000_000 + }) + }) + + test('rejects wrong environment audience', () => { + const token = signJwt(basePayload('varlens-platform:app:varlens:test')) + + expect(() => + verifyPlatformJwt({ + token, + issuer: ISSUER, + audience: AUDIENCE, + jwks: [publicJwk], + nowSeconds: 1_950_000_000 + }) + ).toThrow(/audience/) + }) + + test('rejects missing TOTP MFA assertion', () => { + const token = signJwt({ ...basePayload(CLIENT_ID), amr: ['pwd'] }) + const verified = verifyPlatformJwt({ + token, + issuer: ISSUER, + audience: CLIENT_ID, + jwks: [publicJwk], + nowSeconds: 1_950_000_000 + }) + + expect(() => + assertPlatformMfaClaims({ + payload: verified.payload, + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + expectedNonce: 'nonce-1', + nowSeconds: 1_950_000_000 + }) + ).toThrow(/otp/) + }) + + test('requires iat and matching azp for a multi-audience token', () => { + expect(() => + verifyPlatformJwt({ + token: signJwt({ ...basePayload(CLIENT_ID), iat: undefined }), + issuer: ISSUER, + audience: CLIENT_ID, + jwks: [publicJwk], + nowSeconds: 1_950_000_000 + }) + ).toThrow(/iat claim is required/) + + expect(() => + verifyPlatformJwt({ + token: signJwt({ ...basePayload([CLIENT_ID, 'other']), azp: 'other' }), + issuer: ISSUER, + audience: CLIENT_ID, + jwks: [publicJwk], + nowSeconds: 1_950_000_000 + }) + ).toThrow(/azp/) + }) + + test('requires a fresh server-verifiable authentication time', () => { + expect(() => + assertPlatformMfaClaims({ + payload: { ...basePayload(CLIENT_ID), auth_time: 1_949_000_000 }, + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + expectedNonce: 'nonce-1', + nowSeconds: 1_950_000_000 + }) + ).toThrow(/not fresh/) + }) + + test('rejects tokens without the active JWKS kid', () => { + const token = signJwt(basePayload(CLIENT_ID), { kid: 'rotated-away' }) + + expect(() => + verifyPlatformJwt({ + token, + issuer: ISSUER, + audience: CLIENT_ID, + jwks: [publicJwk], + nowSeconds: 1_950_000_000 + }) + ).toThrow(/kid/) + }) +}) + +describe('platform identity entitlement validation', () => { + test('accepts the varlens-platform-operations wrapped entitlement decision contract', async () => { + const fetchMock = vi.fn(async (url: string, init?: { headers?: Record }) => { + expect(url).toBe( + 'http://ops.internal/api/identity/entitlements/varlens/dev/platform-subject-1' + ) + expect(init?.headers?.authorization).toBe('Bearer introspection-token') + return new Response( + JSON.stringify({ + entitlement: { + active: true, + subject: 'platform-subject-1', + app: 'varlens', + environment: 'dev', + role: 'admin', + status: 'active' + } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + }) + vi.stubGlobal('fetch', fetchMock) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + entitlementsToken: 'introspection-token', + verifyAccessToken: false + }) + + const result = await service.resolveSessionUser( + { + getPlatformUser: vi.fn(async () => ({ + id: 42, + username: 'platform-subject-1', + role: 'user', + is_active: 1, + password_changed_at: null + })) + } as never, + 'platform-subject-1' + ) + + expect(result).toEqual({ + id: 42, + username: 'platform-subject-1', + role: 'admin', + passwordChangedAt: null + }) + }) + + test('caches active entitlement decisions for the short revalidation window', async () => { + const fetchMock = vi.fn(async () => { + return new Response( + JSON.stringify({ + entitlement: { + active: true, + role: 'user', + status: 'active' + } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + }) + vi.stubGlobal('fetch', fetchMock) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + const authService = { + getPlatformUser: vi.fn(async () => ({ + id: 42, + username: 'platform-subject-1', + role: 'user', + is_active: 1, + password_changed_at: null + })) + } as never + + await service.resolveSessionUser(authService, 'platform-subject-1') + await service.resolveSessionUser(authService, 'platform-subject-1') + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test('denies an entitled subject that is not bound in this VarLens instance', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ entitlement: { active: true, role: 'user', status: 'active' } }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ) + ) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + + await expect( + service.resolveSessionUser( + { getPlatformUser: vi.fn(async () => undefined) } as never, + 'other-user' + ) + ).rejects.toThrow(/not provisioned or active/i) + }) + + test('denies a local-password row even when its username equals the OIDC subject', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ entitlement: { active: true, role: 'user', status: 'active' } }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ) + ) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + const getPlatformUser = vi.fn(async () => undefined) + + await expect( + service.resolveSessionUser({ getPlatformUser } as never, 'platform-subject-1') + ).rejects.toThrow(/not provisioned or active/i) + expect(getPlatformUser).toHaveBeenCalledWith('platform-subject-1') + }) +}) + +describe('platform identity OIDC start', () => { + test('requests the configured ACR through acr_values', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + return new Response( + JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/protocol/openid-connect/auth`, + token_endpoint: `${ISSUER}/protocol/openid-connect/token`, + jwks_uri: `${ISSUER}/protocol/openid-connect/certs` + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + }) + ) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + + const result = await service.createAuthorizationUrl({ + request: { + protocol: 'https', + headers: { host: 'varlens-dev.example.test' } + } as never, + appPathPrefix: '', + next: '/' + }) + + expect(new URL(result.authorizationUrl).searchParams.get('acr_values')).toBe(REQUIRED_ACR) + expect(new URL(result.authorizationUrl).searchParams.get('prompt')).toBe('login') + expect(new URL(result.authorizationUrl).searchParams.get('max_age')).toBe('0') + }) + + test('does not force a fresh Keycloak login for the internal MFA retry', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + return new Response( + JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/protocol/openid-connect/auth`, + token_endpoint: `${ISSUER}/protocol/openid-connect/token`, + jwks_uri: `${ISSUER}/protocol/openid-connect/certs` + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + }) + ) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + + const result = await service.createAuthorizationUrl({ + request: { + protocol: 'https', + headers: { host: 'varlens-dev.example.test' } + } as never, + appPathPrefix: '', + next: '/', + forceFreshLogin: false + }) + const params = new URL(result.authorizationUrl).searchParams + + expect(params.get('acr_values')).toBe(REQUIRED_ACR) + expect(params.get('prompt')).toBeNull() + expect(params.get('max_age')).toBeNull() + }) + + test('does not cache a transient discovery failure forever', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('temporary discovery outage')) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/protocol/openid-connect/auth`, + token_endpoint: `${ISSUER}/protocol/openid-connect/token`, + jwks_uri: `${ISSUER}/protocol/openid-connect/certs` + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + const request = { + protocol: 'https', + headers: { host: 'varlens-dev.example.test' } + } as never + + await expect( + service.createAuthorizationUrl({ request, appPathPrefix: '', next: '/' }) + ).rejects.toThrow(/temporary discovery outage/) + const result = await service.createAuthorizationUrl({ request, appPathPrefix: '', next: '/' }) + + expect(result.authorizationUrl.startsWith(ISSUER)).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + test('refreshes JWKS once when the token kid is not in the warm cache', async () => { + const nowSeconds = Math.floor(Date.now() / 1000) + const token = signRotatedJwt({ + ...basePayload(CLIENT_ID), + exp: nowSeconds + 600, + iat: nowSeconds - 60, + auth_time: nowSeconds - 60 + }) + const accessToken = signRotatedJwt({ + ...basePayload(AUDIENCE), + nonce: undefined, + acr: undefined, + amr: undefined, + exp: nowSeconds + 600, + iat: nowSeconds - 60 + }) + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/.well-known/openid-configuration')) { + return new Response( + JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/protocol/openid-connect/auth`, + token_endpoint: `${ISSUER}/protocol/openid-connect/token`, + jwks_uri: `${ISSUER}/protocol/openid-connect/certs` + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + if (url.endsWith('/token')) { + return new Response(JSON.stringify({ id_token: token, access_token: accessToken }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + } + if (url.endsWith('/certs')) { + const keys = + fetchMock.mock.calls.filter(([calledUrl]) => String(calledUrl).endsWith('/certs')) + .length === 1 + ? [publicJwk] + : [rotatedPublicJwk] + return new Response(JSON.stringify({ keys }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + } + throw new Error(`unexpected URL ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + + const result = await service.completeCallback({ + request: { + protocol: 'https', + headers: { host: 'varlens-dev.example.test' } + } as never, + appPathPrefix: '', + code: 'code-1', + expectedNonce: 'nonce-1', + codeVerifier: 'verifier-1' + }) + + expect(result.subject).toBe('platform-subject-1') + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/certs'))).toHaveLength(2) + }) +}) + +describe('platform identity opaque access tokens', () => { + test('accepts an opaque access token when only the ID token is configured for JWT verification', async () => { + const nowSeconds = Math.floor(Date.now() / 1000) + const idToken = signJwt({ + ...basePayload(CLIENT_ID), + exp: nowSeconds + 600, + iat: nowSeconds - 60, + auth_time: nowSeconds - 60 + }) + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/.well-known/openid-configuration')) { + return new Response( + JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/protocol/openid-connect/auth`, + token_endpoint: `${ISSUER}/protocol/openid-connect/token`, + jwks_uri: `${ISSUER}/protocol/openid-connect/certs` + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + if (url.endsWith('/token')) { + return new Response( + JSON.stringify({ id_token: idToken, access_token: 'opaque-reference-token' }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + if (url.endsWith('/certs')) { + return new Response(JSON.stringify({ keys: [publicJwk] }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + } + throw new Error(`unexpected URL ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + const service = new PlatformIdentityService({ + mode: 'platform', + issuerUrl: ISSUER, + clientId: CLIENT_ID, + audience: AUDIENCE, + callbackPath: '/auth/platform/callback', + requiredAcr: REQUIRED_ACR, + requiredAmr: REQUIRED_AMR, + entitlementsUrl: 'http://ops.internal/api/identity/entitlements/varlens/dev', + verifyAccessToken: false + }) + + await expect( + service.completeCallback({ + request: { + protocol: 'https', + headers: { host: 'varlens-dev.example.test' } + } as never, + appPathPrefix: '', + code: 'code-1', + expectedNonce: 'nonce-1', + codeVerifier: 'verifier-1' + }) + ).resolves.toEqual({ subject: 'platform-subject-1' }) + }) +}) + +describe('platform identity callback session state', () => { + test('keeps older pending authorization states when a second start happens before callback', async () => { + process.env.NODE_ENV = 'test' + process.env.VARLENS_SESSION_SECRET_HEX = '11'.repeat(32) + + const app = fastify() + const completeCallback = vi.fn(async () => ({ subject: 'platform-subject-1' })) + const resolveSessionUser = vi.fn(async () => ({ + id: 42, + username: 'platform-subject-1', + role: 'user' as const, + passwordChangedAt: null + })) + const identity = { + config: { callbackPath: '/auth/platform/callback' }, + createAuthorizationUrl: vi + .fn() + .mockResolvedValueOnce({ + authorizationUrl: 'https://identity.example.test/auth?state=state-1', + state: 'state-1', + nonce: 'nonce-1', + codeVerifier: 'verifier-1' + }) + .mockResolvedValueOnce({ + authorizationUrl: 'https://identity.example.test/auth?state=state-2', + state: 'state-2', + nonce: 'nonce-2', + codeVerifier: 'verifier-2' + }), + completeCallback, + resolveSessionUser + } as unknown as PlatformIdentityService + + await registerWebRateLimit(app) + await registerSessions(app, { + authService: { getUser: vi.fn() } as never, + platformIdentity: identity + }) + registerPlatformIdentityRoutes(app, { + identity, + authService: {} as never, + appPathPrefix: '' + }) + + const firstStart = await app.inject({ method: 'GET', url: '/auth/platform/start?next=%2F' }) + const firstCookie = extractCookie(firstStart) + const secondStart = await app.inject({ + method: 'GET', + url: '/auth/platform/start?next=%2F', + headers: { cookie: firstCookie } + }) + const secondCookie = extractCookie(secondStart) + + const firstCallback = await app.inject({ + method: 'GET', + url: '/auth/platform/callback?state=state-1&code=code-1', + headers: { cookie: secondCookie } + }) + + expect(firstCallback.statusCode).toBe(302) + expect(firstCallback.headers.location).toBe('/') + expect(completeCallback).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'code-1', + expectedNonce: 'nonce-1', + codeVerifier: 'verifier-1' + }) + ) + expect(resolveSessionUser).toHaveBeenCalledWith(expect.anything(), 'platform-subject-1') + await app.close() + }) + + test('retries the identity flow once when TOTP enrollment lacks the OTP amr claim', async () => { + process.env.NODE_ENV = 'test' + process.env.VARLENS_SESSION_SECRET_HEX = '11'.repeat(32) + + const app = fastify() + const completeCallback = vi.fn(async () => { + throw new PlatformMfaClaimError('required MFA amr is missing: otp', 'amr', 'otp') + }) + const identity = { + config: { callbackPath: '/auth/platform/callback' }, + buildStartLocation: (appPathPrefix: string, next: string) => + `${appPathPrefix}/auth/platform/start?next=${encodeURIComponent(next)}`, + createAuthorizationUrl: vi + .fn() + .mockResolvedValueOnce({ + authorizationUrl: 'https://identity.example.test/auth?state=state-1', + state: 'state-1', + nonce: 'nonce-1', + codeVerifier: 'verifier-1' + }) + .mockResolvedValueOnce({ + authorizationUrl: 'https://identity.example.test/auth?state=state-2', + state: 'state-2', + nonce: 'nonce-2', + codeVerifier: 'verifier-2' + }), + completeCallback, + resolveSessionUser: vi.fn() + } as unknown as PlatformIdentityService + + await registerWebRateLimit(app) + await registerSessions(app, { + authService: { getUser: vi.fn() } as never, + platformIdentity: identity + }) + registerPlatformIdentityRoutes(app, { + identity, + authService: {} as never, + appPathPrefix: '' + }) + + const start = await app.inject({ method: 'GET', url: '/auth/platform/start?next=%2Fcases' }) + const cookie = extractCookie(start) + const callback = await app.inject({ + method: 'GET', + url: '/auth/platform/callback?state=state-1&code=code-1', + headers: { cookie } + }) + + expect(callback.statusCode).toBe(302) + expect(callback.headers.location).toBe('https://identity.example.test/auth?state=state-2') + const retryCookie = extractCookie(callback) + const rejectedRetry = await app.inject({ + method: 'GET', + url: '/auth/platform/callback?state=state-2&code=code-2', + headers: { cookie: retryCookie } + }) + expect(rejectedRetry.statusCode).toBe(401) + expect(rejectedRetry.body).toContain('Sign-in could not be completed') + await app.close() + }) + + test('does not let a client query parameter disable fresh authentication', async () => { + process.env.NODE_ENV = 'test' + process.env.VARLENS_SESSION_SECRET_HEX = '11'.repeat(32) + + const app = fastify() + const createAuthorizationUrl = vi.fn().mockResolvedValue({ + authorizationUrl: 'https://identity.example.test/auth?state=state-1', + state: 'state-1', + nonce: 'nonce-1', + codeVerifier: 'verifier-1' + }) + const identity = { + config: { callbackPath: '/auth/platform/callback' }, + buildStartLocation: (appPathPrefix: string, next: string) => + `${appPathPrefix}/auth/platform/start?next=${encodeURIComponent(next)}`, + createAuthorizationUrl, + completeCallback: vi.fn(async () => { + throw new PlatformMfaClaimError('required MFA amr is missing: otp', 'amr', 'otp') + }), + resolveSessionUser: vi.fn() + } as unknown as PlatformIdentityService + + await registerWebRateLimit(app) + await registerSessions(app, { + authService: { getUser: vi.fn() } as never, + platformIdentity: identity + }) + registerPlatformIdentityRoutes(app, { + identity, + authService: {} as never, + appPathPrefix: '' + }) + + const start = await app.inject({ + method: 'GET', + url: '/auth/platform/start?next=%2Fcases&mfaRetry=1' + }) + const cookie = extractCookie(start) + const callback = await app.inject({ + method: 'GET', + url: '/auth/platform/callback?state=state-1&code=code-1', + headers: { cookie } + }) + + expect(createAuthorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ forceFreshLogin: true }) + ) + expect(createAuthorizationUrl).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ forceFreshLogin: false }) + ) + expect(callback.statusCode).toBe(302) + await app.close() + }) + + test('restarts login cleanly when a stale callback has no pending state', async () => { + process.env.NODE_ENV = 'test' + process.env.VARLENS_SESSION_SECRET_HEX = '11'.repeat(32) + + const app = fastify() + const identity = { + config: { callbackPath: '/auth/platform/callback' }, + buildStartLocation: (appPathPrefix: string, next: string) => + next === '' + ? `${appPathPrefix}/auth/platform/start` + : `${appPathPrefix}/auth/platform/start?next=${encodeURIComponent(next)}`, + createAuthorizationUrl: vi.fn(), + completeCallback: vi.fn(), + resolveSessionUser: vi.fn() + } as unknown as PlatformIdentityService + + await registerWebRateLimit(app) + await registerSessions(app, { + authService: { getUser: vi.fn() } as never, + platformIdentity: identity + }) + registerPlatformIdentityRoutes(app, { + identity, + authService: {} as never, + appPathPrefix: '' + }) + + const callback = await app.inject({ + method: 'GET', + url: '/auth/platform/callback?state=stale-state&code=code-1' + }) + + expect(callback.statusCode).toBe(302) + expect(callback.headers.location).toBe('/auth/platform/start') + expect(callback.body).toBe('') + await app.close() + }) + + test('returns home when an already logged-in user revisits an old callback URL', async () => { + process.env.NODE_ENV = 'test' + process.env.VARLENS_SESSION_SECRET_HEX = '11'.repeat(32) + + const app = fastify() + const completeCallback = vi.fn(async () => ({ subject: 'platform-subject-1' })) + const identity = { + config: { callbackPath: '/auth/platform/callback' }, + buildStartLocation: (appPathPrefix: string, next: string) => + next === '' + ? `${appPathPrefix}/auth/platform/start` + : `${appPathPrefix}/auth/platform/start?next=${encodeURIComponent(next)}`, + createAuthorizationUrl: vi.fn().mockResolvedValue({ + authorizationUrl: 'https://identity.example.test/auth?state=state-1', + state: 'state-1', + nonce: 'nonce-1', + codeVerifier: 'verifier-1' + }), + completeCallback, + resolveSessionUser: vi.fn(async () => ({ + id: 42, + username: 'platform-subject-1', + role: 'user' as const, + passwordChangedAt: null + })) + } as unknown as PlatformIdentityService + + await registerWebRateLimit(app) + await registerSessions(app, { + authService: { getUser: vi.fn() } as never, + platformIdentity: identity + }) + registerPlatformIdentityRoutes(app, { + identity, + authService: {} as never, + appPathPrefix: '' + }) + + const start = await app.inject({ method: 'GET', url: '/auth/platform/start?next=%2F' }) + const startCookie = extractCookie(start) + const success = await app.inject({ + method: 'GET', + url: '/auth/platform/callback?state=state-1&code=code-1', + headers: { cookie: startCookie } + }) + const authenticatedCookie = extractCookie(success) + const staleCallback = await app.inject({ + method: 'GET', + url: '/auth/platform/callback?state=state-1&code=code-1', + headers: { cookie: authenticatedCookie } + }) + + expect(success.statusCode).toBe(302) + expect(staleCallback.statusCode).toBe(302) + expect(staleCallback.headers.location).toBe('/') + expect(completeCallback).toHaveBeenCalledTimes(1) + await app.close() + }) +}) diff --git a/tests/web-gate/provision-platform-user.test.ts b/tests/web-gate/provision-platform-user.test.ts new file mode 100644 index 000000000..f4cc9b05d --- /dev/null +++ b/tests/web-gate/provision-platform-user.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest' + +import { parseOptions } from '../../src/web/provision-platform-user' + +describe('provision-platform-user CLI', () => { + test('binds only an OIDC subject, display name, and optional local role', () => { + expect( + parseOptions(['--subject', 'oidc-subject-1', '--display-name', 'Alice Example']) + ).toEqual({ + subject: 'oidc-subject-1', + displayName: 'Alice Example', + role: 'user' + }) + + expect( + parseOptions([ + '--subject', + 'oidc-subject-1', + '--display-name', + 'Alice Example', + '--role', + 'admin' + ]) + ).toMatchObject({ role: 'admin' }) + }) + + test('rejects database, workspace, password, and annotation provisioning arguments', () => { + for (const name of [ + '--private-db-secret-ref', + '--workspace', + '--password-file', + '--public-annotation-snapshot-id' + ]) { + expect(() => + parseOptions([ + '--subject', + 'oidc-subject-1', + '--display-name', + 'Alice Example', + name, + 'value' + ]) + ).toThrow(/Unknown argument/) + } + }) +}) diff --git a/vite.web.config.ts b/vite.web.config.ts index c1cd92914..22c9b9fb3 100644 --- a/vite.web.config.ts +++ b/vite.web.config.ts @@ -84,7 +84,8 @@ export default defineConfig({ rollupOptions: { input: { server: resolve(__dirname, 'src/web/server.ts'), - 'postgres-import-worker': resolve(__dirname, 'src/main/workers/postgres-import-worker.ts') + 'postgres-import-worker': resolve(__dirname, 'src/main/workers/postgres-import-worker.ts'), + 'provision-platform-user': resolve(__dirname, 'src/web/provision-platform-user.ts') }, output: { entryFileNames: (chunkInfo) => (chunkInfo.name === 'server' ? 'server.cjs' : '[name].cjs'),