diff --git a/server/public/dashboard-api-keys.html b/server/public/dashboard-api-keys.html index bc487e67b03..6e9a686c1bc 100644 --- a/server/public/dashboard-api-keys.html +++ b/server/public/dashboard-api-keys.html @@ -566,16 +566,23 @@

${escapeHtml(key.name)}

} const data = await response.json(); - const { org, needsSelection } = DashboardNav.resolveOrg(data.organizations); + // Key management requires a selection in this request, including for + // single-org users. Do not substitute a remembered or primary org. + const selectedOrgIds = new URLSearchParams(window.location.search).getAll('org'); + const organizations = data.organizations || []; - if (needsSelection) { + if (selectedOrgIds.length === 0 && organizations.length > 0) { DashboardNav.init({ currentOrgName: 'Select organization' }); - DashboardNav.renderOrgPicker(data.organizations, document.getElementById('loading')); + DashboardNav.renderOrgPicker(organizations, document.getElementById('loading')); return; } + const org = selectedOrgIds.length === 1 + ? organizations.find(candidate => candidate.id === selectedOrgIds[0]) + : null; if (!org) { - document.getElementById('loading').innerHTML = 'No organization found. Go to dashboard'; + DashboardNav.init({ currentOrgName: 'Select organization' }); + document.getElementById('loading').textContent = 'The selected organization is unavailable. Choose an organization from the dashboard.'; return; } diff --git a/server/src/auth/workos-jwt.ts b/server/src/auth/workos-jwt.ts index e8c81cd04a7..8b672736a84 100644 --- a/server/src/auth/workos-jwt.ts +++ b/server/src/auth/workos-jwt.ts @@ -7,11 +7,46 @@ * token issued by the MCP OAuth flow works across both surfaces. */ -import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; +import { createRemoteJWKSet, errors, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from 'jose'; import { createLogger } from '../logger.js'; const logger = createLogger('workos-jwt'); +/** Only token/claim failures prove invalidity; key-service and unknown failures do not. */ +export function isInvalidWorkOSJWTError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const code = (error as { code?: unknown }).code; + return typeof code === 'string' && [ + 'ERR_JWT_EXPIRED', 'ERR_JWT_INVALID', 'ERR_JWT_CLAIM_VALIDATION_FAILED', + 'ERR_JWS_INVALID', 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + 'ERR_JWKS_NO_MATCHING_KEY', + 'ERR_JOSE_ALG_NOT_ALLOWED', 'ERR_JOSE_NOT_SUPPORTED', + ].includes(code); +} + +export class WorkOSJWTUnavailableError extends Error { + readonly status = 503; + + constructor() { + super('Bearer authentication temporarily unavailable'); + this.name = 'WorkOSJWTUnavailableError'; + } +} + +/** Key-service failures can share JOSE codes with malformed token failures. */ +export function unavailableJWTKeyService(resolver: JWTVerifyGetKey): JWTVerifyGetKey { + return async (header, token) => { + try { + return await resolver(header, token); + } catch (error) { + // A healthy JWKS that does not contain the selected kid definitively + // rejects this credential; only key-service failures are unavailable. + if (error instanceof errors.JWKSNoMatchingKey) throw error; + throw new WorkOSJWTUnavailableError(); + } + }; +} + /** * Read `WORKOS_CLIENT_ID` at call time rather than at module import. * Vitest can evaluate `workos-jwt.ts` before a test file has had a @@ -92,15 +127,21 @@ export function looksLikeJWT(token: string): boolean { export async function verifyWorkOSJWT(token: string): Promise { const jwksInstance = getJWKS(); - const { payload } = await jwtVerify(token, jwksInstance); + const { payload } = await jwtVerify(token, + typeof jwksInstance === 'function' ? unavailableJWTKeyService(jwksInstance) : jwksInstance, + // WorkOS user-management tokens use RS256. Reject unsupported token + // algorithms before consulting a key service whose failure means 503. + { algorithms: ['RS256'] }, + ); const azp = typeof payload.azp === 'string' ? payload.azp : undefined; const clientIdClaim = typeof payload.client_id === 'string' ? payload.client_id : undefined; const applicationId = azp ?? clientIdClaim; if (!applicationId || applicationId !== workosClientId()) { - throw new Error( + throw new errors.JWTClaimValidationFailed( `Token application id ("${applicationId ?? 'missing'}") does not match this application`, + payload, azp ? 'azp' : 'client_id', applicationId ? 'mismatch' : 'missing', ); } diff --git a/server/src/conformance/token-route.ts b/server/src/conformance/token-route.ts index 1fb6f208c5e..e1f2000b4b7 100644 --- a/server/src/conformance/token-route.ts +++ b/server/src/conformance/token-route.ts @@ -10,9 +10,9 @@ * Removed in production builds via NODE_ENV gate. */ -import { Router, type Request, type Response } from 'express'; +import { Router, type Request, type Response, type NextFunction } from 'express'; import { requireAuth } from '../middleware/auth.js'; -import { resolveCallerOrgId } from '../routes/helpers/resolve-caller-org.js'; +import { resolveCallerOrgId, sendCallerOrganizationAuthError } from '../routes/helpers/resolve-caller-org.js'; import { createLogger } from '../logger.js'; import { issueConformanceToken } from './token.js'; import { conformanceSessions } from './session-store.js'; @@ -86,7 +86,7 @@ export function buildConformanceTokenRouter(): Router { router.post('/_debug/run-storyboard', requireAuth, async (req: Request, res: Response) => { const isStaticAdmin = (req as Request & { isStaticAdminApiKey?: boolean }) .isStaticAdminApiKey === true; - const callerOrgId = await resolveCallerOrgId(req); + const callerOrgId = isStaticAdmin ? null : await resolveCallerOrgId(req); const bodyOrgId = typeof req.body?.org_id === 'string' ? req.body.org_id : null; let targetOrgId: string; @@ -124,5 +124,11 @@ export function buildConformanceTokenRouter(): Router { }); } + // Preserve typed credential failures before the application's generic 5xx + // handler can turn unavailable organization verification into a 500. + router.use((error: unknown, _req: Request, res: Response, next: NextFunction) => { + if (!sendCallerOrganizationAuthError(error, res)) next(error); + }); + return router; } diff --git a/server/src/db/api-key-management-db.ts b/server/src/db/api-key-management-db.ts new file mode 100644 index 00000000000..57a3cd560a8 --- /dev/null +++ b/server/src/db/api-key-management-db.ts @@ -0,0 +1,217 @@ +import type { PoolClient, QueryConfig, QueryResultRow } from 'pg'; +import { getPool, queryWithTimeout } from './client.js'; +import { + AuthorizationSnapshotUnavailableError, + sameAuthorizationIdentity, + type AuthorizationSnapshot, +} from './user-authorization-snapshot-db.js'; + +const AUTHORIZATION_TIMEOUT_MS = 2_000; +const FENCE_TIMEOUT_MS = 15_000; + +// pg supports per-query read deadlines; @types/pg declares this option only on +// ClientConfig. Preserve the runtime option without weakening query result types. +type BoundedQueryConfig = QueryConfig & { query_timeout: number }; + +// Secret management never reads grants, including during authentication. Keep +// this projection separate from the grant-aware snapshot used by other routes. +const SNAPSHOT_SQL = ` + SELECT pg_catalog.pg_is_in_recovery() AS in_recovery, + credential.workos_user_id AS authenticated_user_id, + primary_binding.workos_user_id AS canonical_user_id, binding.identity_id, + COALESCE(primary_binding.primary_count, 0)::text AS primary_count, + EXISTS ( + SELECT 1 FROM registry_audit_log audit + WHERE audit.workos_user_id = $1 + AND audit.action IN ( + 'identity_credential_deleted', 'identity_primary_deletion_quarantined' + ) + ) AS terminal_marker, + COALESCE(epoch.epoch, 0)::text AS authorization_epoch, + credential.email, credential.email_verified, credential.first_name, credential.last_name + FROM (VALUES (1)) AS anchor(value) + LEFT JOIN users credential ON credential.workos_user_id = $1 + LEFT JOIN identity_workos_users binding ON binding.workos_user_id = credential.workos_user_id + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS primary_count, MIN(candidate.workos_user_id) AS workos_user_id + FROM identity_workos_users candidate + WHERE candidate.identity_id = binding.identity_id AND candidate.is_primary + ) primary_binding ON TRUE + LEFT JOIN authorization_epochs epoch ON epoch.workos_user_id = credential.workos_user_id`; + +interface SnapshotRow { + in_recovery: boolean; + terminal_marker: boolean; + primary_count: string; + authenticated_user_id: string | null; + canonical_user_id: string | null; + identity_id: string | null; + authorization_epoch: string; + email: string | null; + email_verified: boolean; + first_name: string | null; + last_name: string | null; +} + +function snapshotFromRow(row: SnapshotRow | undefined, organizationId: string | null): AuthorizationSnapshot | null { + if (!row || row.in_recovery) throw new AuthorizationSnapshotUnavailableError(); + // Grant-free management must retain the primary snapshot's terminal + // lifecycle boundary, including stale provider recreation and bad routing. + if (row.terminal_marker || !row.authenticated_user_id || !row.identity_id + || row.primary_count !== '1' || !row.canonical_user_id) return null; + return Object.freeze({ + authenticatedUserId: row.authenticated_user_id, + canonicalUserId: row.canonical_user_id, + identityId: row.identity_id, + selectedOrganizationId: organizationId, + authorizationEpoch: row.authorization_epoch, + credential: Object.freeze({ + email: row.email, emailVerified: row.email_verified, + firstName: row.first_name, lastName: row.last_name, + }), + credentialGrant: null, + }); +} + +export async function loadApiKeyManagementSnapshot( + authenticatedUserId: string, + organizationId: string | null, +): Promise { + try { + const result = await queryWithTimeout(SNAPSHOT_SQL, [authenticatedUserId], AUTHORIZATION_TIMEOUT_MS, { + retryTransientCheckout: false, + }); + return snapshotFromRow(result.rows[0], organizationId); + } catch { + throw new AuthorizationSnapshotUnavailableError(); + } +} + +export class ApiKeyManagementStateChangedError extends Error {} + +function assertSameState(previous: AuthorizationSnapshot, current: AuthorizationSnapshot | null): void { + if (!current || !sameAuthorizationIdentity(previous, current) + || previous.selectedOrganizationId !== current.selectedOrganizationId) { + throw new ApiKeyManagementStateChangedError(); + } +} + +async function checkoutBefore(deadline: number): Promise { + const checkout = getPool().connect(); + let timer: ReturnType | undefined; + let expired = false; + try { + return await Promise.race([ + checkout.then((client) => { + if (expired) client.release(); + return client; + }), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + expired = true; + reject(new AuthorizationSnapshotUnavailableError()); + }, Math.max(1, deadline - Date.now())); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Serialize with real identity/epoch row writers, not a route-local mutex. + * No rows are inserted or updated, and the callback is never retried. A users + * FOR UPDATE lock also blocks a first epoch INSERT through its foreign key. + * + * WorkOS membership changes do not participate in this database transaction. + * The caller must recheck direct membership inside it immediately before the + * effect; this fence does not claim atomicity with out-of-band WorkOS changes. + */ +export async function withApiKeyManagementFence( + previous: AuthorizationSnapshot, + work: (assertCurrent: () => Promise, assertLive: () => void) => Promise, +): Promise { + let client: PoolClient; + const deadline = Date.now() + FENCE_TIMEOUT_MS; + try { + client = await checkoutBefore(Date.now() + AUTHORIZATION_TIMEOUT_MS); + } catch { + throw new AuthorizationSnapshotUnavailableError(); + } + let destroyed = false; + const onConnectionError = () => { destroyed = true; }; + client.on('error', onConnectionError); + const query = async (text: string, values: unknown[] = []) => { + const remaining = deadline - Date.now(); + if (destroyed || remaining <= 0) throw new AuthorizationSnapshotUnavailableError(); + try { + // Driver timeout also bounds BEGIN/configuration and network loss. A timed + // out query is never returned to the pool with an uncertain transaction. + const config: BoundedQueryConfig = { text, values, query_timeout: Math.min(AUTHORIZATION_TIMEOUT_MS, remaining) }; + return await client.query(config); + } catch (error) { + destroyed = true; + throw error; + } + }; + try { + try { + await query('BEGIN'); + await query("SELECT set_config('statement_timeout', '2000ms', true), set_config('lock_timeout', '2000ms', true)"); + await query("SELECT set_config('idle_in_transaction_session_timeout', '15000ms', true)"); + const credential = await query( + 'SELECT workos_user_id FROM users WHERE workos_user_id = $1 FOR UPDATE', + [previous.authenticatedUserId], + ); + if (credential.rows.length === 0) throw new ApiKeyManagementStateChangedError(); + // Lock the identity against new primary bindings and its current primary + // against demotion/deletion. Attribution is a replay guard, never authority. + // Take the shared identity lock before either binding so simultaneous + // requests through primary and linked credentials cannot lock each other. + await query('SELECT id FROM identities WHERE id = $1 FOR UPDATE', [previous.identityId]); + await query( + 'SELECT workos_user_id FROM identity_workos_users WHERE workos_user_id = $1 FOR UPDATE', + [previous.authenticatedUserId], + ); + await query( + 'SELECT workos_user_id FROM identity_workos_users WHERE identity_id = $1 AND is_primary FOR UPDATE', + [previous.identityId], + ); + await query( + 'SELECT epoch FROM authorization_epochs WHERE workos_user_id = $1 FOR UPDATE', + [previous.authenticatedUserId], + ); + } catch (error) { + if (error instanceof ApiKeyManagementStateChangedError) throw error; + throw new AuthorizationSnapshotUnavailableError(); + } + const assertLive = () => { + if (destroyed || Date.now() >= deadline) throw new AuthorizationSnapshotUnavailableError(); + }; + const assertCurrent = async () => { + let current: AuthorizationSnapshot | null; + try { + if (Date.now() + 5_000 >= deadline) throw new AuthorizationSnapshotUnavailableError(); + const result = await query(SNAPSHOT_SQL, [previous.authenticatedUserId]); + if (destroyed || Date.now() + 5_000 >= deadline) throw new AuthorizationSnapshotUnavailableError(); + current = snapshotFromRow(result.rows[0], previous.selectedOrganizationId); + } catch { + throw new AuthorizationSnapshotUnavailableError(); + } + assertSameState(previous, current); + }; + await assertCurrent(); + return await work(assertCurrent, assertLive); + } finally { + // The transaction contains locks only. Cleanup failure cannot turn a known + // provider success into a claimed failed mutation, nor cause a second call. + try { + const cleanup: BoundedQueryConfig = { text: 'ROLLBACK', query_timeout: 500 }; + if (!destroyed) await client.query(cleanup); + } catch { + destroyed = true; + } + client.removeListener('error', onConnectionError); + client.release(destroyed); + } +} diff --git a/server/src/middleware/api-key-format.ts b/server/src/middleware/api-key-format.ts index bc8a94dc4b9..57c282fc457 100644 --- a/server/src/middleware/api-key-format.ts +++ b/server/src/middleware/api-key-format.ts @@ -5,3 +5,23 @@ export function isWorkOSApiKeyFormat(token: string): boolean { return token.startsWith('wos_api_key_') || token.startsWith('sk_'); } + +/** + * Recognize an explicitly selected bearer credential before considering cookies. + * HTTP authentication schemes are case-insensitive; accept separator spaces + * and tabs so alternate header formatting cannot cause credential fallback. + * An empty string is a presented, invalid bearer; null means no bearer scheme. + */ +export function getBearerToken(authorization: string | undefined): string | null { + if (authorization === undefined) return null; + let separator = 0; + while (separator < authorization.length && authorization[separator] !== ' ' && authorization[separator] !== '\t') separator++; + if (authorization.slice(0, separator).toLowerCase() !== 'bearer') return null; + while (authorization[separator] === ' ' || authorization[separator] === '\t') separator++; + return authorization.slice(separator); +} + +export function getWorkOSApiKeyToken(authorization: string | undefined): string | null { + const token = getBearerToken(authorization); + return token !== null && isWorkOSApiKeyFormat(token) ? token : null; +} diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 7c55bfcc1d5..88474cf2f82 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -10,11 +10,14 @@ import { import { resolveWebUserAAOAdminAccess } from '../addie/admin-status-lookup.js'; import { respondToAdminAuthorizationError } from '../auth/admin-authorization-response.js'; import { bansDb } from '../db/bans-db.js'; -import { isWorkOSApiKeyFormat } from './api-key-format.js'; -import { verifyWorkOSJWT, looksLikeJWT } from '../auth/workos-jwt.js'; +import { getBearerToken, getWorkOSApiKeyToken, isWorkOSApiKeyFormat } from './api-key-format.js'; +import { verifyWorkOSJWT, looksLikeJWT, isInvalidWorkOSJWTError, WorkOSJWTUnavailableError } from '../auth/workos-jwt.js'; import { storeRefreshedSession, getRefreshedSession, cleanExpiredRefreshes } from '../db/session-refresh-db.js'; import { loadAuthorizationSnapshot, AuthorizationSnapshotUnavailableError } from '../db/user-authorization-snapshot-db.js'; import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js'; +import { loadApiKeyManagementSnapshot } from '../db/api-key-management-db.js'; + +type AuthorizationSnapshotLoader = typeof loadAuthorizationSnapshot; import { constantTimeEqual } from '../utils/constant-time-equal.js'; import { resolveEffectiveMembership } from '../db/org-filters.js'; @@ -24,6 +27,13 @@ const logger = createLogger('auth-middleware'); const workos = new WorkOS(process.env.WORKOS_API_KEY!, { clientId: process.env.WORKOS_CLIENT_ID!, }); +// Credential validation must finish within the request budget. Keep session +// refresh's client policy separate; never retry an API-key authorization check. +const apiKeyWorkos = new WorkOS(process.env.WORKOS_API_KEY!, { + clientId: process.env.WORKOS_CLIENT_ID!, + timeout: 5_000, + maxRetries: 0, +}); const WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID!; const WORKOS_COOKIE_PASSWORD = process.env.WORKOS_COOKIE_PASSWORD!; @@ -202,6 +212,16 @@ function isTransientAuthError(err: unknown): boolean { return false; } +/** Keep provider-key failures distinct from malformed sealed credentials. */ +function isInvalidSealedBearerError(error: unknown): boolean { + // The SDK verifies the sealed JWT with its own unwrapped key resolver. + // Unsupported provider JWKs can throw this code even for a healthy token. + if (error instanceof Error && (error as { code?: unknown }).code === 'ERR_JOSE_NOT_SUPPORTED') return false; + return isInvalidWorkOSJWTError(error) || (error instanceof Error && ( + error.name === 'InvalidCharacterError' || error.message === 'Invalid expiration' + )); +} + /** * Invalidate session cache for a specific cookie (e.g., on logout) */ @@ -250,33 +270,57 @@ export interface ValidatedApiKey { type ApiKeySyntheticUser = WorkOSUser & { isMember?: boolean }; +class ApiKeyAuthorizationUnavailableError extends Error { + readonly status = 503; + + constructor() { + super('API key authorization unavailable'); + this.name = 'ApiKeyAuthorizationUnavailableError'; + } +} + /** * Validate a WorkOS API key from the Authorization header - * Returns the validated API key info or null if invalid + * Returns an organization-owned key or null if invalid/unsupported. + * Provider failures throw: unavailable validation is not evidence of invalidity. + * Deliberately uncached so revoke/rotation is checked for every request. */ export async function validateWorkOSApiKey(req: Request): Promise { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) return null; - - const token = authHeader.slice(7); // Remove 'Bearer ' prefix - - // WorkOS API keys use 'wos_api_key_' (legacy) or 'sk_' (current) prefix - if (!isWorkOSApiKeyFormat(token)) return null; + const token = getWorkOSApiKeyToken(req.headers.authorization); + if (!token) return null; + let result: Awaited>; try { - const result = await workos.apiKeys.createValidation({ value: token }); - if (!result.apiKey) return null; - - return { - id: result.apiKey.id, - organizationId: result.apiKey.owner.id, - name: result.apiKey.name, - permissions: result.apiKey.permissions, - }; - } catch (error) { - logger.debug({ err: error }, 'API key validation failed'); - return null; + result = await apiKeyWorkos.apiKeys.createValidation({ value: token }); + } catch { + // The provider authenticates this call with the server's WorkOS secret. + // Even a provider 401 may concern that secret, not the presented key. + // Do not log upstream errors that may contain the submitted key value. + throw new ApiKeyAuthorizationUnavailableError(); + } + if (result.apiKey === null) return null; + + const apiKey = result.apiKey; + // Only organization-owned keys are issued by this application. User-owned + // keys require their own exact-credential authorization policy. + if (apiKey?.owner?.type === 'user') return null; + if ( + !apiKey?.owner || apiKey.owner.type !== 'organization' || + typeof apiKey.id !== 'string' || !apiKey.id || + typeof apiKey.owner.id !== 'string' || !apiKey.owner.id || + typeof apiKey.name !== 'string' || + !Array.isArray(apiKey.permissions) || + !apiKey.permissions.every((permission) => typeof permission === 'string') + ) { + throw new ApiKeyAuthorizationUnavailableError(); } + + return { + id: apiKey.id, + organizationId: apiKey.owner.id, + name: apiKey.name, + permissions: [...apiKey.permissions], + }; } /** @@ -287,13 +331,7 @@ function apiKeyHasPermission(apiKey: ValidatedApiKey, permission: string): boole } async function buildApiKeyUser(apiKey: ValidatedApiKey): Promise { - let isMember = false; - try { - const membership = await resolveEffectiveMembership(apiKey.organizationId); - isMember = membership.is_member; - } catch (err) { - logger.warn({ err, apiKeyId: apiKey.id, organizationId: apiKey.organizationId }, 'Failed to resolve API key owner membership'); - } + const membership = await resolveEffectiveMembership(apiKey.organizationId); return { id: `api_key_${apiKey.id}`, @@ -303,10 +341,48 @@ async function buildApiKeyUser(apiKey: ValidatedApiKey): Promise { + if (!getWorkOSApiKeyToken(req.headers.authorization)) return false; + + try { + const apiKey = await validateWorkOSApiKey(req); + if (!apiKey) { + res.status(401).json({ error: 'invalid_api_key' }); + return true; + } + + selectedOrganizationForAuthentication(req, apiKey.organizationId); + + // Key and ban authorization are fresh on every use, including replays of + // a key that was previously accepted on this process. + const banCheck = await bansDb.checkPlatformBanForApiKey(apiKey.id, apiKey.organizationId); + if (typeof banCheck.banned !== 'boolean') throw new ApiKeyAuthorizationUnavailableError(); + if (banCheck.banned) { + if (banCheck.ban) sendBanResponse(res, banCheck.ban); + else res.status(403).json({ error: 'Account suspended' }); + return true; + } + + const user = await buildApiKeyUser(apiKey); + req.user = user; + req.accessToken = 'workos-api-key'; + (req as Request & { apiKey?: ValidatedApiKey }).apiKey = apiKey; + } catch (error) { + if (sendAuthorizationStateError(error, res)) return true; + logger.warn({ path: req.path }, 'API key authorization unavailable'); + res.status(503).json({ error: 'authorization_unavailable' }); + return true; + } + + next(); + return true; +} + /** * Validated OAuth bearer token — a WorkOS-signed JWT issued via the * OAuth 2.1 flow brokered by mcpAuthRouter. Callers end up here when @@ -353,10 +429,11 @@ function hashBearerToken(token: string): string { * whether the org holds an active AAO membership. Downstream * `enrichUserWithMembership` resolves real membership via the DB. */ -export async function validateWorkOSBearerJWT(req: Request): Promise { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) return null; - const token = authHeader.slice(7); +export async function validateWorkOSBearerJWT( + req: Request, loadSnapshot: AuthorizationSnapshotLoader = loadAuthorizationSnapshot, +): Promise { + const token = getBearerToken(req.headers.authorization); + if (!token) return null; if (isWorkOSApiKeyFormat(token)) return null; // handled by validateWorkOSApiKey if (ADMIN_API_KEY && token === ADMIN_API_KEY) return null; // handled by hasValidAdminApiKey @@ -372,9 +449,8 @@ export async function validateWorkOSBearerJWT(req: Request): Promise value !== undefined); @@ -728,9 +803,12 @@ function selectedOrganizationForAuthentication(req: Request, providerOrg?: strin } /** Hydrate a new request object; never mutate the provider object in a cache. */ -async function hydrateAuthenticatedUser(user: WorkOSUser, organizationId: string | null): Promise { +async function hydrateAuthenticatedUser( + user: WorkOSUser, organizationId: string | null, + loadSnapshot: AuthorizationSnapshotLoader = loadAuthorizationSnapshot, +): Promise { const authenticatedUserId = getOrganizationAuthorizationUserId(user); - const snapshot = await loadAuthorizationSnapshot(authenticatedUserId, organizationId); + const snapshot = await loadSnapshot(authenticatedUserId, organizationId); if (!snapshot) throw new InvalidAuthorizationCredentialError(); const hydrated: WorkOSUser = { ...user, @@ -749,16 +827,18 @@ async function hydrateAuthenticatedUser(user: WorkOSUser, organizationId: string /** Local dev login is an explicit synthetic-auth bypass. Keep attribution useful * against seeded databases without making a database mandatory for dev fixtures. */ -async function hydrateDevUser(user: WorkOSUser): Promise { +async function hydrateDevUser( + user: WorkOSUser, loadSnapshot: AuthorizationSnapshotLoader = loadAuthorizationSnapshot, +): Promise { try { - return await hydrateAuthenticatedUser(user, null); + return await hydrateAuthenticatedUser(user, null, loadSnapshot); } catch { return user; } } function sendAuthorizationStateError(error: unknown, res: Response, includeTransientErrors = false): boolean { - if (includeTransientErrors && isTransientAuthError(error)) { + if (error instanceof WorkOSJWTUnavailableError || (includeTransientErrors && isTransientAuthError(error))) { logger.warn({ err: error }, 'Authentication service unavailable'); res.status(503).json({ error: 'Authentication service temporarily unavailable' }); return true; @@ -788,7 +868,19 @@ function sendAuthorizationStateError(error: unknown, res: Response, includeTrans * Automatically refreshes expired access tokens using the refresh token */ export async function requireAuth(req: Request, res: Response, next: NextFunction) { - const isHtmlRequest = req.accepts('html') && !req.originalUrl.startsWith('/api/'); + return requireAuthWithSnapshot(req, res, next, loadAuthorizationSnapshot); +} + +/** Trusted route wiring: secret management must not depend on credential grants. */ +export async function requireApiKeyManagementAuth(req: Request, res: Response, next: NextFunction) { + return requireAuthWithSnapshot(req, res, next, loadApiKeyManagementSnapshot); +} + +async function requireAuthWithSnapshot( + req: Request, res: Response, next: NextFunction, loadSnapshot: AuthorizationSnapshotLoader, +) { + const bearerToken = getBearerToken(req.headers.authorization); + const isHtmlRequest = bearerToken === null && req.accepts('html') && !req.originalUrl.startsWith('/api/'); // Check for static admin API key first (for internal tooling) if (hasValidAdminApiKey(req)) { @@ -808,34 +900,13 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio return next(); } - // Check for WorkOS API key (for programmatic access) - const apiKey = await validateWorkOSApiKey(req); - if (apiKey) { - logger.debug({ path: req.path, apiKeyId: apiKey.id }, 'Authenticated via WorkOS API key'); - // Create a synthetic user for API key auth - the organization owns the key - req.user = await buildApiKeyUser(apiKey); - req.accessToken = 'workos-api-key'; - // Store API key info for permission checks - (req as Request & { apiKey?: ValidatedApiKey }).apiKey = apiKey; - - // Check platform ban for API key - const apiKeyBan = await checkPlatformBan( - `apikey:${apiKey.id}`, - () => bansDb.checkPlatformBanForApiKey(apiKey.id, apiKey.organizationId) - ); - if (apiKeyBan) { - logger.info({ apiKeyId: apiKey.id, banId: apiKeyBan.id }, 'API key request blocked by platform ban'); - return sendBanResponse(res, apiKeyBan); - } - - return next(); - } + if (await authenticateApiKeyRequest(req, res, next)) return; // Check for OAuth-issued user JWT (user SSO'd via AuthKit through the // MCP OAuth flow and is now calling the REST API with that token). let jwtAuth: ValidatedBearerJWT | null; try { - jwtAuth = await validateWorkOSBearerJWT(req); + jwtAuth = await validateWorkOSBearerJWT(req, loadSnapshot); } catch (error) { if (sendAuthorizationStateError(error, res, true)) return; throw error; @@ -861,11 +932,16 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio return next(); } - // Dev mode: check for dev-session cookie - if (DEV_MODE_ENABLED) { + // A rejected JWT or empty bearer is terminal, including with a valid cookie. + if (bearerToken !== null && (!bearerToken || looksLikeJWT(bearerToken) || bearerToken.startsWith('eyJ'))) { + return res.status(401).json({ error: 'Invalid bearer token' }); + } + + // Dev cookies cannot replace an explicitly selected bearer credential. + if (DEV_MODE_ENABLED && bearerToken === null) { const devUser = createDevUser(req); if (devUser) { - req.user = await hydrateDevUser(devUser); + req.user = await hydrateDevUser(devUser, loadSnapshot); req.accessToken = 'dev-mode-token'; // Carry over dev config flags (isMember, isAdmin) so enrichUserWithMembership skips DB lookup const devConfig = getDevUser(req); @@ -886,21 +962,19 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio }); } - const sessionCookie = req.cookies['wos-session']; + const sessionCookie = bearerToken === null ? req.cookies['wos-session'] : extractSealedSession(req); logger.debug({ path: req.path, hasCookie: !!sessionCookie, isHtmlRequest }, 'Authentication check'); - // codeql[js/user-controlled-bypass] - auth check must read session cookie to verify identity if (!sessionCookie) { logger.info({ path: req.path, isHtmlRequest }, 'No wos-session cookie present — user not logged in'); - if (isHtmlRequest) { - return res.redirect(`/auth/login?return_to=${encodeURIComponent(req.originalUrl)}`); - } - return res.status(401).json({ - error: 'Authentication required', - message: 'Please log in to access this resource', - login_url: '/auth/login', - }); + return isHtmlRequest + ? res.redirect(`/auth/login?return_to=${encodeURIComponent(req.originalUrl)}`) + : res.status(401).json({ + error: 'Authentication required', + message: 'Please log in to access this resource', + login_url: '/auth/login', + }); } try { @@ -930,7 +1004,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio // Cache hit - use cached session data logger.debug({ userId: cached.user.id }, 'Using cached session'); req.user = await hydrateAuthenticatedUser( - cached.user, selectedOrganizationForAuthentication(req, cached.orgId), + cached.user, selectedOrganizationForAuthentication(req, cached.orgId), loadSnapshot, ); req.accessToken = cached.accessToken; @@ -982,6 +1056,9 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio cookiePassword: WORKOS_COOKIE_PASSWORD, }); + if (bearerToken !== null && !refreshResult.authenticated && 'retryable' in refreshResult && refreshResult.retryable) { + throw new WorkOSJWTUnavailableError(); + } if (refreshResult.authenticated && refreshResult.sealedSession) { logger.debug({ path: req.path }, 'Session refresh succeeded'); newSealedSession = refreshResult.sealedSession; @@ -1009,6 +1086,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio refreshFailed = true; } } catch (refreshError) { + if (bearerToken !== null) throw refreshError; logger.warn({ err: refreshError, path: req.path }, 'Session refresh threw an error'); refreshFailed = true; } @@ -1039,6 +1117,9 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio const sharedRefresh = await sharedSessionObj.refresh({ cookiePassword: WORKOS_COOKIE_PASSWORD, }); + if (bearerToken !== null && !sharedRefresh.authenticated && 'retryable' in sharedRefresh && sharedRefresh.retryable) { + throw new WorkOSJWTUnavailableError(); + } if (sharedRefresh.authenticated && sharedRefresh.sealedSession) { logger.info({ path: req.path }, 'Shared session refresh succeeded'); newSealedSession = sharedRefresh.sealedSession; @@ -1054,6 +1135,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio result = await refreshedObj.authenticate(); } } catch (innerRefreshErr) { + if (bearerToken !== null) throw innerRefreshErr; logger.warn({ err: innerRefreshErr, path: req.path }, 'Shared session refresh also failed'); } @@ -1063,6 +1145,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio } } } catch (dbError) { + if (bearerToken !== null) throw dbError; logger.warn({ err: dbError, path: req.path }, 'Failed to look up shared session'); } } @@ -1126,7 +1209,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio } const hydrated = await hydrateAuthenticatedUser( - user, selectedOrganizationForAuthentication(req, result.organizationId), + user, selectedOrganizationForAuthentication(req, result.organizationId), loadSnapshot, ); // Retain authentication only; derived request authority is never cached. @@ -1158,6 +1241,9 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio next(); } catch (error) { if (sendAuthorizationStateError(error, res)) return; + if (bearerToken !== null && !isInvalidSealedBearerError(error)) { + return res.status(503).json({ error: 'Authentication service temporarily unavailable' }); + } if (isTransientAuthError(error)) { logger.warn({ err: error, path: req.path }, 'Transient auth upstream failure — returning 503 without clearing session'); if (isHtmlRequest) { @@ -1785,9 +1871,8 @@ function extractSealedSession(req: Request): string | undefined { // Format: Authorization: Bearer const authHeader = req.headers.authorization; if (authHeader !== undefined) { - if (!authHeader.startsWith('Bearer ')) return undefined; - const token = authHeader.slice(7); - if (token.trim() && !isWorkOSApiKeyFormat(token) && !looksLikeJWT(token)) { + const token = getBearerToken(authHeader); + if (token && token.trim() && !isWorkOSApiKeyFormat(token) && !looksLikeJWT(token) && !token.startsWith('eyJ')) { return token; } return undefined; @@ -1821,24 +1906,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti return next(); } - const apiKey = await validateWorkOSApiKey(req); - if (apiKey) { - logger.debug({ path: req.path, apiKeyId: apiKey.id }, 'Authenticated via WorkOS API key (optional auth)'); - req.user = await buildApiKeyUser(apiKey); - req.accessToken = 'workos-api-key'; - (req as Request & { apiKey?: ValidatedApiKey }).apiKey = apiKey; - - const apiKeyBan = await checkPlatformBan( - `apikey:${apiKey.id}`, - () => bansDb.checkPlatformBanForApiKey(apiKey.id, apiKey.organizationId) - ); - if (apiKeyBan) { - logger.info({ apiKeyId: apiKey.id, banId: apiKeyBan.id }, 'API key optional-auth request blocked by platform ban'); - return sendBanResponse(res, apiKeyBan); - } - - return next(); - } + if (await authenticateApiKeyRequest(req, res, next)) return; let jwtAuth: ValidatedBearerJWT | null; try { @@ -1869,7 +1937,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti } // Dev mode: set dev user if logged in via dev-session cookie - if (DEV_MODE_ENABLED) { + if (DEV_MODE_ENABLED && req.headers.authorization === undefined) { const devUser = createDevUser(req); if (devUser) { req.user = await hydrateDevUser(devUser); @@ -1965,7 +2033,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti refreshFailed = true; } } catch (refreshError) { - if (isTransientAuthError(refreshError)) throw refreshError; + if (getBearerToken(req.headers.authorization) !== null || isTransientAuthError(refreshError)) throw refreshError; logger.debug({ err: refreshError }, 'Optional auth refresh failed'); refreshFailed = true; } @@ -2003,7 +2071,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti result = await refreshedObj.authenticate(); } } catch (innerRefreshErr) { - if (isTransientAuthError(innerRefreshErr)) throw innerRefreshErr; + if (getBearerToken(req.headers.authorization) !== null || isTransientAuthError(innerRefreshErr)) throw innerRefreshErr; logger.debug({ err: innerRefreshErr }, 'Shared session refresh also failed (optional auth)'); } } else { @@ -2074,6 +2142,9 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti } } catch (error) { if (sendAuthorizationStateError(error, res, true)) return; + if (getBearerToken(req.headers.authorization) !== null && !isInvalidSealedBearerError(error)) { + return res.status(503).json({ error: 'Authentication service temporarily unavailable' }); + } logger.debug({ err: error }, 'Optional auth failed'); return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index c6006fd2e82..c82fd52834f 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -460,6 +460,16 @@ export function createAdminRouter(): { pageRouter: Router; apiRouter: Router } { }); } + // API-key Widgets would reopen the mutation path contained by the REST + // routes. Do not delegate it until membership revocation and key + // mutation share a provider-enforced authorization fence. + if (requestedScope === "widgets:api-keys:manage") { + return res.status(503).json({ + error: "API key mutations unavailable", + message: "API key creation and revocation are disabled until membership changes can be fenced.", + }); + } + const token = await workos.widgets.createToken({ organizationId, // Widgets are bound to a specific WorkOS user as auth credential — diff --git a/server/src/routes/api-keys.ts b/server/src/routes/api-keys.ts index 32d43a0ad8d..2cde4ef6148 100644 --- a/server/src/routes/api-keys.ts +++ b/server/src/routes/api-keys.ts @@ -1,20 +1,29 @@ /** * API key management routes * - * Uses WorkOS API for organization API key CRUD. - * Requires authenticated session (cookie-based auth). - * Verifies org membership before allowing any operation. + * Lists organization API keys through WorkOS. Mutations are contained until + * the provider can enforce a durable membership fence. + * Requires an authenticated user credential and an explicitly selected org. + * Verifies fresh, exact-credential authority before every operation. */ import type { Request, Response } from "express"; import { Router } from "express"; import { WorkOS } from "@workos-inc/node"; import { createLogger } from "../logger.js"; -import { requireAuth } from "../middleware/auth.js"; +import { requireApiKeyManagementAuth } from "../middleware/auth.js"; +import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js"; import { - resolveUserOrgMembership, - type UserOrgMembership, -} from "../utils/resolve-user-org-membership.js"; + loadApiKeyManagementSnapshot, + withApiKeyManagementFence, + ApiKeyManagementStateChangedError, +} from "../db/api-key-management-db.js"; +import { + AuthorizationSnapshotUnavailableError, + sameAuthorizationIdentity, + type AuthorizationSnapshot, +} from "../db/user-authorization-snapshot-db.js"; +import { resolveUserRole } from "../utils/resolve-user-role.js"; const logger = createLogger("api-keys-routes"); @@ -36,19 +45,31 @@ const AUTH_ENABLED = !!( const workos = AUTH_ENABLED ? new WorkOS(WORKOS_API_KEY!, { clientId: process.env.WORKOS_CLIENT_ID!, + timeout: 5_000, + maxRetries: 0, }) : null; -/** - * Make a direct HTTP request to the WorkOS API. - * Used for endpoints without dedicated SDK methods (e.g., API key management). - */ -async function workosRequest( - method: string, - path: string, - options?: { query?: Record; body?: unknown }, +class KeyManagementAuthorizationError extends Error { + constructor(readonly status: 403 | 503) { + super(status === 403 ? "Access denied" : "Organization authorization unavailable"); + } +} + +// Accept only opaque ASCII identifiers, not paths or encoded paths. Excluding +// dots also prevents URL normalization, which encodeURIComponent alone cannot. +function isOpaqueWorkOSId(value: string): boolean { + return value.length > 0 && !/[^A-Za-z0-9_-]/.test(value); +} + +/** Read only the fixed API-key inventory subresource of a validated organization. */ +async function listWorkOSApiKeys( + organizationId: string, + options?: { query?: Record }, ): Promise<{ status: number; data: unknown }> { - const url = new URL(path, WORKOS_BASE_URL); + if (!isOpaqueWorkOSId(organizationId)) throw new KeyManagementAuthorizationError(503); + const url = new URL(WORKOS_BASE_URL); + url.pathname = `/organizations/${organizationId}/api_keys`; if (options?.query) { for (const [key, value] of Object.entries(options.query)) { url.searchParams.set(key, value); @@ -59,23 +80,18 @@ async function workosRequest( Authorization: `Bearer ${WORKOS_API_KEY}`, }; - const fetchOptions: RequestInit = { method, headers }; - if (options?.body) { - headers["Content-Type"] = "application/json"; - fetchOptions.body = JSON.stringify(options.body); + // Deliberately read-only: no transport path can create or revoke a key until + // membership revocation and key mutation share an authoritative fence. + const fetchOptions: RequestInit = { method: "GET", headers, signal: AbortSignal.timeout(5_000) }; + let response: globalThis.Response; + try { + response = await fetch(url.toString(), fetchOptions); + } catch { + throw new KeyManagementAuthorizationError(503); } - - // CodeQL: URL is constructed from hardcoded https://api.workos.com base + API path - const response = await fetch(url.toString(), fetchOptions); // lgtm[js/request-forgery] if (!response.ok) { - let body: string; - try { - body = await response.text(); - } catch { - body = "(unable to read response body)"; - } const error = new Error( - `WorkOS API error: ${response.status} ${body}`, + `WorkOS API error: ${response.status}`, ) as Error & { status: number }; error.status = response.status; throw error; @@ -84,36 +100,109 @@ async function workosRequest( if (response.status === 204) { return { status: 204, data: null }; } - return { status: response.status, data: await response.json() }; + try { + return { status: response.status, data: await response.json() }; + } catch { + throw new KeyManagementAuthorizationError(503); + } } /** - * Resolve the authenticated user's active membership in the specified organization. - * Returns null and sends the appropriate error response when access is denied. + * Select only an explicit request organization. Never consult a session's org, + * the canonical identity, or a primary/first organization. Conflicting selectors + * are ambiguous even when the caller can administer both organizations. */ -async function verifyOrgMembership( +function selectedOrganizationId( req: Request, res: Response, - organizationId: string, -): Promise { - const membership = await resolveUserOrgMembership( - workos, - req.user!.id, - organizationId, - ); - - // resolveUserOrgMembership currently derives roles from active rows only, - // but keep the status check at this key-management boundary so a future - // resolver change (or inconsistent upstream response) cannot create, expose, - // or revoke keys for a pending or inactive membership. - if (!membership || membership.status !== "active") { - res.status(403).json({ - error: "Access denied", - message: "You are not a member of this organization", +): string | null { + // These are the same explicit selectors recognized by the authorization + // observer. Require agreement rather than giving one location precedence. + const headerOrg = req.headers["x-organization-id"]; + const supplied: unknown[] = [ + req.query.org, + req.query.organization_id, + req.query.organizationId, + req.body?.organizationId, + req.body?.organization_id, + headerOrg, + ].filter((value) => value !== undefined); + const headerCount = req.rawHeaders.filter( + (value, index) => index % 2 === 0 && value.toLowerCase() === "x-organization-id", + ).length; + if ( + supplied.length === 0 || + headerCount > 1 || + (typeof headerOrg === "string" && headerOrg.includes(",")) || + supplied.some((value) => typeof value !== "string" || !isOpaqueWorkOSId(value)) || + supplied.some((value) => value !== supplied[0]) + ) { + res.status(400).json({ + error: "Select one organization explicitly", }); return null; } - return membership; + return supplied[0] as string; +} + +interface ApiKeyManagementAuthorization { + organizationId: string; + snapshot: AuthorizationSnapshot; + directMembership: string; + actor: { + userId: string; + authWorkosUserId: string; + identityId?: string; + }; +} + +async function requireDirectAdminMembership(credentialId: string, organizationId: string): Promise { + if (!workos) throw new KeyManagementAuthorizationError(503); + try { + const memberships = await workos.userManagement.listOrganizationMemberships({ + userId: credentialId, organizationId, + }); + const direct = memberships.data.filter((membership) => + membership.userId === credentialId && membership.organizationId === organizationId + && membership.status === "active", + ); + // These provider markers detect replacement, role changes and observed ABA. + // updatedAt is not a monotonic epoch or a provider-enforced mutation lease. + const records = direct.map((membership) => { + if (!membership.id || !membership.createdAt || !membership.updatedAt + || typeof membership.id !== "string" || typeof membership.createdAt !== "string" + || typeof membership.updatedAt !== "string" || !membership.role?.slug + || typeof membership.role.slug !== "string" + || (membership.roles !== undefined && (!Array.isArray(membership.roles) + || membership.roles.some((entry) => typeof entry?.slug !== "string")))) { + throw new KeyManagementAuthorizationError(503); + } + return JSON.stringify([ + membership.id, membership.userId, membership.organizationId, membership.status, + membership.role.slug, membership.roles?.map((entry) => entry.slug).sort() ?? null, + membership.createdAt, membership.updatedAt, + ]); + }); + const role = resolveUserRole(direct); + if (role !== "owner" && role !== "admin") throw new KeyManagementAuthorizationError(403); + return JSON.stringify(records.sort()); + } catch (error) { + if (error instanceof KeyManagementAuthorizationError) throw error; + throw new KeyManagementAuthorizationError(503); + } +} + +function assertSameMembership(previous: string, current: string): void { + if (previous !== current) throw new KeyManagementAuthorizationError(403); +} + +function sendMutationUnavailable(res: Response): void { + // No feature flag or request override: re-enabling requires a reviewed, + // durable fence enforced by the same authority as membership mutation. + res.status(503).json({ + error: "API key mutations unavailable", + message: "API key creation and revocation are disabled until membership changes can be fenced.", + }); } /** @@ -122,18 +211,65 @@ async function verifyOrgMembership( * automation keys, and revocation can disable them. Keep the full lifecycle * restricted to active organization owners and admins. */ -function requireOrgAdmin( - membership: UserOrgMembership, +async function authorizeKeyManagement( + req: Request, res: Response, + organizationId: string, action: "create" | "list" | "revoke", -): boolean { - if (membership.role === "owner" || membership.role === "admin") return true; +): Promise { + const credentialId = getOrganizationAuthorizationUserId(req.user!); + const authorizationSnapshot = req.user!.authorizationSnapshot; + // Preserve authentication-time attribution across provider awaits. Snapshot + // state is non-enumerable, so carry it explicitly into the authorization check. + const actor = { + userId: authorizationSnapshot?.canonicalUserId ?? req.user!.id, + authWorkosUserId: credentialId, + identityId: authorizationSnapshot + ? authorizationSnapshot.identityId ?? undefined + : req.user!.identityId, + }; + // Key-use permissions (including tenant admin:*) do not grant key-management + // authority or impersonation of a user credential. + if (credentialId === "admin_api_key" || credentialId.startsWith("api_key_")) { + res.status(403).json({ error: "A user credential is required to manage API keys" }); + return null; + } - res.status(403).json({ - error: "Access denied", - message: `Only organization owners and admins can ${action} API keys`, + const snapshot = await loadApiKeyManagementSnapshot(credentialId, organizationId); + if (!snapshot || (authorizationSnapshot && ( + !sameAuthorizationIdentity(authorizationSnapshot, snapshot) + || (authorizationSnapshot.selectedOrganizationId !== null + && authorizationSnapshot.selectedOrganizationId !== organizationId) + ))) throw new ApiKeyManagementStateChangedError(); + + // Only current direct WorkOS membership can authorize secret management. + // Grants are neither read nor compared, including grants in an old snapshot. + let directMembership: string; + try { + directMembership = await requireDirectAdminMembership(credentialId, organizationId); + } catch (error) { + if (!(error instanceof KeyManagementAuthorizationError) || error.status !== 403) throw error; + res.status(403).json({ + error: "Access denied", + message: `Only organization owners and admins can ${action} API keys`, + }); + return null; + } + return { organizationId, snapshot, directMembership, actor }; +} + +async function withCurrentKeyManagementAuthorization( + authorization: ApiKeyManagementAuthorization, + effect: (assertCurrent: () => Promise, assertLive: () => void) => Promise, +): Promise { + return withApiKeyManagementFence(authorization.snapshot, async (assertCurrent, assertLive) => { + const directMembership = await requireDirectAdminMembership( + authorization.actor.authWorkosUserId, authorization.organizationId, + ); + assertSameMembership(authorization.directMembership, directMembership); + await assertCurrent(); + return effect(assertCurrent, assertLive); }); - return false; } /** @@ -145,6 +281,13 @@ function sendWorkOSError( error: unknown, fallbackMessage: string, ) { + if (error instanceof AuthorizationSnapshotUnavailableError || error instanceof KeyManagementAuthorizationError) { + const status = error instanceof KeyManagementAuthorizationError ? error.status : 503; + return res.status(status).json({ error: status === 403 ? "Access denied" : "Organization authorization unavailable" }); + } + if (error instanceof ApiKeyManagementStateChangedError) { + return res.status(403).json({ error: "Authorization changed; authenticate again" }); + } const status = error instanceof Error && "status" in error && typeof (error as { status: unknown }).status === "number" ? (error as { status: number }).status @@ -156,23 +299,17 @@ export function createApiKeysRouter(): Router { const router = Router(); // GET /api/me/api-keys - List API keys for an organization - router.get("/", requireAuth, async (req, res) => { + router.get("/", requireApiKeyManagementAuth, async (req, res) => { try { if (!workos) { - return res.status(500).json({ error: "Authentication not configured" }); + return res.status(503).json({ error: "Organization authorization unavailable" }); } - const requestedOrganizationId = req.query.org as string; - // Missing IDs terminate here; authorization below returns a canonical - // organization ID from an exact-match WorkOS membership. - if (!requestedOrganizationId) { - return res - .status(400) - .json({ error: "org query parameter is required" }); - } + const requestedOrganizationId = selectedOrganizationId(req, res); + if (!requestedOrganizationId) return; - const membership = await verifyOrgMembership(req, res, requestedOrganizationId); - if (!membership || !requireOrgAdmin(membership, res, "list")) return; + const membership = await authorizeKeyManagement(req, res, requestedOrganizationId, "list"); + if (!membership) return; const authorizedOrganizationId = membership.organizationId; const params: Record = {}; @@ -180,13 +317,21 @@ export function createApiKeysRouter(): Router { if (req.query.before) params.before = req.query.before as string; if (req.query.limit) params.limit = req.query.limit as string; - const result = await workosRequest( - "GET", - `/organizations/${authorizedOrganizationId}/api_keys`, - { query: params }, - ); - - res.json(result.data); + await withCurrentKeyManagementAuthorization(membership, async (assertCurrent, assertLive) => { + const result = await listWorkOSApiKeys( + authorizedOrganizationId, + { query: params }, + ); + // Buffer the entire provider read, then discard it if authority changed. + // Local locks remain held through emission; no cleanup await precedes it. + await assertCurrent(); + const currentMembership = await requireDirectAdminMembership( + membership.actor.authWorkosUserId, authorizedOrganizationId, + ); + assertLive(); + assertSameMembership(membership.directMembership, currentMembership); + res.json(result.data); + }); } catch (error) { logger.error({ err: error }, "Error listing API keys"); sendWorkOSError(res, error, "Failed to list API keys"); @@ -194,76 +339,52 @@ export function createApiKeysRouter(): Router { }); // POST /api/me/api-keys - Create an API key - router.post("/", requireAuth, async (req, res) => { + router.post("/", requireApiKeyManagementAuth, async (req, res) => { try { if (!workos) { - return res.status(500).json({ error: "Authentication not configured" }); - } - - const requestedOrganizationId = - (req.query.org as string) || req.body.organizationId; - // Missing IDs terminate here; authorization below returns a canonical - // organization ID from an exact-match WorkOS membership. - if (!requestedOrganizationId) { - return res.status(400).json({ - error: "org query parameter or organizationId in body is required", - }); + return res.status(503).json({ error: "Organization authorization unavailable" }); } - const membership = await verifyOrgMembership(req, res, requestedOrganizationId); - if (!membership || !requireOrgAdmin(membership, res, "create")) return; - const authorizedOrganizationId = membership.organizationId; + const requestedOrganizationId = selectedOrganizationId(req, res); + if (!requestedOrganizationId) return; + const membership = await authorizeKeyManagement(req, res, requestedOrganizationId, "create"); + if (!membership) return; const { name, permissions } = req.body; - if (!name) { - return res.status(400).json({ error: "name is required" }); - } - - if ( - permissions !== undefined && - (!Array.isArray(permissions) || - permissions.some((permission) => typeof permission !== "string")) - ) { - return res.status(400).json({ - error: "Invalid permissions", - message: "permissions must be an array of supported permission strings", - }); - } - - const requestedPermissions = [ - ...new Set((permissions as string[] | undefined) ?? []), - ]; - const unsupportedPermissions = requestedPermissions.filter( - (permission) => !ALLOWED_PRIVILEGED_PERMISSIONS.has(permission), - ); - if (unsupportedPermissions.length > 0) { - return res.status(400).json({ - error: "Invalid permissions", - message: "One or more requested permissions are not supported", - }); - } - - const body: { name: string; permissions?: string[] } = { name }; - if (requestedPermissions.length > 0) { - body.permissions = requestedPermissions; - } - - const result = await workosRequest( - "POST", - `/organizations/${authorizedOrganizationId}/api_keys`, - { body }, - ); - - logger.info( - { - userId: req.user!.id, - organizationId: authorizedOrganizationId, - keyName: name, - }, - "API key created", - ); - - res.status(201).json(result.data); + await withCurrentKeyManagementAuthorization(membership, async () => { + if (!name) { + res.status(400).json({ error: "name is required" }); + return null; + } + + if ( + permissions !== undefined && + (!Array.isArray(permissions) || + permissions.some((permission) => typeof permission !== "string")) + ) { + res.status(400).json({ + error: "Invalid permissions", + message: "permissions must be an array of supported permission strings", + }); + return null; + } + + const requestedPermissions = [ + ...new Set((permissions as string[] | undefined) ?? []), + ]; + const unsupportedPermissions = requestedPermissions.filter( + (permission) => !ALLOWED_PRIVILEGED_PERMISSIONS.has(permission), + ); + if (unsupportedPermissions.length > 0) { + res.status(400).json({ + error: "Invalid permissions", + message: "One or more requested permissions are not supported", + }); + return null; + } + + sendMutationUnavailable(res); + }); } catch (error) { logger.error({ err: error }, "Error creating API key"); sendWorkOSError(res, error, "Failed to create API key"); @@ -271,53 +392,26 @@ export function createApiKeysRouter(): Router { }); // DELETE /api/me/api-keys/:id - Revoke an API key - router.delete("/:id", requireAuth, async (req, res) => { + router.delete("/:id", requireApiKeyManagementAuth, async (req, res) => { try { if (!workos) { - return res.status(500).json({ error: "Authentication not configured" }); - } - - const requestedOrganizationId = req.query.org as string; - // Missing IDs terminate here; authorization below returns a canonical - // organization ID from an exact-match WorkOS membership. - if (!requestedOrganizationId) { - return res - .status(400) - .json({ error: "org query parameter is required" }); + return res.status(503).json({ error: "Organization authorization unavailable" }); } - const membership = await verifyOrgMembership(req, res, requestedOrganizationId); - if (!membership || !requireOrgAdmin(membership, res, "revoke")) return; - const authorizedOrganizationId = membership.organizationId; + const requestedOrganizationId = selectedOrganizationId(req, res); + if (!requestedOrganizationId) return; + const membership = await authorizeKeyManagement(req, res, requestedOrganizationId, "revoke"); + if (!membership) return; const apiKeyId = req.params.id; - try { - await workosRequest("DELETE", `/organizations/${authorizedOrganizationId}/api_keys/${apiKeyId}`); - logger.info( - { - userId: req.user!.id, - organizationId: authorizedOrganizationId, - apiKeyId, - }, - "API key revoked", - ); - } catch (error) { - const status = - error instanceof Error && "status" in error && typeof (error as { status: unknown }).status === "number" - ? (error as { status: number }).status - : null; - if (status !== 404) throw error; - logger.info( - { - userId: req.user!.id, - organizationId: authorizedOrganizationId, - apiKeyId, - }, - "API key revoke: already absent in WorkOS", - ); - } - - res.status(204).end(); + await withCurrentKeyManagementAuthorization(membership, async () => { + // Authorize before terminal input rejection. No provider URL is built. + if (!isOpaqueWorkOSId(apiKeyId)) { + res.status(400).json({ error: "Invalid API key ID" }); + return; + } + sendMutationUnavailable(res); + }); } catch (error) { logger.error({ err: error }, "Error revoking API key"); sendWorkOSError(res, error, "Failed to revoke API key"); diff --git a/server/src/routes/community-mirrors.ts b/server/src/routes/community-mirrors.ts index b442c2d1751..169cf0fbe31 100644 --- a/server/src/routes/community-mirrors.ts +++ b/server/src/routes/community-mirrors.ts @@ -32,7 +32,7 @@ import { isAuthenticatedUserAAOAdmin, type AAOAdminPrincipal } from '../addie/ad import { validateAdagentsDocument } from '../services/adagents-schema-validator.js'; import { registryReadRateLimiter, brandCreationRateLimiter } from '../middleware/rate-limit.js'; import { createLogger } from '../logger.js'; -import { resolveCallerOrgId } from './helpers/resolve-caller-org.js'; +import { resolveCallerOrgId, sendCallerOrganizationAuthError } from './helpers/resolve-caller-org.js'; import { notifyCommunityMirrorProposalReviewed, notifyPendingCommunityMirrorProposal, @@ -116,9 +116,14 @@ async function resolveManager( return userId; } -async function callerOrganizationId(req: Request): Promise { - const attached = (req as Request & { apiKey?: { organizationId?: string } }).apiKey?.organizationId; - return attached ?? resolveCallerOrgId(req); +async function callerOrganizationId(req: Request, res: Response): Promise { + try { + const attached = (req as Request & { apiKey?: { organizationId?: string } }).apiKey?.organizationId; + return attached ?? await resolveCallerOrgId(req); + } catch (error) { + if (sendCallerOrganizationAuthError(error, res)) return undefined; + throw error; + } } function reviewedContentDigest(document: Record): string { @@ -211,7 +216,8 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig) if (reviewQueue && !isManager) { return res.status(403).json({ error: 'Registry moderator access is required for the review queue' }); } - const organizationId = isManager ? null : await callerOrganizationId(req); + const organizationId = isManager ? null : await callerOrganizationId(req, res); + if (organizationId === undefined) return; const limit = req.query.limit ? parseInt(String(req.query.limit), 10) : undefined; const offset = req.query.offset ? parseInt(String(req.query.offset), 10) : undefined; try { @@ -244,7 +250,8 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig) const proposal = await mirrorDb.getProposalById(proposalId); if (!proposal) return res.status(404).json({ error: 'Community mirror proposal not found' }); const isManager = await canManageMirrors(req.user!); - const organizationId = isManager ? null : await callerOrganizationId(req); + const organizationId = isManager ? null : await callerOrganizationId(req, res); + if (organizationId === undefined) return; const ownsProposal = organizationId ? proposal.proposed_by_organization_id === organizationId : proposal.proposed_by_user_id === userId; @@ -458,7 +465,8 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig) const userId = req.user?.id; if (!userId) return res.status(401).json({ error: 'Authentication required' }); const isManager = await canManageMirrors(req.user!); - const organizationId = isManager ? null : await callerOrganizationId(req); + const organizationId = isManager ? null : await callerOrganizationId(req, res); + if (organizationId === undefined) return; if (!isManager && !organizationId) { return res.status(403).json({ error: 'Organization context is required to propose a community mirror' }); } diff --git a/server/src/routes/helpers/resolve-caller-org.ts b/server/src/routes/helpers/resolve-caller-org.ts index ae4cc682b6a..259458e85e8 100644 --- a/server/src/routes/helpers/resolve-caller-org.ts +++ b/server/src/routes/helpers/resolve-caller-org.ts @@ -7,16 +7,23 @@ * against the WorkOS JWKS endpoint. * 2. WorkOS API key (sk_* / wos_api_key_* prefixes) — server-to-server * integrations. Validated via the existing `validateWorkOSApiKey` helper. - * 3. Sealed session — web/native app sessions whose cookie or bearer - * unsealed in `optionalAuth`, producing `req.user`. Organization is - * resolved via `resolvePrimaryOrganization`, which falls back to the - * user's organization_memberships when the cached column is NULL. + * 3. Cookie session — a web session unsealed in `optionalAuth`, producing + * `req.user`. With no Bearer header, the legacy read-model fallback uses + * `resolvePrimaryOrganization` and organization_memberships. + * + * An explicit Bearer must supply its own verified organization. Opaque/native + * session Bearers cannot use `req.user` to select an implicit primary organization. */ -import type { Request } from 'express'; +import type { Request, Response } from 'express'; import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTVerifyGetKey } from 'jose'; -import { isWorkOSApiKeyFormat } from '../../middleware/api-key-format.js'; -import { validateWorkOSApiKey } from '../../middleware/auth.js'; +import { getBearerToken, isWorkOSApiKeyFormat } from '../../middleware/api-key-format.js'; +import { + ConflictingOrganizationSelectionError, + selectedOrganizationForAuthentication, + validateWorkOSApiKey, +} from '../../middleware/auth.js'; +import { isInvalidWorkOSJWTError, unavailableJWTKeyService, WorkOSJWTUnavailableError } from '../../auth/workos-jwt.js'; import { resolvePrimaryOrganization } from '../../db/users-db.js'; import { createLogger } from '../../logger.js'; @@ -44,15 +51,34 @@ function jwksForIssuer(iss: string): { jwks: JWTVerifyGetKey; clientId: string } export type MinimalReq = Pick & { user?: { id?: string } }; +export class CallerOrganizationAuthError extends Error { + constructor(readonly status: 401 | 403 | 503) { + super(status === 401 ? 'Invalid bearer token' + : status === 403 ? 'Conflicting organization selection' + : 'Authorization temporarily unavailable'); + this.name = 'CallerOrganizationAuthError'; + } +} + +/** Preserve authentication failures when callers also handle application errors. */ +export function sendCallerOrganizationAuthError(error: unknown, res: Response): boolean { + if (!(error instanceof CallerOrganizationAuthError)) return false; + res.status(error.status).json({ + error: error.status === 401 ? 'invalid_bearer_token' + : error.status === 403 ? 'organization_selection_conflict' + : 'authorization_unavailable', + }); + return true; +} + /** * Extract and verify a WorkOS OIDC access token. Returns the `org_id` claim * on success, or `null` for API keys, sealed sessions, missing tokens, or - * failed verification. Never throws. + * invalid verification. Key-service and unknown verification failures throw 503. */ export async function orgIdFromBearerJwt(req: MinimalReq): Promise { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) return null; - const token = auth.slice(7); + const token = getBearerToken(req.headers.authorization); + if (!token) return null; if (isWorkOSApiKeyFormat(token)) return null; // Sealed sessions are not JWTs — skip verification to avoid JWKS noise. if (!token.startsWith('eyJ')) return null; @@ -69,40 +95,83 @@ export async function orgIdFromBearerJwt(req: MinimalReq): Promise { - const jwtOrg = await orgIdFromBearerJwt(req); - if (jwtOrg) return jwtOrg; +type ResolvedBearerOrganization = + | { kind: 'verified'; organizationId: string } + | { kind: 'invalid' }; + +/** Each validator skips unsupported formats without making provider calls. */ +async function resolveBearerOrganization(req: MinimalReq): Promise { + try { + const jwtOrg = await orgIdFromBearerJwt(req); + if (jwtOrg) return { kind: 'verified', organizationId: jwtOrg }; + + const apiKey = await validateWorkOSApiKey(req as Request); + if (apiKey) return { kind: 'verified', organizationId: apiKey.organizationId }; - const apiKey = await validateWorkOSApiKey(req as Request); - if (apiKey) return apiKey.organizationId; + return { kind: 'invalid' }; + } catch { + throw new CallerOrganizationAuthError(503); + } +} - if (req.user?.id) { +async function resolveCookieOrganization(userId: string | undefined): Promise { + if (userId) { try { - return await resolvePrimaryOrganization(req.user.id); + return await resolvePrimaryOrganization(userId); } catch (err) { - logger.warn({ err, userId: req.user.id }, 'caller org resolution failed — falling back to public-only'); + logger.warn({ err, userId }, 'caller org resolution failed — falling back to public-only'); } } return null; } +/** + * Resolve the caller's organization from a verified credential result. + * Null is reserved for callers without a bearer; a supplied bearer must + * authorize an organization or fail explicitly. + */ +export async function resolveCallerOrgId(req: MinimalReq): Promise { + const authorization = req.headers.authorization; + if (getBearerToken(authorization) === null) return resolveCookieOrganization(req.user?.id); + + // Capture the credential and selection before any await. Later request + // mutation cannot substitute another key or erase an organization conflict. + const request = req as Request; + const credentialRequest = { + headers: Object.freeze({ ...req.headers, authorization }), + query: Object.freeze({ ...request.query }), + body: Object.freeze({ ...request.body }), + params: Object.freeze({ ...request.params }), + }; + const credential = await resolveBearerOrganization(credentialRequest); + if (credential.kind === 'invalid') throw new CallerOrganizationAuthError(401); + + try { + selectedOrganizationForAuthentication(credentialRequest as Request, credential.organizationId); + } catch (error) { + if (error instanceof ConflictingOrganizationSelectionError) { + throw new CallerOrganizationAuthError(403); + } + throw new CallerOrganizationAuthError(503); + } + return credential.organizationId; +} + /** Test hook: reset the per-client JWKS cache. */ export function __resetJwksForTests(): void { jwksByClient.clear(); diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index d565954f73a..d04bdb39400 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -192,7 +192,7 @@ import { classifyProbeError } from "../utils/probe-error.js"; import { isAuthenticatedUserAAOAdmin, type AAOAdminPrincipal } from "../addie/admin-status-lookup.js"; import { respondToAdminAuthorizationError } from "../auth/admin-authorization-response.js"; import { OrganizationDatabase, hasApiAccess, resolveMembershipTier } from "../db/organization-db.js"; -import { resolveCallerOrgId } from "./helpers/resolve-caller-org.js"; +import { resolveCallerOrgId, sendCallerOrganizationAuthError } from "./helpers/resolve-caller-org.js"; import { canonicalizeAgentUrl, PublisherDatabase } from "../db/publisher-db.js"; import { buildCreativeCapabilities } from "../creative-agent/task-handlers.js"; import { @@ -6128,6 +6128,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { id: saved.id, }); } catch (error) { + if (sendCallerOrganizationAuthError(error, res)) return; logger.error({ error }, "Failed to save property"); return res.status(500).json({ error: "Failed to save property" }); } @@ -6169,6 +6170,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { `Origin verification binds this domain to your organization.`, }); } catch (error) { + if (sendCallerOrganizationAuthError(error, res)) return; logger.error({ error }, 'Failed to issue domain claim'); return res.status(500).json({ error: 'Failed to issue domain claim' }); } @@ -6209,6 +6211,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { } return res.json(outcome); } catch (error) { + if (sendCallerOrganizationAuthError(error, res)) return; logger.error({ error }, 'Origin verification failed'); return res.status(500).json({ error: 'Origin verification failed' }); } @@ -6861,6 +6864,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { res.json({ agents: enriched, count: enriched.length }); } catch (error) { + if (sendCallerOrganizationAuthError(error, res)) return; logger.error({ err: error, path: req.path }, "Failed to list agents"); res.status(500).json({ error: "Failed to list agents" }); } @@ -10168,6 +10172,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { agents, }); } catch (error) { + if (sendCallerOrganizationAuthError(error, res)) return; logger.error({ err: error, path: req.path }, "Operator lookup failed"); res.status(500).json({ error: "Operator lookup failed" }); } diff --git a/server/tests/integration/api-key-exact-credential.test.ts b/server/tests/integration/api-key-exact-credential.test.ts new file mode 100644 index 00000000000..baff1be3b9e --- /dev/null +++ b/server/tests/integration/api-key-exact-credential.test.ts @@ -0,0 +1,523 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import type { Pool } from 'pg'; +import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; + +const mocks = vi.hoisted(() => ({ + fetch: vi.fn(), memberships: vi.fn(), previousSnapshot: undefined as AuthorizationSnapshot | undefined, +})); +vi.mock('@workos-inc/node', () => ({ + WorkOS: class WorkOS { + userManagement = { listOrganizationMemberships: mocks.memberships }; + }, +})); +// Only the authentication and external WorkOS transport are mocked. The route, +// exact-credential lifecycle fence runs against PostgreSQL. Grants are fixture +// data that must never authorize, elevate, or impede key management. +vi.mock('../../src/middleware/auth.js', () => ({ + requireApiKeyManagementAuth(req: express.Request, _res: express.Response, next: express.NextFunction) { + req.user = { + id: 'user_api_key_boundary_primary', + authWorkosUserId: req.get('x-authenticated-user') ?? 'user_api_key_boundary_primary', + email: 'sam@pinnacle.example', + emailVerified: true, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + if (mocks.previousSnapshot) { + Object.defineProperty(req.user, 'authorizationSnapshot', { value: mocks.previousSnapshot, enumerable: false }); + } + next(); + }, +})); + +import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import { createApiKeysRouter } from '../../src/routes/api-keys.js'; +import { loadAuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; +import { bumpAuthorizationEpochs } from '../../src/db/authorization-epoch-db.js'; + +const PRIMARY = 'user_api_key_boundary_primary'; +const LINKED = 'user_api_key_boundary_linked'; +const PINNACLE = 'org_api_key_boundary_pinnacle'; +const STREAMHAUS = 'org_api_key_boundary_streamhaus'; + +describe('API key direct exact credential membership in PostgreSQL', () => { + let pool: Pool; + let identities: string[] = []; + const app = express(); + app.use(express.json()); + app.use('/api/me/api-keys', createApiKeysRouter()); + + async function cleanup() { + await pool.query('DELETE FROM registry_audit_log WHERE workos_user_id = ANY($1)', [[PRIMARY, LINKED]]); + await pool.query('DELETE FROM organization_memberships WHERE workos_user_id = ANY($1)', [[PRIMARY, LINKED]]); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = ANY($1)', [[PINNACLE, STREAMHAUS]]); + await pool.query('DELETE FROM users WHERE workos_user_id = ANY($1)', [[PRIMARY, LINKED]]); + if (identities.length) { + await pool.query('DELETE FROM identities WHERE id = ANY($1::uuid[])', [identities]); + identities = []; + } + } + + beforeAll(async () => { + pool = initializeDatabase({ + connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test', + }); + await runMigrations(); + vi.stubGlobal('fetch', mocks.fetch); + }, 60000); + + afterAll(async () => { + try { + await cleanup(); + } finally { + vi.unstubAllGlobals(); + await closeDatabase(); + } + }); + + beforeEach(async () => { + await cleanup(); + mocks.previousSnapshot = undefined; + mocks.memberships.mockReset().mockResolvedValue({ data: [] }); + mocks.fetch.mockReset().mockImplementation(async (_url, options: RequestInit) => ({ + ok: true, + status: options.method === 'DELETE' ? 204 : 200, + json: async () => ({ data: [] }), + })); + await pool.query( + 'INSERT INTO organizations (workos_organization_id, name) VALUES ($1, $2), ($3, $4)', + [PINNACLE, 'Pinnacle Agency', STREAMHAUS, 'StreamHaus'], + ); + await pool.query( + `INSERT INTO users (workos_user_id, email, primary_organization_id) + VALUES ($1, $2, $3), ($4, $5, $3)`, + [PRIMARY, 'sam@pinnacle.example', PINNACLE, LINKED, 'sam@streamhaus.example'], + ); + const bindings = await pool.query( + 'SELECT workos_user_id, identity_id FROM identity_workos_users WHERE workos_user_id = ANY($1)', + [[PRIMARY, LINKED]], + ); + identities = bindings.rows.map((row) => row.identity_id); + const primaryIdentity = bindings.rows.find((row) => row.workos_user_id === PRIMARY).identity_id; + await pool.query( + 'UPDATE identity_workos_users SET identity_id = $1, is_primary = FALSE WHERE workos_user_id = $2', + [primaryIdentity, LINKED], + ); + }); + + function list(credential: string, organizationId?: string) { + const query = organizationId ? `?org=${organizationId}` : ''; + return request(app).get(`/api/me/api-keys${query}`).set('x-authenticated-user', credential); + } + + function operation(action: 'list' | 'create' | 'revoke', credential: string, organizationId: string) { + if (action === 'list') return list(credential, organizationId); + if (action === 'create') { + return request(app).post(`/api/me/api-keys?org=${organizationId}`) + .set('x-authenticated-user', credential).send({ name: 'Scoped key' }); + } + return request(app).delete(`/api/me/api-keys/key_scoped?org=${organizationId}`) + .set('x-authenticated-user', credential); + } + + function lifecycle(credential: string, organizationId: string) { + return Promise.all((['list', 'create', 'revoke'] as const).map((action) => operation(action, credential, organizationId))); + } + + function barrier() { + let release!: () => void; + const promise = new Promise((resolve) => { release = resolve; }); + return { promise, release }; + } + + async function waitForBlockedBy(blockerPid: number, minimum = 1) { + const deadline = Date.now() + 1_500; + do { + const result = await pool.query<{ pid: number; blockers: number[] }>( + `SELECT pid, pg_blocking_pids(pid) AS blockers FROM pg_stat_activity + WHERE cardinality(pg_blocking_pids(pid)) > 0`, + ); + // A second waiter may queue behind the first tuple-lock waiter rather + // than reporting the original transaction directly as its blocker. + const chain = new Set([blockerPid]); + for (let pass = 0; pass < result.rows.length; pass++) { + for (const row of result.rows) { + if (row.blockers.some((pid) => chain.has(pid))) chain.add(row.pid); + } + } + if (chain.size - 1 >= minimum) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } while (Date.now() < deadline); + throw new Error('Expected a lifecycle operation to wait on the exact credential fence'); + } + + + async function grant(credential: string, organization: string, role = 'admin') { + const result = await pool.query( + `INSERT INTO organization_credential_grants + (workos_user_id, workos_organization_id, role, granted_by_workos_user_id) + VALUES ($1, $2, $3, $4) RETURNING id`, + [credential, organization, role, PRIMARY], + ); + return result.rows[0].id as string; + } + + function allowDirectMembership(role = 'owner') { + mocks.memberships.mockImplementation(async ({ userId, organizationId }) => ({ + data: [{ id: `mem_${userId}`, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), userId, organizationId, status: 'active', role: { slug: role } }], + })); + } + + it.each([ + [PRIMARY, 'identity_credential_deleted'], [LINKED, 'identity_credential_deleted'], + [PRIMARY, 'identity_primary_deletion_quarantined'], [LINKED, 'identity_primary_deletion_quarantined'], + ])('withholds buffered inventory when %s gains a durable %s marker during the read', async (credential, action) => { + allowDirectMembership(); + mocks.fetch.mockResolvedValue({ + ok: true, status: 200, + json: async () => { + await pool.query( + `INSERT INTO registry_audit_log (workos_organization_id, workos_user_id, action, resource_type, resource_id) + VALUES ($1, $2::text, $3, 'user', $2::text)`, [PINNACLE, credential, action], + ); + return { data: [{ id: 'key_withheld_lifecycle_marker', secret: 'withheld-lifecycle-secret' }] }; + }, + }); + const response = await operation('list', credential, PINNACLE); + expect(response.status).toBe(403); + expect(response.text).not.toContain('key_withheld_lifecycle_marker'); + expect(response.text).not.toContain('withheld-lifecycle-secret'); + expect(mocks.fetch).toHaveBeenCalledOnce(); + expect(mocks.fetch.mock.calls[0][1].method).toBe('GET'); + }); + + async function persistedMemberships() { + return (await pool.query( + 'SELECT * FROM organization_memberships WHERE workos_user_id = ANY($1) ORDER BY workos_user_id', + [[PRIMARY, LINKED]], + )).rows; + } + + async function changeGrant(id: string, state: 'revoked' | 'expired' | 'future') { + if (state === 'revoked') { + await pool.query( + 'UPDATE organization_credential_grants SET revoked_at = NOW(), revoked_by_workos_user_id = $2 WHERE id = $1', + [id, PRIMARY], + ); + return; + } + await pool.query( + `UPDATE organization_credential_grants + SET effective_from = NOW() + $2::interval, + effective_until = NOW() + $3::interval WHERE id = $1`, + [id, state === 'expired' ? '-2 days' : '1 day', state === 'expired' ? '-1 day' : '2 days'], + ); + } + + it.each([ + [PRIMARY, PINNACLE, LINKED, STREAMHAUS], + [LINKED, STREAMHAUS, PRIMARY, PINNACLE], + ])('never lets %s manage keys through its own or its sibling credential grant', async (credential, organization, sibling, siblingOrganization) => { + await grant(credential, organization, 'owner'); + await grant(sibling, siblingOrganization); + const before = await persistedMemberships(); + + expect((await lifecycle(credential, organization)).map((result) => result.status)).toEqual([403, 403, 403]); + expect((await lifecycle(credential, siblingOrganization)).map((result) => result.status)).toEqual([403, 403, 403]); + expect((await list(credential)).status).toBe(400); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(await persistedMemberships()).toEqual(before); + }); + + it.each(['admin', 'owner'])('does not elevate a direct member through an exact %s grant', async (grantRole) => { + await grant(LINKED, STREAMHAUS, grantRole); + allowDirectMembership('member'); + + expect((await lifecycle(LINKED, STREAMHAUS)).map((result) => result.status)).toEqual([403, 403, 403]); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(await persistedMemberships()).toEqual([]); + }); + + it.each(['absent', 'admin', 'owner', 'member', 'revoked', 'expired', 'future'] as const)( + 'permits direct admin reads and contains mutations independently of %s grants', async (state) => { + if (state !== 'absent') { + const id = await grant(LINKED, STREAMHAUS, ['member', 'owner'].includes(state) ? state : 'admin'); + // Authentication may have captured an active grant. Changing that grant + // must not revoke the credential's independent direct WorkOS membership. + mocks.previousSnapshot = (await loadAuthorizationSnapshot(LINKED, STREAMHAUS))!; + if (state === 'revoked' || state === 'expired' || state === 'future') await changeGrant(id, state); + } + allowDirectMembership('admin'); + const before = await persistedMemberships(); + + expect((await lifecycle(LINKED, STREAMHAUS)).map((result) => result.status)).toEqual([200, 503, 503]); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + expect(await persistedMemberships()).toEqual(before); + }, + ); + + it('does not query unavailable grant storage for direct active membership', async () => { + await grant(LINKED, STREAMHAUS); + mocks.previousSnapshot = (await loadAuthorizationSnapshot(LINKED, STREAMHAUS))!; + allowDirectMembership(); + const blocker = await pool.connect(); + try { + await blocker.query('BEGIN'); + // Any grant-table read would block until the route's authorization query + // times out. The route must complete while this lock remains held. + await blocker.query('LOCK TABLE organization_credential_grants IN ACCESS EXCLUSIVE MODE'); + expect((await lifecycle(LINKED, STREAMHAUS)).map((result) => result.status)).toEqual([200, 503, 503]); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + } finally { + await blocker.query('ROLLBACK'); + blocker.release(); + } + expect(await persistedMemberships()).toEqual([]); + }); + + it('returns unavailable without secret effects when WorkOS fails despite an active exact owner grant', async () => { + await grant(LINKED, STREAMHAUS, 'owner'); + mocks.memberships.mockRejectedValue(new Error('WorkOS temporarily unavailable')); + const before = await persistedMemberships(); + + expect((await lifecycle(LINKED, STREAMHAUS)).map((result) => result.status)).toEqual([503, 503, 503]); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(await persistedMemberships()).toEqual(before); + }); + + it.each([ + [PRIMARY, PINNACLE, LINKED], + [LINKED, STREAMHAUS, PRIMARY], + ])('does not reuse %s canonical sibling, cached membership, or primary organization', async (credential, organization, sibling) => { + await pool.query( + `INSERT INTO organization_memberships + (workos_user_id, workos_organization_id, workos_membership_id, email, role) + VALUES ($1, $2, $3, $4, 'owner')`, + [credential, organization, 'mem_api_key_boundary_stale', 'sam@streamhaus.example'], + ); + await grant(credential, organization, 'owner'); + mocks.memberships.mockResolvedValue({ + data: [{ userId: sibling, organizationId: organization, status: 'active', role: { slug: 'owner' } }], + }); + const before = await persistedMemberships(); + + expect((await lifecycle(credential, organization)).map((result) => result.status)).toEqual([403, 403, 403]); + expect((await list(credential)).status).toBe(400); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(await persistedMemberships()).toEqual(before); + expect((await pool.query( + 'SELECT primary_organization_id FROM users WHERE workos_user_id = $1', [credential], + )).rows[0].primary_organization_id).toBe(PINNACLE); + }); + + it.each([PRIMARY, LINKED])('denies replayed identity epoch for %s even when fresh WorkOS membership could authorize', async (credential) => { + mocks.previousSnapshot = (await loadAuthorizationSnapshot(credential, STREAMHAUS))!; + allowDirectMembership(); + expect((await lifecycle(credential, STREAMHAUS)).map((result) => result.status)).toEqual([200, 503, 503]); + await bumpAuthorizationEpochs(pool, [credential]); + mocks.memberships.mockClear(); + mocks.fetch.mockClear(); + + expect((await lifecycle(credential, STREAMHAUS)).map((result) => result.status)).toEqual([403, 403, 403]); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(await persistedMemberships()).toEqual([]); + }); + + it.each((['list', 'create', 'revoke'] as const).flatMap((action) => + [PRIMARY, LINKED].map((credential) => ({ action, credential })), + ))('rechecks $credential after $action waits behind an authoritative epoch mutation', async ({ action, credential }) => { + await bumpAuthorizationEpochs(pool, [credential]); + mocks.previousSnapshot = (await loadAuthorizationSnapshot(credential, STREAMHAUS))!; + allowDirectMembership(); + const membershipChecked = barrier(); + mocks.memberships.mockImplementation(async ({ userId, organizationId }) => { + membershipChecked.release(); + return { data: [{ id: `mem_${userId}`, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), userId, organizationId, status: 'active', role: { slug: 'owner' } }] }; + }); + const mutation = await pool.connect(); + let pending: Promise | undefined; + try { + await mutation.query('BEGIN'); + await mutation.query('SELECT workos_user_id FROM users WHERE workos_user_id = $1 FOR UPDATE', [credential]); + const pid = (await mutation.query('SELECT pg_backend_pid() AS pid')).rows[0].pid as number; + pending = operation(action, credential, STREAMHAUS).then((response) => response); + await Promise.race([ + membershipChecked.promise, + pending.then(() => { throw new Error('Request completed before direct membership validation'); }), + ]); + // The initial direct lookup has completed. The provider operation must + // wait for the authoritative row lock and recheck after its release. + await waitForBlockedBy(pid); + expect(mocks.fetch).not.toHaveBeenCalled(); + await bumpAuthorizationEpochs(mutation, [credential]); + await mutation.query('COMMIT'); + expect((await pending).status).toBe(403); + expect(mocks.fetch).not.toHaveBeenCalled(); + } finally { + await mutation.query('ROLLBACK'); + mutation.release(); + await pending; + } + }); + + it.each((['list'] as const).flatMap((action) => + [PRIMARY, LINKED].flatMap((credential) => + (['existing', 'absent'] as const).map((epochState) => ({ action, credential, epochState }))), + ))('holds the exact $credential $epochState epoch fence throughout the $action provider effect', async ({ action, credential, epochState }) => { + if (epochState === 'existing') await bumpAuthorizationEpochs(pool, [credential]); + allowDirectMembership(); + const providerEntered = barrier(); + const providerReleased = barrier(); + mocks.fetch.mockImplementation(async (_url, options: RequestInit) => { + providerEntered.release(); + await providerReleased.promise; + return { + ok: true, + status: options.method === 'DELETE' ? 204 : 200, + json: async () => ({ data: [] }), + }; + }); + const mutation = await pool.connect(); + let mutationPending: Promise | undefined; + const pending = operation(action, credential, STREAMHAUS).then((response) => response); + try { + await Promise.race([ + providerEntered.promise, + pending.then(() => { throw new Error('Request completed before the provider effect'); }), + ]); + await mutation.query('BEGIN'); + const pid = (await mutation.query('SELECT pg_backend_pid() AS pid')).rows[0].pid as number; + mutationPending = bumpAuthorizationEpochs(mutation, [credential]); + const deadline = Date.now() + 1_500; + let blockingPids: number[] = []; + do { + blockingPids = (await pool.query('SELECT pg_blocking_pids($1) AS pids', [pid])).rows[0].pids; + if (blockingPids.length) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } while (Date.now() < deadline); + expect(blockingPids.length).toBeGreaterThan(0); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + providerReleased.release(); + expect((await pending).status).toBe(200); + await mutationPending; + await mutation.query('COMMIT'); + } finally { + providerReleased.release(); + await pending; + await mutationPending; + await mutation.query('ROLLBACK'); + mutation.release(); + } + }); + + it('serializes linked and primary key management without taking binding locks in opposite order', async () => { + allowDirectMembership(); + const identity = (await pool.query( + 'SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1', [PRIMARY], + )).rows[0].identity_id; + const blocker = await pool.connect(); + let linked: Promise | undefined; + let primary: Promise | undefined; + try { + await blocker.query('BEGIN'); + await blocker.query('SELECT id FROM identities WHERE id = $1 FOR UPDATE', [identity]); + const pid = (await blocker.query('SELECT pg_backend_pid() AS pid')).rows[0].pid as number; + linked = list(LINKED, STREAMHAUS).then((response) => response); + await waitForBlockedBy(pid); + primary = list(PRIMARY, PINNACLE).then((response) => response); + await waitForBlockedBy(pid, 2); + // LINKED is first in the identity-lock queue. PRIMARY must not already + // own the primary binding that LINKED will need after this lock opens. + await blocker.query('COMMIT'); + expect((await Promise.all([linked, primary])).map((response) => response.status)).toEqual([200, 200]); + expect(mocks.fetch).toHaveBeenCalledTimes(2); + } finally { + await blocker.query('ROLLBACK'); + blocker.release(); + await Promise.all([linked, primary]); + } + }); + + it.each([PRIMARY, LINKED].flatMap((credential) => + (['headers', 'body'] as const).flatMap((phase) => + (['revoked', 'member', 'admin', 'outage'] as const).map((change) => ({ credential, phase, change }))), + ))('withholds $credential inventory after external $change during provider $phase', async ({ credential, phase, change }) => { + let current: 'owner' | 'revoked' | 'member' | 'admin' | 'outage' = 'owner'; + const entered = barrier(); + const released = barrier(); + mocks.memberships.mockImplementation(async ({ userId, organizationId }) => { + if (current === 'outage') throw new Error('Membership provider unavailable'); + return { data: current === 'revoked' ? [] : [{ + id: `mem_${userId}`, userId, organizationId, status: 'active', role: { slug: current }, + createdAt: new Date(0).toISOString(), updatedAt: new Date(current === 'owner' ? 0 : 1).toISOString(), + }] }; + }); + mocks.fetch.mockImplementation(async () => { + if (phase === 'headers') { entered.release(); await released.promise; } + return { ok: true, status: 200, json: async () => { + if (phase === 'body') { entered.release(); await released.promise; } + return { data: [{ id: 'key_must_not_escape', value: 'secret_must_not_escape' }] }; + } }; + }); + let completed = false; + const pending = list(credential, STREAMHAUS).then((response) => { completed = true; return response; }); + try { + await Promise.race([entered.promise, pending.then(() => { throw new Error('Read missed barrier'); })]); + expect(completed).toBe(false); + current = change; + released.release(); + const response = await pending; + expect(response.status).toBe(change === 'outage' ? 503 : 403); + expect(response.text).not.toContain('key_must_not_escape'); + expect(response.text).not.toContain('secret_must_not_escape'); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + expect(mocks.fetch.mock.calls[0][1].method).toBe('GET'); + expect(mocks.memberships).toHaveBeenCalledTimes(3); + expect(await persistedMemberships()).toEqual([]); + } finally { + released.release(); + await pending; + } + }); + + it.each([PRIMARY, LINKED].flatMap((credential) => + (['create', 'revoke'] as const).flatMap((action) => + (['revoked', 'admin'] as const).map((change) => ({ credential, action, change }))), + ))('contains $credential $action even after the final provider lookup races with $change', async ({ credential, action, change }) => { + const entered = barrier(); + const released = barrier(); + let changed = false; + let calls = 0; + mocks.memberships.mockImplementation(async ({ userId, organizationId }) => { + // Capture the provider's old response before the external mutation. The + // second lookup deliberately cannot see the change; containment must + // still prevent a key mutation without pretending this read is atomic. + const data = changed && change === 'revoked' ? [] : [{ + id: `mem_${userId}`, userId, organizationId, status: 'active', + role: { slug: changed ? 'admin' : 'owner' }, + createdAt: new Date(0).toISOString(), updatedAt: new Date(changed ? 1 : 0).toISOString(), + }]; + if (++calls === 2) { entered.release(); await released.promise; } + return { data }; + }); + const pending = operation(action, credential, STREAMHAUS).then((response) => response); + try { + await Promise.race([entered.promise, pending.then(() => { throw new Error('Mutation missed barrier'); })]); + changed = true; + released.release(); + const response = await pending; + expect(response.status).toBe(503); + expect(response.body.error).toBe('API key mutations unavailable'); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(await persistedMemberships()).toEqual([]); + } finally { + released.release(); + await pending; + } + }); + +}); diff --git a/server/tests/integration/api-key-management-auth.test.ts b/server/tests/integration/api-key-management-auth.test.ts new file mode 100644 index 00000000000..344129e90c6 --- /dev/null +++ b/server/tests/integration/api-key-management-auth.test.ts @@ -0,0 +1,416 @@ +/** Mounted authentication and lifecycle authorization must never consult grants. */ +import express from 'express'; +import request from 'supertest'; +import { once } from 'node:events'; +import { request as httpRequest } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkOSUser } from '../../src/types.js'; + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), loadSealedSession: vi.fn(), memberships: vi.fn(), + verifyWorkOSJWT: vi.fn(), fetch: vi.fn(), checkPlatformBan: vi.fn(), createValidation: vi.fn(), +})); +vi.mock('@workos-inc/node', () => ({ + WorkOS: class WorkOS { + userManagement = { + loadSealedSession: mocks.loadSealedSession, + listOrganizationMemberships: mocks.memberships, + }; + apiKeys = { createValidation: mocks.createValidation }; + }, +})); +vi.mock('../../src/db/bans-db.js', () => ({ + bansDb: { checkPlatformBan: mocks.checkPlatformBan, checkPlatformBanForApiKey: vi.fn() }, +})); +vi.mock('../../src/auth/workos-jwt.js', async (importOriginal) => ({ + ...await importOriginal(), + verifyWorkOSJWT: mocks.verifyWorkOSJWT, +})); + +// Authentication, identity/epoch hydration, route authorization and its locks +// are real. Only remote authentication, membership and key transport are mocked. +import { closeDatabase, initializeDatabase } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import { invalidateBanCache, requireAuth, stopAuthTimers } from '../../src/middleware/auth.js'; +import { createApiKeysRouter } from '../../src/routes/api-keys.js'; + +const PRIMARY = 'user_api_key_auth_primary'; +const CREDENTIAL = 'user_api_key_auth_linked'; +const ORGANIZATION = 'org_api_key_auth_pinnacle'; +const PROVIDER_USER = { + id: CREDENTIAL, email: 'sam@pinnacle.example', emailVerified: true, + firstName: 'Sam', lastName: 'Adeyemi', + createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), +}; + +let sequence = 0; + +describe('API key management mounted authentication without grant storage', () => { + let pool: Pool; + let identityIds: string[] = []; + let capturedUsers: WorkOSUser[] = []; + const app = express(); + app.use(express.json()); + app.use((req, res, next) => { + // Explicit parsed cookies avoid introducing a separate cookie/CSRF harness. + const cookie = req.headers.cookie; + req.cookies = cookie?.startsWith('wos-session=') + ? { 'wos-session': cookie.slice('wos-session='.length) } : {}; + res.on('finish', () => { + if (req.user) capturedUsers.push(req.user); + }); + next(); + }); + app.use('/api/me/api-keys', createApiKeysRouter()); + app.use('/dashboard-api-keys-control', createApiKeysRouter()); + app.get('/api/default-auth-control', requireAuth, (_req, res) => res.json({ allowed: true })); + + async function rawDelete(path: string, headers: Record) { + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + return await new Promise((resolve, reject) => { + // Preserve the wire path: URL-aware clients normalize dot segments + // before Express can decode and authorize the actual supplied ID. + const req = httpRequest({ + hostname: '127.0.0.1', port: (server.address() as AddressInfo).port, + method: 'DELETE', path, headers, + }, (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }); + req.on('error', reject); + req.end(); + }); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + } + + function credentialHeaders(kind: 'cookie' | 'JWT', token: string): Record { + return kind === 'cookie' ? { Cookie: `wos-session=${token}` } : { Authorization: `bEaReR\t${token}` }; + } + + function expectExactMembershipLookups(count: number) { + expect(mocks.memberships.mock.calls).toEqual(Array.from({ length: count }, () => [{ + userId: CREDENTIAL, organizationId: ORGANIZATION, + }])); + } + + async function cleanup() { + await pool.query('DELETE FROM registry_audit_log WHERE workos_user_id = ANY($1)', [[PRIMARY, CREDENTIAL]]); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [ORGANIZATION]); + await pool.query('DELETE FROM users WHERE workos_user_id = ANY($1)', [[PRIMARY, CREDENTIAL]]); + if (identityIds.length) { + await pool.query('DELETE FROM identities WHERE id = ANY($1::uuid[])', [identityIds]); + identityIds = []; + } + } + + beforeAll(async () => { + pool = initializeDatabase({ + connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test', + }); + await runMigrations(); + vi.stubGlobal('fetch', mocks.fetch); + }, 60_000); + + afterAll(async () => { + try { + await cleanup(); + } finally { + stopAuthTimers(); + vi.unstubAllGlobals(); + await closeDatabase(); + } + }); + + beforeEach(async () => { + await cleanup(); + vi.clearAllMocks(); + capturedUsers = []; + invalidateBanCache('user', CREDENTIAL); + mocks.checkPlatformBan.mockResolvedValue({ banned: false }); + mocks.createValidation.mockResolvedValue({ apiKey: null }); + mocks.authenticate.mockResolvedValue({ + authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token', organizationId: ORGANIZATION, + }); + mocks.loadSealedSession.mockReturnValue({ authenticate: mocks.authenticate }); + mocks.verifyWorkOSJWT.mockResolvedValue({ + sub: CREDENTIAL, email: PROVIDER_USER.email, isM2M: false, orgId: ORGANIZATION, + }); + mocks.memberships.mockImplementation(async ({ userId, organizationId }) => ({ + data: [{ id: `mem_${userId}`, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), userId, organizationId, status: 'active', role: { slug: 'admin' } }], + })); + mocks.fetch.mockImplementation(async (_url, options: RequestInit) => ({ + ok: true, status: options.method === 'DELETE' ? 204 : 200, + json: async () => ({ data: [] }), + })); + await pool.query( + 'INSERT INTO organizations (workos_organization_id, name) VALUES ($1, $2)', + [ORGANIZATION, 'Pinnacle Agency'], + ); + await pool.query( + `INSERT INTO users (workos_user_id, email, primary_organization_id) + VALUES ($1, $2, $3), ($4, $5, $3)`, + [PRIMARY, 'sam-primary@pinnacle.example', ORGANIZATION, CREDENTIAL, PROVIDER_USER.email], + ); + const bindings = await pool.query( + 'SELECT workos_user_id, identity_id FROM identity_workos_users WHERE workos_user_id = ANY($1)', + [[PRIMARY, CREDENTIAL]], + ); + identityIds = bindings.rows.map((row) => row.identity_id); + const primaryIdentity = bindings.rows.find((row) => row.workos_user_id === PRIMARY).identity_id; + await pool.query( + 'UPDATE identity_workos_users SET identity_id = $1, is_primary = FALSE WHERE workos_user_id = $2', + [primaryIdentity, CREDENTIAL], + ); + await pool.query( + `INSERT INTO organization_credential_grants + (workos_user_id, workos_organization_id, role, granted_by_workos_user_id) + VALUES ($1, $2, 'owner', $3)`, [CREDENTIAL, ORGANIZATION, PRIMARY], + ); + }); + + it.each(['cookie', 'JWT'] as const)('denies terminal lifecycle states before effects for cold and warm %s credentials', async (kind) => { + for (const state of ['identity_credential_deleted', 'identity_primary_deletion_quarantined', 'missing binding', 'missing primary']) { + for (const warm of [false, true]) { + const token = kind === 'cookie' ? `sealed-lifecycle-${++sequence}` : `header.lifecycle${++sequence}.signature`; + const headers = credentialHeaders(kind, token); + if (warm) { + expect((await request(app).get(`/api/me/api-keys?org=${ORGANIZATION}`).set(headers)).status).toBe(200); + } + if (state === 'missing binding') { + await pool.query('DELETE FROM identity_workos_users WHERE workos_user_id = $1', [CREDENTIAL]); + } else if (state === 'missing primary') { + await pool.query('UPDATE identity_workos_users SET is_primary = FALSE WHERE workos_user_id = $1', [PRIMARY]); + } else { + await pool.query( + `INSERT INTO registry_audit_log (workos_organization_id, workos_user_id, action, resource_type, resource_id) + VALUES ($1, $2::text, $3, 'user', $2::text)`, [ORGANIZATION, CREDENTIAL, state], + ); + } + vi.clearAllMocks(); + capturedUsers = []; + const listed = await request(app).get(`/api/me/api-keys?org=${ORGANIZATION}`).set(headers); + const created = await request(app).post(`/api/me/api-keys?org=${ORGANIZATION}`).set(headers).send({ name: 'Scoped key' }); + const revoked = await request(app).delete(`/api/me/api-keys/key_exact?org=${ORGANIZATION}`).set(headers); + expect([listed.status, created.status, revoked.status]).toEqual([401, 401, 401]); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(capturedUsers).toEqual([]); + await pool.query('DELETE FROM registry_audit_log WHERE workos_user_id = $1', [CREDENTIAL]); + await pool.query('UPDATE identity_workos_users SET is_primary = TRUE WHERE workos_user_id = $1', [PRIMARY]); + if (state === 'missing binding') { + await pool.query( + `INSERT INTO identity_workos_users (identity_id, workos_user_id, is_primary) + SELECT identity_id, $2, FALSE FROM identity_workos_users WHERE workos_user_id = $1`, [PRIMARY, CREDENTIAL], + ); + } + } + } + }); + + it.each(['identity_credential_deleted', 'identity_primary_deletion_quarantined'])( + 'retains %s denial after stale provider recreation of a warm credential', async action => { + const headers = credentialHeaders('cookie', `sealed-recreation-${++sequence}`); + expect((await request(app).get(`/api/me/api-keys?org=${ORGANIZATION}`).set(headers)).status).toBe(200); + await pool.query( + `INSERT INTO registry_audit_log (workos_organization_id, workos_user_id, action, resource_type, resource_id) + VALUES ($1, $2::text, $3, 'user', $2::text)`, [ORGANIZATION, CREDENTIAL, action], + ); + await pool.query('DELETE FROM users WHERE workos_user_id = $1', [CREDENTIAL]); + await pool.query('INSERT INTO users (workos_user_id, email) VALUES ($1, $2)', [CREDENTIAL, PROVIDER_USER.email]); + const recreated = await pool.query('SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1', [CREDENTIAL]); + identityIds.push(recreated.rows[0].identity_id); + vi.clearAllMocks(); + expect((await request(app).get(`/api/me/api-keys?org=${ORGANIZATION}`).set(headers)).status).toBe(401); + expect(mocks.authenticate).not.toHaveBeenCalled(); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }, + ); + + it.each(['cookie', 'JWT'] as const)('permits cold and warm %s listing and contains mutations while grant storage is inaccessible', async (kind) => { + const token = kind === 'cookie' ? `sealed-management-${++sequence}` : `header.management${++sequence}.signature`; + const authenticate = (req: request.Test) => kind === 'cookie' + ? req.set('Cookie', `wos-session=${token}`) : req.set('Authorization', `Bearer ${token}`); + const blocker = await pool.connect(); + try { + await blocker.query('BEGIN'); + await blocker.query('LOCK TABLE organization_credential_grants IN ACCESS EXCLUSIVE MODE'); + for (const _cacheState of ['cold', 'warm']) { + const listed = await authenticate(request(app).get(`/api/me/api-keys?org=${ORGANIZATION}`)); + const created = await authenticate(request(app).post(`/api/me/api-keys?org=${ORGANIZATION}`)).send({ name: 'Scoped key' }); + const revoked = await authenticate(request(app).delete(`/api/me/api-keys/key_exact?org=${ORGANIZATION}`)); + expect([listed.status, created.status, revoked.status]).toEqual([200, 503, 503]); + } + expect(mocks.fetch).toHaveBeenCalledTimes(2); + expect(mocks.memberships.mock.calls.every(([selection]) => + selection.userId === CREDENTIAL && selection.organizationId === ORGANIZATION, + )).toBe(true); + expect(capturedUsers).toHaveLength(6); + expect(new Set(capturedUsers).size).toBe(6); + for (const user of capturedUsers) { + expect(user.id).toBe(PRIMARY); + expect(user.authWorkosUserId).toBe(CREDENTIAL); + expect(user.authorizationSnapshot?.credentialGrant).toBeNull(); + expect(user.authorizationSnapshot?.authenticatedUserId).toBe(CREDENTIAL); + } + if (kind === 'cookie') expect(mocks.authenticate).toHaveBeenCalledOnce(); + else expect(mocks.verifyWorkOSJWT).toHaveBeenCalledOnce(); + } finally { + await blocker.query('ROLLBACK'); + blocker.release(); + } + }); + + it.each([ + ['invalid API key', 'bEaReR\tsk_invalid'], + ['revoked API key', 'BEARER wos_api_key_revoked'], + ['invalid JWT', 'Bearer header.invalid.signature'], + ])('keeps %s terminal despite a valid attached cookie', async (_kind, authorization) => { + mocks.verifyWorkOSJWT.mockRejectedValue(Object.assign(new Error('Invalid JWT signature'), { + code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + })); + const authenticate = (req: request.Test) => req + .set('Authorization', authorization).set('Cookie', `wos-session=valid-management-${++sequence}`); + const listed = await authenticate(request(app).get(`/api/me/api-keys?org=${ORGANIZATION}`)); + const created = await authenticate(request(app).post(`/api/me/api-keys?org=${ORGANIZATION}`)).send({ name: 'Scoped key' }); + const revoked = await authenticate(request(app).delete(`/api/me/api-keys/key_exact?org=${ORGANIZATION}`)); + expect([listed.status, created.status, revoked.status]).toEqual([401, 401, 401]); + expect(mocks.authenticate).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(capturedUsers).toEqual([]); + }); + + it.each(['cookie', 'JWT'] as const)('authorizes exact cold/warm %s credentials before rejecting non-opaque wire IDs', async (kind) => { + const token = kind === 'cookie' ? `sealed-path-${++sequence}` : `header.path${++sequence}.signature`; + const headers = credentialHeaders(kind, token); + const keyPaths = [ + '.', '..', '%2e', '%2E%2e', '.%2E', '%2e./', + '%2e%2e%2fkey_other', '..%5Ckey_other', 'key_%2f..%2fkey_other', + '%252e%252e%252fkey_other', '%255c', 'key_%25', + '%2F%2Fexample.test', 'https%3A%2F%2Fexample.test', + 'key_value%3Fother=value', 'key_value%23fragment', + 'key_value%00', 'key_value%09', 'key_value%0A', 'key_value%0D', + 'key_value%EF%BC%8Fother', + ]; + for (const keyPath of keyPaths) { + mocks.memberships.mockClear(); + expect(await rawDelete(`/API/ME/API-KEYS/${keyPath}?org=${ORGANIZATION}`, headers)).toBe(400); + expectExactMembershipLookups(2); + expect(mocks.fetch).not.toHaveBeenCalled(); + } + expect(capturedUsers).toHaveLength(keyPaths.length); + for (const user of capturedUsers) { + expect(user.id).toBe(PRIMARY); + expect(user.authWorkosUserId).toBe(CREDENTIAL); + expect(user.authorizationSnapshot?.credentialGrant).toBeNull(); + } + if (kind === 'cookie') expect(mocks.authenticate).toHaveBeenCalledOnce(); + else expect(mocks.verifyWorkOSJWT).toHaveBeenCalledOnce(); + }); + + it.each([ + ['/api/me/api-keys', 'application/json', 401], + ['/api/me/api-keys', 'text/html', 401], + ['/dashboard-api-keys-control', 'text/html', 302], + ] as const)('terminates credential absence for %s accepting %s before management hydration or effects', async (prefix, accept, status) => { + const path = `${prefix}?org=${ORGANIZATION}`; + for (const method of ['get', 'post'] as const) { + const response = await request(app)[method](path).set('Accept', accept).send({ name: 'Scoped key' }); + expect(response.status).toBe(status); + if (status === 302) { + expect(response.headers.location).toBe(`/auth/login?return_to=${encodeURIComponent(path)}`); + } else { + expect(response.body.error).toBe('Authentication required'); + } + } + expect(await rawDelete(`${prefix}/%2E%2E?org=${ORGANIZATION}`, { Accept: accept })).toBe(status); + expect(capturedUsers).toEqual([]); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.authenticate).not.toHaveBeenCalled(); + expect(mocks.verifyWorkOSJWT).not.toHaveBeenCalled(); + expect(mocks.createValidation).not.toHaveBeenCalled(); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(['cookie', 'JWT'] as const)('rejects non-opaque %s organization selectors before management lookups or effects', async (kind) => { + for (const organizationId of ['.', '..', '%2e%2e', 'org_target/other', 'org_target\\other', + 'org_target?other=value', 'org_target#fragment', 'org_target\u0000', 'org_target\t', 'org_target\uff0fother']) { + // Let the real authentication selection agree, so the route must enforce + // its own opaque-segment boundary rather than relying on a conflict. + mocks.authenticate.mockResolvedValue({ + authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token', organizationId, + }); + mocks.verifyWorkOSJWT.mockResolvedValue({ + sub: CREDENTIAL, email: PROVIDER_USER.email, isM2M: false, orgId: organizationId, + }); + const token = kind === 'cookie' ? `sealed-org-path-${++sequence}` : `header.orgpath${++sequence}.signature`; + const headers = credentialHeaders(kind, token); + const path = `/api/me/api-keys?org=${encodeURIComponent(organizationId)}`; + const listed = await request(app).get(path).set(headers); + const created = await request(app).post(path).set(headers).send({ name: 'Scoped key' }); + const revoked = await rawDelete(`/api/me/api-keys/key_exact?org=${encodeURIComponent(organizationId)}`, headers); + expect([listed.status, created.status, revoked]).toEqual([400, 400, 400]); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + } + }); + + it.each(['cookie', 'JWT'] as const)('preserves exact %s denial and uncertainty before non-opaque ID rejection', async (kind) => { + const token = kind === 'cookie' ? `sealed-path-denial-${++sequence}` : `header.pathdenial${++sequence}.signature`; + const headers = credentialHeaders(kind, token); + mocks.memberships.mockResolvedValueOnce({ data: [] }); + expect(await rawDelete(`/api/me/api-keys/%2e%2e%2fkey_other?org=${ORGANIZATION}`, headers)).toBe(403); + expectExactMembershipLookups(1); + mocks.memberships.mockClear(); + mocks.memberships.mockRejectedValueOnce(new Error('Membership provider unavailable')); + expect(await rawDelete(`/api/me/api-keys/%2e%2e%2fkey_other?org=${ORGANIZATION}`, headers)).toBe(503); + expectExactMembershipLookups(1); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(['cookie', 'JWT'] as const)('lists the fixed organization subresource for %s but never dispatches accepted mutations', async (kind) => { + const token = kind === 'cookie' ? `sealed-path-control-${++sequence}` : `header.pathcontrol${++sequence}.signature`; + const headers = credentialHeaders(kind, token); + const listed = await request(app).get(`/api/me/api-keys?org=${ORGANIZATION}`).set(headers); + expect(listed.status).toBe(200); + expect(mocks.fetch).toHaveBeenCalledOnce(); + const [url, options] = mocks.fetch.mock.calls[0]; + expect(new URL(url).origin).toBe('https://api.workos.com'); + expect(new URL(url).pathname).toBe(`/organizations/${ORGANIZATION}/api_keys`); + expect(options.method).toBe('GET'); + mocks.fetch.mockClear(); + const created = await request(app).post(`/api/me/api-keys?org=${ORGANIZATION}`) + .set(headers).send({ name: 'Scoped key' }); + expect(created.status).toBe(503); + for (const keyPath of ['api_key_01AZaz09_-', '%6bey_valid/']) { + expect(await rawDelete(`/api/me/api-keys/${keyPath}?org=${ORGANIZATION}`, headers)).toBe(503); + } + expectExactMembershipLookups(9); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it('keeps grant-bearing default authentication separate from the management route policy', async () => { + const blocker = await pool.connect(); + try { + await blocker.query('BEGIN'); + await blocker.query('LOCK TABLE organization_credential_grants IN ACCESS EXCLUSIVE MODE'); + const response = await request(app).get(`/api/default-auth-control?org=${ORGANIZATION}`) + .set('Cookie', `wos-session=default-management-control-${++sequence}`); + expect(response.status).toBe(503); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + } finally { + await blocker.query('ROLLBACK'); + blocker.release(); + } + }); +}); diff --git a/server/tests/integration/content-my-content.test.ts b/server/tests/integration/content-my-content.test.ts index d6f9f7922a7..bcd5dbe1190 100644 --- a/server/tests/integration/content-my-content.test.ts +++ b/server/tests/integration/content-my-content.test.ts @@ -36,6 +36,7 @@ vi.mock('../../src/middleware/auth.js', () => { const requireAuthMock = (req: any, _res: any, next: any) => { setTestUser(req); next(); }; return { requireAuth: requireAuthMock, + requireApiKeyManagementAuth: requireAuthMock, requireAdmin: passthrough, requireTenantAdminForOrganization: passthrough, optionalAuth: (req: any, _res: any, next: any) => { setTestUser(req); next(); }, diff --git a/server/tests/integration/event-speakers.test.ts b/server/tests/integration/event-speakers.test.ts index 6c9e0d3705c..0a19a7ba00b 100644 --- a/server/tests/integration/event-speakers.test.ts +++ b/server/tests/integration/event-speakers.test.ts @@ -27,6 +27,7 @@ vi.mock('../../src/middleware/auth.js', () => { const requireAuthMock = (req: any, _res: any, next: any) => { setTestUser(req); next(); }; return { requireAuth: requireAuthMock, + requireApiKeyManagementAuth: requireAuthMock, requireAdmin: passthrough, requireTenantAdminForOrganization: passthrough, optionalAuth: (req: any, _res: any, next: any) => { setTestUser(req); next(); }, diff --git a/server/tests/integration/events-draft-preview.test.ts b/server/tests/integration/events-draft-preview.test.ts index 831c4ab0def..fe4193ea5cc 100644 --- a/server/tests/integration/events-draft-preview.test.ts +++ b/server/tests/integration/events-draft-preview.test.ts @@ -28,6 +28,7 @@ vi.mock('../../src/middleware/auth.js', () => { const requireAuthMock = (req: any, _res: any, next: any) => { setTestUser(req); next(); }; return { requireAuth: requireAuthMock, + requireApiKeyManagementAuth: requireAuthMock, requireAdmin: passthrough, requireTenantAdminForOrganization: passthrough, optionalAuth: (req: any, _res: any, next: any) => { setTestUser(req); next(); }, diff --git a/server/tests/integration/perspective-assets.test.ts b/server/tests/integration/perspective-assets.test.ts index 1440a855824..ab6e03cce61 100644 --- a/server/tests/integration/perspective-assets.test.ts +++ b/server/tests/integration/perspective-assets.test.ts @@ -35,6 +35,7 @@ vi.mock('../../src/middleware/auth.js', () => { const requireAuthMock = (req: any, _res: any, next: any) => { setTestUser(req); next(); }; return { requireAuth: requireAuthMock, + requireApiKeyManagementAuth: requireAuthMock, requireAdmin: passthrough, requireTenantAdminForOrganization: passthrough, optionalAuth: (req: any, _res: any, next: any) => { setTestUser(req); next(); }, diff --git a/server/tests/unit/addie-admin-global-auth.test.ts b/server/tests/unit/addie-admin-global-auth.test.ts index 891b393f1e8..33ed901af67 100644 --- a/server/tests/unit/addie-admin-global-auth.test.ts +++ b/server/tests/unit/addie-admin-global-auth.test.ts @@ -107,7 +107,7 @@ function validatedTenantKey(permission: 'admin:*' | 'admin:read') { return { apiKey: { id: `key_${permission}`, - owner: { id: 'org_tenant' }, + owner: { type: 'organization', id: 'org_tenant' }, name: 'Tenant admin key', permissions: [permission], }, diff --git a/server/tests/unit/api-key-dashboard-role-gate.test.ts b/server/tests/unit/api-key-dashboard-role-gate.test.ts index ebbe4f19503..a8777376a2c 100644 --- a/server/tests/unit/api-key-dashboard-role-gate.test.ts +++ b/server/tests/unit/api-key-dashboard-role-gate.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; const dashboardHtml = readFileSync( join(process.cwd(), 'server/public/dashboard-api-keys.html'), @@ -31,3 +32,64 @@ describe('API key dashboard organization-role gate', () => { ); }); }); + +describe('API key dashboard explicit organization selection', () => { + const primary = { id: 'org_pinnacle', name: 'Pinnacle Agency', role: 'owner', status: 'active', is_primary: true }; + const secondary = { id: 'org_streamhaus', name: 'StreamHaus', role: 'admin', status: 'active' }; + + async function initialize(search: string, organizations = [primary, secondary]) { + const elements = Object.fromEntries(['loading', 'content', 'accessDenied'].map((id) => [ + id, { style: { display: id === 'loading' ? 'block' : 'none' }, textContent: '', innerHTML: '' }, + ])); + const nav = { + init: vi.fn(), renderOrgPicker: vi.fn(), setOrgOptions: vi.fn(), + resolveOrg: vi.fn(() => ({ org: primary, needsSelection: false })), + }; + const loadKeys = vi.fn(); + const localStorage = { getItem: vi.fn(() => primary.id), setItem: vi.fn() }; + const context = vm.createContext({ + document: { getElementById: (id: string) => elements[id] }, + window: { location: { search, pathname: '/dashboard/api-keys' } }, + URLSearchParams, localStorage, DashboardNav: nav, currentOrg: null, loadKeys, + fetch: vi.fn(async () => ({ ok: true, json: async () => ({ organizations }) })), + console, + }); + const start = dashboardHtml.indexOf('async function init()'); + const end = dashboardHtml.indexOf('// Close modals on Escape key', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + vm.runInContext(dashboardHtml.slice(start, end), context); + await context.init(); + return { elements, nav, loadKeys, context, localStorage }; + } + + it.each([[primary], [primary, secondary]])('requires a selection even with %j and a remembered primary organization', async (...organizations) => { + const result = await initialize('', organizations); + expect(result.nav.renderOrgPicker).toHaveBeenCalledWith(organizations, result.elements.loading); + expect(result.nav.resolveOrg).not.toHaveBeenCalled(); + expect(result.localStorage.getItem).not.toHaveBeenCalled(); + expect(result.elements.content.style.display).toBe('none'); + expect(result.loadKeys).not.toHaveBeenCalled(); + }); + + it.each(['?org=org_unknown', '?org=', '?org=org_pinnacle&org=org_streamhaus'])('refuses unavailable or ambiguous selection %s without falling back', async (search) => { + const result = await initialize(search); + expect(result.elements.loading.textContent).toContain('selected organization is unavailable'); + expect(result.elements.content.style.display).toBe('none'); + expect(result.loadKeys).not.toHaveBeenCalled(); + }); + + it('loads keys only for the explicitly selected active admin organization', async () => { + const result = await initialize('?org=org_streamhaus'); + expect(result.context.currentOrg.id).toBe(secondary.id); + expect(result.elements.content.style.display).toBe('block'); + expect(result.loadKeys).toHaveBeenCalledOnce(); + }); + + it('denies a selected member organization even when the other organization has an owner role', async () => { + const result = await initialize('?org=org_streamhaus', [primary, { ...secondary, role: 'member' }]); + expect(result.elements.accessDenied.style.display).toBe('block'); + expect(result.elements.content.style.display).toBe('none'); + expect(result.loadKeys).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/api-key-exact-credential-auth.test.ts b/server/tests/unit/api-key-exact-credential-auth.test.ts new file mode 100644 index 00000000000..f39e26b3250 --- /dev/null +++ b/server/tests/unit/api-key-exact-credential-auth.test.ts @@ -0,0 +1,545 @@ +import type { Request, Response } from 'express'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + createValidation: vi.fn(), + checkPlatformBanForApiKey: vi.fn(), + checkPlatformBan: vi.fn(), + resolveEffectiveMembership: vi.fn(), + loadSealedSession: vi.fn(), + verifyWorkOSJWT: vi.fn(), + loadAuthorizationSnapshot: vi.fn(), + getRefreshedSession: vi.fn(), +})); + +vi.hoisted(() => { + process.env.WORKOS_API_KEY = 'sk_server_test'; + process.env.WORKOS_CLIENT_ID = 'client_test'; + process.env.WORKOS_COOKIE_PASSWORD = 'test-cookie-password-at-least-32-characters'; + process.env.ADMIN_API_KEY = 'static-admin-test'; + delete process.env.DEV_USER_EMAIL; + delete process.env.DEV_USER_ID; +}); + +vi.mock('@workos-inc/node', () => ({ + WorkOS: class WorkOS { + apiKeys = { createValidation: mocks.createValidation }; + userManagement = { loadSealedSession: mocks.loadSealedSession }; + }, +})); +vi.mock('../../src/db/bans-db.js', () => ({ + bansDb: { + checkPlatformBanForApiKey: mocks.checkPlatformBanForApiKey, + checkPlatformBan: mocks.checkPlatformBan, + }, +})); +vi.mock('../../src/db/org-filters.js', () => ({ + resolveEffectiveMembership: mocks.resolveEffectiveMembership, +})); +vi.mock('../../src/auth/workos-jwt.js', async (importOriginal) => ({ + ...await importOriginal(), + verifyWorkOSJWT: mocks.verifyWorkOSJWT, +})); +vi.mock('../../src/db/user-authorization-snapshot-db.js', async (importOriginal) => ({ + ...await importOriginal(), + loadAuthorizationSnapshot: mocks.loadAuthorizationSnapshot, +})); +vi.mock('../../src/db/session-refresh-db.js', () => ({ + getRefreshedSession: mocks.getRefreshedSession, + storeRefreshedSession: vi.fn().mockResolvedValue(undefined), + cleanExpiredRefreshes: vi.fn().mockResolvedValue(0), +})); + +import { + optionalAuth, + requireAuth, + requireAdmin, + requireTenantAdminForOrganization, + stopAuthTimers, + type ValidatedApiKey, +} from '../../src/middleware/auth.js'; + +type KeyRequest = Request & { apiKey?: ValidatedApiKey }; + +function makeRequest(token = 'sk_tenant_key', orgId?: string): KeyRequest { + return { + headers: { authorization: `Bearer ${token}` }, + cookies: { 'wos-session': 'valid-broader-session' }, + params: orgId ? { orgId } : {}, + path: '/api/admin/organizations', + originalUrl: '/api/admin/organizations', + method: 'GET', + accepts: () => false, + } as unknown as KeyRequest; +} + +function makeResponse() { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; +} + +function tenantKey(id = 'key_a', organizationId = 'org_a', permissions = ['admin:*']) { + return { + apiKey: { + id, + owner: { type: 'organization', id: organizationId }, + name: 'Tenant key', + permissions, + }, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + mocks.createValidation.mockResolvedValue(tenantKey()); + mocks.checkPlatformBanForApiKey.mockResolvedValue({ banned: false }); + mocks.checkPlatformBan.mockResolvedValue({ banned: false }); + mocks.resolveEffectiveMembership.mockResolvedValue({ is_member: false }); + mocks.getRefreshedSession.mockResolvedValue(null); + mocks.loadAuthorizationSnapshot.mockImplementation(async (id: string, org: string | null) => ({ + authenticatedUserId: id, canonicalUserId: id, identityId: 'identity_one', + authorizationEpoch: '1', selectedOrganizationId: org, credentialGrant: null, + credential: { email: 'sam@example.test', emailVerified: true, firstName: 'Sam', lastName: 'Adeyemi' }, + })); +}); +afterAll(stopAuthTimers); + +describe.each([ + ['requireAuth', requireAuth], + ['optionalAuth', optionalAuth], +] as const)('%s exact API-key credential', (_name, authenticate) => { + it.each(['bearer sk_revoked', 'BEARER sk_revoked', 'Bearer sk_revoked', 'Bearer\tsk_revoked', 'bEaReR wos_api_key_revoked'])( + 'recognizes the selected API key with alternate header formatting: %s', async (authorization) => { + mocks.createValidation.mockResolvedValue({ apiKey: null }); + const req = makeRequest(); + req.headers.authorization = authorization; + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(mocks.createValidation).toHaveBeenCalledOnce(); + expect(next).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }, + ); + + it('rejects an invalid or revoked key without using an accompanying session', async () => { + mocks.createValidation.mockResolvedValue({ apiKey: null }); + const req = makeRequest(); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'invalid_api_key' }); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(req.apiKey).toBeUndefined(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.verifyWorkOSJWT).not.toHaveBeenCalled(); + }); + + it.each([undefined, 401, 403, 429, 503])('fails unavailable validation closed for provider status %s', async (status) => { + mocks.createValidation.mockRejectedValue(Object.assign(new Error('provider error'), { status })); + const req = makeRequest(); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ error: 'authorization_unavailable' }); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); + + it('rejects a user-owned key without inferring organization authority', async () => { + const owner = { type: 'user', id: 'user_linked', organizationId: 'org_a' }; + mocks.createValidation.mockResolvedValue({ apiKey: { ...tenantKey().apiKey, owner } }); + const req = makeRequest(); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(mocks.resolveEffectiveMembership).not.toHaveBeenCalled(); + }); + + it.each([ + ['header', (req: KeyRequest) => { req.headers['x-organization-id'] = 'org_b'; }], + ['query org', (req: KeyRequest) => { req.query = { org: 'org_b' }; }], + ['query organization_id', (req: KeyRequest) => { req.query = { organization_id: 'org_b' }; }], + ['query organizationId', (req: KeyRequest) => { req.query = { organizationId: 'org_b' }; }], + ['body organization_id', (req: KeyRequest) => { req.body = { organization_id: 'org_b' }; }], + ['body organizationId', (req: KeyRequest) => { req.body = { organizationId: 'org_b' }; }], + ['route orgId', (req: KeyRequest) => { req.params = { orgId: 'org_b' }; }], + ['route organizationId', (req: KeyRequest) => { req.params = { organizationId: 'org_b' }; }], + ] as const)('rejects an API key provider conflict from %s before attaching authority', async (_location, select) => { + const req = makeRequest(); + select(req); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(req.apiKey).toBeUndefined(); + expect(mocks.checkPlatformBanForApiKey).not.toHaveBeenCalled(); + }); + + it.each([ + { owner: { id: 'org_a' } }, + { owner: null }, + { owner: { type: 'organization', id: '' } }, + { id: '' }, + { permissions: undefined }, + { permissions: ['admin:*', null] }, + ])('refuses incomplete provider authorization data: %j', async (invalidFields) => { + mocks.createValidation.mockResolvedValue({ apiKey: { ...tenantKey().apiKey, ...invalidFields } }); + const req = makeRequest(); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + expect(req.apiKey).toBeUndefined(); + }); + + it('does not attach a principal when the key ban check is unavailable', async () => { + mocks.checkPlatformBanForApiKey.mockRejectedValue(new Error('database unavailable')); + const req = makeRequest(); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(req.apiKey).toBeUndefined(); + }); + + it('does not cache an accepted key across revocation or rotation', async () => { + mocks.createValidation + .mockResolvedValueOnce(tenantKey('old_key')) + .mockResolvedValueOnce({ apiKey: null }) + .mockResolvedValueOnce(tenantKey('new_key')) + .mockResolvedValueOnce({ apiKey: null }); + const requests = [makeRequest('sk_old'), makeRequest('sk_old'), makeRequest('sk_new'), makeRequest('sk_old')]; + const responses = requests.map(() => makeResponse()); + const next = vi.fn(); + + for (const [index, req] of requests.entries()) { + await authenticate(req, responses[index], next); + } + + expect(mocks.createValidation.mock.calls.map(([args]) => args.value)).toEqual(['sk_old', 'sk_old', 'sk_new', 'sk_old']); + expect(next).toHaveBeenCalledTimes(2); + expect(requests[0].apiKey?.id).toBe('old_key'); + expect(requests[2].apiKey?.id).toBe('new_key'); + expect(responses[1].status).toHaveBeenCalledWith(401); + expect(responses[3].status).toHaveBeenCalledWith(401); + }); + + it('does not reuse a stale ban allow decision', async () => { + mocks.checkPlatformBanForApiKey + .mockResolvedValueOnce({ banned: false }) + .mockResolvedValueOnce({ banned: true }); + const next = vi.fn(); + const deniedRequest = makeRequest(); + const deniedResponse = makeResponse(); + + await authenticate(makeRequest(), makeResponse(), next); + await authenticate(deniedRequest, deniedResponse, next); + + expect(mocks.checkPlatformBanForApiKey).toHaveBeenCalledTimes(2); + expect(deniedResponse.status).toHaveBeenCalledWith(403); + expect(deniedRequest.user).toBeUndefined(); + expect(next).toHaveBeenCalledOnce(); + }); + + it('keeps concurrent validation decisions and tenant principals separate', async () => { + let releaseFirst!: (result: ReturnType) => void; + mocks.createValidation.mockImplementation(({ value }: { value: string }) => value === 'sk_a' + ? new Promise>((resolve) => { releaseFirst = resolve; }) + : Promise.resolve(tenantKey('key_b', 'org_b', ['admin:read']))); + const requestA = makeRequest('sk_a'); + const requestB = makeRequest('sk_b'); + const nextA = vi.fn(); + const nextB = vi.fn(); + + const pendingA = authenticate(requestA, makeResponse(), nextA); + await authenticate(requestB, makeResponse(), nextB); + expect(nextA).not.toHaveBeenCalled(); + releaseFirst(tenantKey('key_a', 'org_a')); + await pendingA; + + expect(requestA.apiKey).toMatchObject({ id: 'key_a', organizationId: 'org_a', permissions: ['admin:*'] }); + expect(requestB.apiKey).toMatchObject({ id: 'key_b', organizationId: 'org_b', permissions: ['admin:read'] }); + expect(requestA.user).not.toBe(requestB.user); + expect(nextA).toHaveBeenCalledOnce(); + expect(nextB).toHaveBeenCalledOnce(); + }); +}); + +describe('tenant-key use scope', () => { + it.each([ + ['org_a', 'GET', ['admin:*'], true], + ['org_a', 'GET', ['admin:read'], true], + ['org_b', 'GET', ['admin:*'], false], + [undefined, 'GET', ['admin:*'], false], + ['org_a', 'DELETE', ['admin:read'], false], + ['org_a', 'GET', ['admin'], false], + ['org_a', 'GET', [], false], + ] as const)('requires exact selected org and permission: %s %s %j', async (orgId, method, permissions, allowed) => { + mocks.createValidation.mockResolvedValue(tenantKey('key_a', 'org_a', [...permissions])); + const req = makeRequest('sk_a', orgId); + req.method = method; + const res = makeResponse(); + const authenticated = vi.fn(); + const authorized = vi.fn(); + + await requireAuth(req, res, authenticated); + await requireTenantAdminForOrganization(req, res, authorized); + + if (orgId === 'org_b') { + expect(authenticated).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(req.apiKey).toBeUndefined(); + } else { + expect(authenticated).toHaveBeenCalledOnce(); + } + if (allowed) expect(authorized).toHaveBeenCalledOnce(); + else { + expect(authorized).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + } + }); + + it('uses current permissions when the same key is revalidated', async () => { + mocks.createValidation + .mockResolvedValueOnce(tenantKey('key_a', 'org_a', ['admin:*'])) + .mockResolvedValueOnce(tenantKey('key_a', 'org_a', ['admin:read'])); + const authorized = vi.fn(); + for (let i = 0; i < 2; i++) { + const req = makeRequest('sk_a', 'org_a'); + req.method = 'DELETE'; + await requireAuth(req, makeResponse(), vi.fn()); + await requireTenantAdminForOrganization(req, makeResponse(), authorized); + } + expect(authorized).toHaveBeenCalledOnce(); + }); + + it.each(['admin:*', 'admin:read'])('never upgrades a tenant %s key to platform administration', async (permission) => { + mocks.createValidation.mockResolvedValue(tenantKey('key_a', 'org_a', [permission])); + const req = makeRequest('sk_a', 'org_a'); + const res = makeResponse(); + const next = vi.fn(); + + await requireAuth(req, res, vi.fn()); + await requireAdmin(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'global_admin_required' })); + expect(next).not.toHaveBeenCalled(); + }); +}); + +let bearerSequence = 0; + +describe.each([ + ['requireAuth', requireAuth], ['optionalAuth', optionalAuth], +] as const)('%s explicit bearer selection', (_name, authenticate) => { + function noCookieRequest(authorization: string): KeyRequest { + const req = makeRequest(); + req.headers.authorization = authorization; + Object.defineProperty(req, 'cookies', { + get: () => { throw new Error('An explicit bearer must never read a cookie'); }, + }); + return req; + } + + it.each(['Bearer', 'Bearer ', 'bearer ', 'BEARER\t', 'Bearer eyJmalformed', 'bEaReR eyJmalformed..'])( + 'rejects empty or malformed JWT bearer %j without reading the cookie', async (authorization) => { + const req = noCookieRequest(authorization); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }, + ); + + it.each([ + 'ERR_JWT_EXPIRED', 'ERR_JWT_INVALID', 'ERR_JWT_CLAIM_VALIDATION_FAILED', + 'ERR_JWS_INVALID', 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + 'ERR_JWKS_NO_MATCHING_KEY', + 'ERR_JOSE_ALG_NOT_ALLOWED', 'ERR_JOSE_NOT_SUPPORTED', + ])('returns 401 for definitive JWT rejection %s without cookie fallback', async (code) => { + mocks.verifyWorkOSJWT.mockRejectedValue(Object.assign(new Error('Invalid token'), { code })); + const req = noCookieRequest(`bearer eyJ.reject${++bearerSequence}.signature`); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + + it.each([ + new Error('Unknown provider failure'), + Object.assign(new Error('Unknown JWKS failure'), { code: 'ERR_JOSE_GENERIC' }), + Object.assign(new Error('Invalid key service response'), { code: 'ERR_JWKS_INVALID' }), + Object.assign(new Error('Timeout'), { code: 'ERR_JWKS_TIMEOUT' }), + Object.assign(new Error('Fetch failed'), { code: 'ECONNRESET' }), + Object.assign(new Error('Provider authorization unavailable'), { status: 401 }), + Object.assign(new Error('Provider unavailable'), { status: 503 }), + ])('returns 503 for unavailable JWT verification: %s', async (error) => { + mocks.verifyWorkOSJWT.mockRejectedValue(error); + const req = noCookieRequest(`BEARER eyJ.unavailable${++bearerSequence}.signature`); + const res = makeResponse(); + const next = vi.fn(); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + + it.each(['wrong-static-token', 'malformed-opaque-token'])( + 'validates only the selected opaque bearer %s, never an accompanying valid cookie', async (token) => { + const req = makeRequest(`${token}-${++bearerSequence}`); + const res = makeResponse(); + const next = vi.fn(); + mocks.loadSealedSession.mockImplementation(({ sessionData }: { sessionData: string }) => ({ + authenticate: vi.fn().mockResolvedValue(sessionData === 'valid-broader-session' ? { + authenticated: true, + user: { id: 'broader_cookie_user', email: 'cookie@example.test' }, + accessToken: 'cookie-access', + } : { authenticated: false }), + refresh: vi.fn().mockResolvedValue({ authenticated: false }), + })); + + await authenticate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sessionData: `${token}-${bearerSequence}` })); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }, + ); + + it('continues to authenticate a valid JWT with its exact snapshot and selected org', async () => { + mocks.verifyWorkOSJWT.mockResolvedValue({ sub: 'user_authenticated', orgId: 'org_selected', isM2M: false }); + const req = noCookieRequest(`bEaReR\teyJ.valid${++bearerSequence}.signature`); + const next = vi.fn(); + + await authenticate(req, makeResponse(), next); + + expect(next).toHaveBeenCalledOnce(); + expect(req.user?.authorizationSnapshot?.authenticatedUserId).toBe('user_authenticated'); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledExactlyOnceWith('user_authenticated', 'org_selected'); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); + + it('continues to authenticate native sealed bearer sessions without reading a cookie', async () => { + const token = `native-sealed-${++bearerSequence}`; + const req = noCookieRequest(`BEARER ${token}`); + const next = vi.fn(); + mocks.loadSealedSession.mockReturnValue({ authenticate: vi.fn().mockResolvedValue({ + authenticated: true, user: { id: 'user_native', email: 'native@example.test' }, accessToken: 'native-access', + }) }); + + await authenticate(req, makeResponse(), next); + + expect(next).toHaveBeenCalledOnce(); + expect(req.user?.authorizationSnapshot?.authenticatedUserId).toBe('user_native'); + expect(mocks.loadSealedSession).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sessionData: token })); + }); + + it('continues to authenticate the configured static bearer credential', async () => { + const req = noCookieRequest('bearer static-admin-test'); + const next = vi.fn(); + + await authenticate(req, makeResponse(), next); + + expect(next).toHaveBeenCalledOnce(); + expect(req.user?.id).toBe('admin_api_key'); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.verifyWorkOSJWT).not.toHaveBeenCalled(); + }); +}); + +describe.each([ + ['requireAuth', requireAuth], ['optionalAuth', optionalAuth], +] as const)('%s sealed bearer unavailable state', (_label, authenticate) => { + it.each(['initial', 'shared'] as const)('does not mark a retryable %s refresh as a dead credential', async (stage) => { + const token = `retryable-sealed-${++bearerSequence}`; + const authenticateSession = vi.fn().mockResolvedValue({ authenticated: false }); + const refresh = vi.fn().mockResolvedValue({ authenticated: false, retryable: true }); + if (stage === 'shared') { + refresh.mockResolvedValueOnce({ authenticated: false, retryable: false }); + mocks.getRefreshedSession.mockResolvedValueOnce('shared-sealed'); + } + mocks.loadSealedSession.mockReturnValue({ authenticate: authenticateSession, refresh }); + const unavailableResponse = makeResponse(); + const next = vi.fn(); + await authenticate(makeRequest(token), unavailableResponse, next); + expect(unavailableResponse.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + + authenticateSession.mockResolvedValue({ + authenticated: true, user: { id: 'user_recovered', email: 'sam@example.test' }, accessToken: 'recovered', + }); + const recoveredResponse = makeResponse(); + await authenticate(makeRequest(token), recoveredResponse, next); + expect(next).toHaveBeenCalledOnce(); + expect(recoveredResponse.status).not.toHaveBeenCalled(); + }); + + it.each(['authenticate', 'refresh', 'shared refresh'] as const)( + 'keeps unknown provider/JWKS failures in %s unavailable and retryable', async (stage) => { + const token = `unavailable-sealed-${++bearerSequence}`; + const authenticateSession = vi.fn().mockResolvedValue({ authenticated: false }); + const refresh = vi.fn().mockRejectedValue(new Error('Unknown provider or key-service failure')); + if (stage === 'authenticate') authenticateSession.mockRejectedValueOnce(new Error('Unknown JWKS failure')); + if (stage === 'shared refresh') { + refresh.mockResolvedValueOnce({ authenticated: false, retryable: false }); + mocks.getRefreshedSession.mockResolvedValueOnce('shared-sealed'); + } + mocks.loadSealedSession.mockReturnValue({ authenticate: authenticateSession, refresh }); + const res = makeResponse(); + const next = vi.fn(); + await authenticate(makeRequest(token), res, next); + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + authenticateSession.mockResolvedValue({ + authenticated: true, user: { id: 'user_recovered', email: 'sam@example.test' }, accessToken: 'recovered', + }); + await authenticate(makeRequest(token), makeResponse(), next); + expect(next).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/server/tests/unit/api-key-issuance-permissions.test.ts b/server/tests/unit/api-key-issuance-permissions.test.ts index 97de73617ad..b99c5a27220 100644 --- a/server/tests/unit/api-key-issuance-permissions.test.ts +++ b/server/tests/unit/api-key-issuance-permissions.test.ts @@ -1,10 +1,25 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; +import { request as httpRequest } from 'node:http'; +import { once } from 'node:events'; +import type { AddressInfo } from 'node:net'; +import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; const mocks = vi.hoisted(() => ({ listOrganizationMemberships: vi.fn(), + queryWithTimeout: vi.fn(), + transactionQuery: vi.fn(), + clientQuery: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + connectionError: undefined as ((error: Error) => void) | undefined, + connect: vi.fn(), + release: vi.fn(), fetch: vi.fn(), + info: vi.fn(), + request: null as express.Request | null, + previousSnapshot: undefined as AuthorizationSnapshot | undefined, })); vi.hoisted(() => { @@ -21,22 +36,79 @@ vi.mock('@workos-inc/node', () => ({ }, })); +vi.mock('../../src/db/client.js', () => ({ + queryWithTimeout: mocks.queryWithTimeout, isTransientConnectionError: () => false, + getPool: () => ({ connect: mocks.connect }), +})); +vi.mock('../../src/logger.js', () => ({ + createLogger: () => ({ info: mocks.info, warn: vi.fn(), error: vi.fn() }), +})); + vi.mock('../../src/middleware/auth.js', () => ({ DEV_USERS: {}, isDevModeEnabled: () => false, - requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + requireApiKeyManagementAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { req.user = { - id: 'user_caller', - email: 'caller@example.test', + id: req.get('x-canonical-user') ?? 'user_caller', + authWorkosUserId: req.get('x-authenticated-user'), + identityId: req.get('x-identity-id'), + email: req.get('x-user-email') ?? 'caller@example.test', + isAdmin: req.get('x-platform-admin') === 'true', emailVerified: true, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), }; + if (mocks.previousSnapshot) { + Object.defineProperty(req.user, 'authorizationSnapshot', { value: mocks.previousSnapshot, enumerable: false }); + } + mocks.request = req; next(); }, })); const { createApiKeysRouter } = await import('../../src/routes/api-keys.js'); +const { loadAuthorizationSnapshot } = await import('../../src/db/user-authorization-snapshot-db.js'); + +function snapshotRow(userId: string, organizationId?: string, role?: 'owner' | 'admin' | 'member') { + return { + in_recovery: false, terminal_marker: false, primary_count: '1', authenticated_user_id: userId, + canonical_user_id: userId === 'user_linked' ? 'user_primary' : userId, + identity_id: 'identity_original', authorization_epoch: '1', + email: 'caller@example.test', email_verified: true, first_name: null, last_name: null, + grant_id: role ? 'grant_scoped' : null, grant_organization_id: role ? organizationId : null, + grant_role: role ?? null, grant_effective_from: role ? '2026-01-01T00:00:00.000000Z' : null, + grant_effective_until: null, + }; +} + +function setSnapshotWithoutGrant() { + mocks.queryWithTimeout.mockImplementation(async (_sql, [userId]) => ({ rows: [snapshotRow(userId)] })); +} + +function transactionClient() { + return { query: mocks.clientQuery, release: mocks.release, on: mocks.on, removeListener: mocks.removeListener }; +} + +beforeEach(() => { + mocks.queryWithTimeout.mockReset(); + mocks.connectionError = undefined; + mocks.on.mockReset().mockImplementation((_event, listener) => { mocks.connectionError = listener; }); + mocks.clientQuery.mockReset().mockImplementation(async (statement, parameters) => ( + typeof statement === 'string' + ? mocks.transactionQuery(statement, parameters) + : mocks.transactionQuery(statement.text, statement.values) + )); + mocks.transactionQuery.mockReset().mockImplementation(async (sql, parameters) => { + if (sql.includes('AS authenticated_user_id')) { + return mocks.queryWithTimeout.getMockImplementation()!(sql, parameters); + } + return { rows: sql.includes('FROM users WHERE') ? [{ workos_user_id: parameters[0] }] : [] }; + }); + mocks.connect.mockReset().mockResolvedValue(transactionClient()); + setSnapshotWithoutGrant(); + mocks.request = null; + mocks.previousSnapshot = undefined; +}); // Own the process-global transport for the lifetime of this file. Installing // and restoring it independently in sibling suites lets one suite's teardown @@ -62,6 +134,8 @@ function setMembership( mocks.listOrganizationMemberships.mockResolvedValue({ data: [ { + id: 'membership_caller', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + userId: 'user_caller', organizationId: 'org_target', role: { slug: role }, status, @@ -74,6 +148,8 @@ function setUnboundMembership(organizationId?: string) { mocks.listOrganizationMemberships.mockResolvedValue({ data: [ { + id: 'membership_caller', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + userId: 'user_caller', ...(organizationId ? { organizationId } : {}), role: { slug: 'owner' }, status: 'active', @@ -124,7 +200,7 @@ describe('tenant API key issuance permissions', () => { ['owner', 'admin:*'], ['admin', 'admin:read'], ] as const)( - 'allows an organization %s to create a key with server-approved permission %s', + 'contains an organization %s creating a key with server-approved permission %s', async (role, permission) => { setMembership(role); @@ -132,48 +208,51 @@ describe('tenant API key issuance permissions', () => { .post('/api/me/api-keys?org=org_target') .send({ name: 'Automation key', permissions: [permission] }); - expect(response.status).toBe(201); - const [url, options] = mocks.fetch.mock.calls[0] as [string, RequestInit]; - expect(new URL(url).pathname).toBe('/organizations/org_target/api_keys'); - expect(JSON.parse(options.body as string)).toEqual({ - name: 'Automation key', - permissions: [permission], - }); + expect(response.status).toBe(503); + expect(response.body.error).toBe('API key mutations unavailable'); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); }, ); - it('allows an active owner to create an unprivileged key', async () => { + it('contains an active owner creating an unprivileged key', async () => { setMembership('owner'); const response = await request(app) .post('/api/me/api-keys?org=org_target') .send({ name: 'Registry key', permissions: [] }); - expect(response.status).toBe(201); + expect(response.status).toBe(503); + expect(response.body.error).toBe('API key mutations unavailable'); expect(mocks.listOrganizationMemberships).toHaveBeenCalledWith({ userId: 'user_caller', organizationId: 'org_target', }); - const [, options] = mocks.fetch.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(options.body as string)).toEqual({ name: 'Registry key' }); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); }); - it('deduplicates server-approved permissions before sending them to WorkOS', async () => { + it('does not send even valid duplicate permissions to WorkOS while mutations are disabled', async () => { setMembership('owner'); + const response = await request(app).post('/api/me/api-keys?org=org_target') + .send({ name: 'Duplicate scopes', permissions: ['admin:read', 'admin:read'] }); + expect(response.status).toBe(503); + expect(response.body.error).toBe('API key mutations unavailable'); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); - const response = await request(app) - .post('/api/me/api-keys?org=org_target') - .send({ - name: 'Deduplicated key', - permissions: ['admin:read', 'admin:read'], - }); - - expect(response.status).toBe(201); - const [, options] = mocks.fetch.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(options.body as string)).toEqual({ - name: 'Deduplicated key', - permissions: ['admin:read'], - }); + it.each([undefined, '', null, false, 0])('completes both exact authorization checks before rejecting missing key name %j', async (name) => { + setMembership('owner'); + const response = await request(app).post('/api/me/api-keys?org=org_target').send({ name }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('name is required'); + expect(mocks.listOrganizationMemberships.mock.calls).toEqual([ + [{ userId: 'user_caller', organizationId: 'org_target' }], + [{ userId: 'user_caller', organizationId: 'org_target' }], + ]); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.release).toHaveBeenCalledOnce(); }); it.each([ @@ -188,7 +267,9 @@ describe('tenant API key issuance permissions', () => { expect(response.status).toBe(400); expect(response.body.error).toBe('Invalid permissions'); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); }); it('rejects caller-invented permissions even for an organization owner', async () => { @@ -200,7 +281,9 @@ describe('tenant API key issuance permissions', () => { expect(response.status).toBe(400); expect(response.body.error).toBe('Invalid permissions'); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); }); }); @@ -216,6 +299,93 @@ describe('organization-wide API key management permissions', () => { }); }); + async function rawDelete(path: string) { + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + return await new Promise((resolve, reject) => { + // Passing a raw HTTP path avoids the client's URL dot normalization. + const req = httpRequest({ + hostname: '127.0.0.1', port: (server.address() as AddressInfo).port, + method: 'DELETE', path: `${path}?org=org_target`, + headers: { 'x-canonical-user': 'user_primary', 'x-authenticated-user': 'user_linked' }, + }, (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }); + req.on('error', reject); + req.end(); + }); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + } + + function exactLinkedMembership(role: 'admin' | 'member') { + mocks.listOrganizationMemberships.mockResolvedValue({ data: [{ + id: 'membership_linked', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + userId: 'user_linked', organizationId: 'org_target', status: 'active', role: { slug: role }, + }] }); + } + + function expectExactAuthorizationWithoutDelete(expectedLookups = 2) { + expect(mocks.listOrganizationMemberships.mock.calls).toEqual(Array.from({ length: expectedLookups }, () => [{ + userId: 'user_linked', organizationId: 'org_target', + }])); + expect(mocks.queryWithTimeout).toHaveBeenCalledWith( + expect.any(String), ['user_linked'], expect.any(Number), { retryTransientCheckout: false }, + ); + expect(mocks.fetch).not.toHaveBeenCalled(); + } + + it.each([ + '.', '..', '%2e', '%2E', '.%2e', '.%2E', '%2e.', '%2E.', + '%2e%2e', '%2e%2E', '%2E%2e', '%2E%2E', './', '../', '%2E/', '%2e%2E/', + ])('authorizes the exact credential before rejecting raw dot-segment path %s without a provider DELETE', async (keyPath) => { + exactLinkedMembership('admin'); + expect(await rawDelete(`/api/me/api-keys/${keyPath}`)).toBe(400); + expectExactAuthorizationWithoutDelete(); + }); + + it('authorizes before rejecting dot segments on a case-insensitive route', async () => { + exactLinkedMembership('admin'); + expect(await rawDelete('/API/ME/API-KEYS/%2E%2E/')).toBe(400); + expectExactAuthorizationWithoutDelete(); + }); + + it.each([ + '%2E%2E%2Fkey_other', '..%2fkey_other', '%2e%2e%5ckey_other', '..\\key_other', + 'key_%2F..%2Fkey_other', 'key_%5c..%5ckey_other', + '%252e%252e', '%252E%252E%252Fkey_other', '%255c', '%25', + '%2Fkey_other', '%2f%2fapi.workos.com', 'https%3A%2F%2Fexample.test', + 'key_value%3Fother=value', 'key_value%23fragment', 'key_value%3Bsegment', + 'key_value%00', 'key_value%09', 'key_value%0A', 'key_value%0D', + 'key_value%20', 'key_value.', 'key_value%EF%BC%8Fother', + ])('rejects non-opaque decoded key input after exact authorization (case %#)', async (keyPath) => { + exactLinkedMembership('admin'); + expect(await rawDelete(`/api/me/api-keys/${keyPath}`)).toBe(400); + expectExactAuthorizationWithoutDelete(); + }); + + it.each(['key_01AZaz09_-', 'api_key_01H123ABC', '%6bey_encoded', 'key_valid/'])( + 'accepts an opaque key identifier but keeps revocation contained (case %#)', async (keyPath) => { + exactLinkedMembership('admin'); + expect(await rawDelete(`/api/me/api-keys/${keyPath}`)).toBe(503); + expectExactAuthorizationWithoutDelete(); + }, + ); + + it.each(['.', '..', '%2E', '%2e%2E'])('preserves forbidden and unavailable exact authorization for raw path %s', async (keyPath) => { + exactLinkedMembership('member'); + expect(await rawDelete(`/api/me/api-keys/${keyPath}`)).toBe(403); + expectExactAuthorizationWithoutDelete(1); + + vi.clearAllMocks(); + mocks.listOrganizationMemberships.mockRejectedValue(new Error('Authorization provider unavailable')); + expect(await rawDelete(`/api/me/api-keys/${keyPath}`)).toBe(503); + expectExactAuthorizationWithoutDelete(1); + }); + it('refuses an ordinary member listing organization API keys before WorkOS is called', async () => { setMembership('member'); @@ -250,21 +420,13 @@ describe('organization-wide API key management permissions', () => { expect(options.method).toBe('GET'); }); - it('allows an active admin to revoke an organization API key', async () => { + it('contains an active admin revoking an organization API key', async () => { setMembership('admin'); - mocks.fetch.mockResolvedValue({ ok: true, status: 204 }); - - const response = await request(app).delete( - '/api/me/api-keys/key_owner_automation?org=org_target', - ); - - expect(response.status).toBe(204); - expect(mocks.fetch).toHaveBeenCalledOnce(); - const [url, options] = mocks.fetch.mock.calls[0] as [string, RequestInit]; - expect(new URL(url).pathname).toBe( - '/organizations/org_target/api_keys/key_owner_automation', - ); - expect(options.method).toBe('DELETE'); + const response = await request(app).delete('/api/me/api-keys/key_owner_automation?org=org_target'); + expect(response.status).toBe(503); + expect(response.body.error).toBe('API key mutations unavailable'); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); }); it.each([ @@ -344,3 +506,791 @@ describe('inactive API key lifecycle principals', () => { }, ); }); + +describe('exact credential API key authorization', () => { + const app = createApp(); + const operations = ['create', 'list', 'revoke'] as const; + + function perform(operation: typeof operations[number], org?: string) { + const query = org === undefined ? '' : `?org=${encodeURIComponent(org)}`; + return operation === 'create' + ? request(app).post(`/api/me/api-keys${query}`).send({ name: 'Scoped automation' }) + : operation === 'list' + ? request(app).get(`/api/me/api-keys${query}`) + : request(app).delete(`/api/me/api-keys/key_scoped${query}`); + } + + beforeEach(() => { + vi.clearAllMocks(); + mocks.listOrganizationMemberships.mockReset().mockResolvedValue({ data: [] }); + mocks.fetch.mockReset().mockImplementation(async (_url, options: RequestInit) => ({ + ok: true, + status: options.method === 'DELETE' ? 204 : 200, + json: async () => ({ data: [] }), + })); + }); + + it.each(operations)('isolates linked credentials in both directions when they %s keys', async (operation) => { + const organizations: Record = { + user_primary: 'org_pinnacle', + user_linked: 'org_streamhaus', + }; + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => ({ + data: organizations[userId] === organizationId + ? [{ id: `membership_${userId}_${organizationId}`, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId, organizationId, status: 'active', role: { slug: 'owner' } }] + : [], + })); + + for (const [credential, ownOrg] of Object.entries(organizations)) { + const otherOrg = ownOrg === 'org_pinnacle' ? 'org_streamhaus' : 'org_pinnacle'; + const denied = await perform(operation, otherOrg) + .set('x-canonical-user', 'user_primary') + .set('x-authenticated-user', credential); + expect(denied.status).toBe(403); + expect(mocks.fetch).not.toHaveBeenCalled(); + + const allowed = await perform(operation, ownOrg) + .set('x-canonical-user', 'user_primary') + .set('x-authenticated-user', credential); + expect(allowed.status).toBe(operation === 'list' ? 200 : 503); + expect(mocks.listOrganizationMemberships).toHaveBeenLastCalledWith({ + userId: credential, organizationId: ownOrg, + }); + expect(mocks.queryWithTimeout).toHaveBeenLastCalledWith( + expect.any(String), [credential], expect.any(Number), { retryTransientCheckout: false }, + ); + if (operation === 'list') { + expect(new URL(mocks.fetch.mock.calls[0][0]).pathname).toContain(`/organizations/${ownOrg}/api_keys`); + } else { + expect(allowed.body.error).toBe('API key mutations unavailable'); + expect(mocks.fetch).not.toHaveBeenCalled(); + } + mocks.fetch.mockClear(); + } + }); + + it.each(operations)('requires an explicit organization to %s even for an owner', async (operation) => { + setMembership('owner'); + + expect((await perform(operation)).status).toBe(400); + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.queryWithTimeout).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each([ + '/api/me/api-keys?org=org_target&org=org_other', + '/api/me/api-keys?org=%20', + ])('rejects an ambiguous or malformed selected organization: %s', async (url) => { + setMembership('owner'); + expect((await request(app).get(url)).status).toBe(400); + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it('rejects a path-like selected organization before any membership or key request', async () => { + setMembership('owner'); + const response = await request(app).get('/api/me/api-keys?org=org_target%2F..%2Forg_other'); + expect(response.status).toBe(400); + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each([ + '.', '..', '../org_other', 'org_target/../org_other', 'org_target\\..\\org_other', + '%2e%2e', '%252e%252e', '%2F', 'org_target?other=value', 'org_target#fragment', + 'https://example.test', '//example.test', 'org_target\n', 'org_target\r', + 'org_target\t', 'org_target\0', 'org_target ', 'org_\uFF0Fother', + ])('rejects non-opaque organization selectors in every lifecycle operation (case %#)', async (org) => { + setMembership('owner'); + for (const operation of operations) { + expect((await perform(operation, org)).status).toBe(400); + } + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.queryWithTimeout).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(['org_01AZaz09_-', 'org_01H123ABC'])( + 'keeps the outbound inventory URL fixed for an accepted opaque organization (case %#)', async (org) => { + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => ({ + data: [{ id: 'membership_opaque', createdAt: '2026-01-01', updatedAt: '2026-01-01', + userId, organizationId, status: 'active', role: { slug: 'admin' } }], + })); + const response = await perform('list', org).query({ after: '../cursor?#', limit: '10' }); + expect(response.status).toBe(200); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + const [url, options] = mocks.fetch.mock.calls[0]; + const parsed = new URL(url); + expect(parsed.origin).toBe('https://api.workos.com'); + expect(parsed.pathname).toBe(`/organizations/${org}/api_keys`); + expect(parsed.hash).toBe(''); + expect([...parsed.searchParams]).toEqual([['after', '../cursor?#'], ['limit', '10']]); + expect(options.method).toBe('GET'); + }, + ); + + it('rejects conflicting query and body organization selections', async () => { + setMembership('owner'); + const response = await request(app).post('/api/me/api-keys?org=org_target') + .send({ organizationId: 'org_other', name: 'Ambiguous key' }); + expect(response.status).toBe(400); + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(['api_key_key_tenant', 'admin_api_key'])('refuses synthetic principal %s for all key management', async (principal) => { + setMembership('owner'); + for (const operation of operations) { + expect((await perform(operation, 'org_target').set('x-canonical-user', principal)).status).toBe(403); + } + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.queryWithTimeout).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(operations)('does not let canonical platform-admin or email attribution authorize %s', async (operation) => { + const response = await perform(operation, 'org_target') + .set('x-canonical-user', 'user_primary') + .set('x-authenticated-user', 'user_linked') + .set('x-platform-admin', 'true') + .set('x-user-email', 'administrator@example.test'); + expect(response.status).toBe(403); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledExactlyOnceWith({ + userId: 'user_linked', organizationId: 'org_target', + }); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + }); + + it.each(['api_key_key_tenant', 'admin_api_key'])('refuses synthetic exact credential %s even with normal canonical user attribution', async (principal) => { + setMembership('owner'); + for (const operation of operations) { + expect((await perform(operation, 'org_target') + .set('x-canonical-user', 'user_caller').set('x-authenticated-user', principal)).status).toBe(403); + } + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.queryWithTimeout).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(operations)('returns unavailable separately from forbidden when authority cannot establish permission to %s', async (operation) => { + mocks.listOrganizationMemberships.mockRejectedValue(new Error('WorkOS unavailable')); + expect((await perform(operation, 'org_target')).status).toBe(503); + expect(mocks.fetch).not.toHaveBeenCalled(); + + setMembership('member'); + mocks.queryWithTimeout.mockRejectedValue(new Error('Credential lifecycle database unavailable')); + expect((await perform(operation, 'org_target')).status).toBe(503); + expect(mocks.fetch).not.toHaveBeenCalled(); + + mocks.queryWithTimeout.mockImplementation(async (sql, [userId]) => { + if (sql.includes('organization_credential_grants')) throw new Error('Grant storage unavailable'); + return { rows: [snapshotRow(userId)] }; + }); + expect((await perform(operation, 'org_target')).status).toBe(403); + expect(mocks.connect).not.toHaveBeenCalled(); + }); + + it('does not cache organization authority between operations', async () => { + setMembership('owner'); + expect((await perform('list', 'org_target')).status).toBe(200); + mocks.fetch.mockClear(); + setMembership('member'); + + expect((await perform('create', 'org_target')).status).toBe(403); + expect((await perform('revoke', 'org_target')).status).toBe(403); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(5); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it('does not inherit a linked credential grant or a higher canonical role', async () => { + mocks.listOrganizationMemberships.mockImplementation(async ({ userId }) => ({ + data: userId === 'user_primary' + ? [{ id: `membership_${userId}`, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId, organizationId: 'org_target', status: 'active', role: { slug: 'owner' } }] + : [], + })); + mocks.queryWithTimeout.mockImplementation(async (_sql, [userId, orgId]) => ({ + rows: [snapshotRow(userId, orgId, orgId === 'org_target' ? userId === 'user_primary' ? 'owner' : 'member' : undefined)], + })); + + for (const operation of operations) { + expect((await perform(operation, 'org_target') + .set('x-canonical-user', 'user_primary') + .set('x-authenticated-user', 'user_linked')).status).toBe(403); + } + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(operations)('never authorizes %s through a grant-only exact credential', async (operation) => { + mocks.queryWithTimeout.mockImplementation(async (_sql, [userId]) => ({ + rows: [snapshotRow(userId, 'org_target', userId === 'user_linked' ? 'admin' : undefined)], + })); + + expect((await perform(operation, 'org_target') + .set('x-canonical-user', 'user_primary').set('x-authenticated-user', 'user_linked')).status).toBe(403); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + }); + + it.each(operations)('does not elevate an ordinary direct member with an owner or admin grant to %s keys', async (operation) => { + setMembership('member'); + for (const grantRole of ['owner', 'admin'] as const) { + mocks.queryWithTimeout.mockImplementation(async (_sql, [userId]) => ({ + rows: [snapshotRow(userId, 'org_target', grantRole)], + })); + expect((await perform(operation, 'org_target')).status).toBe(403); + } + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + }); + + it.each(operations)('keeps direct owner/admin %s authorization independent of unavailable grant storage and snapshot grants', async (operation) => { + const grantRows = [ + snapshotRow('user_caller'), + snapshotRow('user_caller', 'org_target', 'member'), + snapshotRow('user_caller', 'org_target', 'admin'), + snapshotRow('user_caller', 'org_target', 'owner'), + { ...snapshotRow('user_caller', 'org_target', 'admin'), grant_effective_until: '2020-01-01T00:00:00.000000Z' }, + snapshotRow('user_caller', 'org_other', 'owner'), + ]; + for (const directRole of ['owner', 'admin'] as const) { + setMembership(directRole); + for (const previousRow of grantRows) { + mocks.queryWithTimeout.mockResolvedValue({ rows: [previousRow] }); + mocks.previousSnapshot = (await loadAuthorizationSnapshot('user_caller', 'org_target'))!; + mocks.queryWithTimeout.mockClear().mockImplementation(async (sql, [userId]) => { + if (sql.includes('organization_credential_grants')) { + throw new Error('Grant storage unavailable'); + } + return { rows: [snapshotRow(userId)] }; + }); + const response = await perform(operation, 'org_target'); + expect(response.status, `${directRole}; ${JSON.stringify(previousRow)}`).toBe(operation === 'list' ? 200 : 503); + expect(mocks.queryWithTimeout.mock.calls.every(([sql]) => !sql.includes('organization_credential_grants'))).toBe(true); + } + } + expect(mocks.fetch).toHaveBeenCalledTimes(operation === 'list' ? 12 : 0); + }); + + it.each(['epoch changed', 'organization changed'])('preserves the non-enumerable snapshot and rejects replay after %s', async (change) => { + const original = snapshotRow('user_linked', 'org_target', 'admin'); + mocks.queryWithTimeout.mockResolvedValue({ rows: [original] }); + mocks.previousSnapshot = (await loadAuthorizationSnapshot( + 'user_linked', change === 'organization changed' ? 'org_other' : 'org_target', + ))!; + const current = change === 'epoch changed' ? { ...original, authorization_epoch: '2' } : original; + mocks.queryWithTimeout.mockResolvedValue({ rows: [current] }); + mocks.listOrganizationMemberships.mockResolvedValue({ + data: [{ id: 'membership_linked', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId: 'user_linked', organizationId: 'org_target', status: 'active', role: { slug: 'owner' } }], + }); + + for (const operation of operations) { + expect((await perform(operation, 'org_target') + .set('x-canonical-user', 'user_primary').set('x-authenticated-user', 'user_linked')).status).toBe(403); + } + expect(Object.keys(mocks.request!.user!)).not.toContain('authorizationSnapshot'); + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(operations)('rechecks current direct membership inside the lifecycle fence before %s', async (operation) => { + for (const changedMembership of ['member', 'pending', 'absent', 'unavailable'] as const) { + vi.clearAllMocks(); + const recheckStarted = Promise.withResolvers(); + const releaseRecheck = Promise.withResolvers(); + let lookups = 0; + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => { + if (++lookups === 1) return { data: [{ id: `membership_${userId}_${organizationId}`, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId, organizationId, status: 'active', role: { slug: 'owner' } }] }; + recheckStarted.resolve(); + await releaseRecheck.promise; + if (changedMembership === 'unavailable') throw new Error('WorkOS unavailable during final recheck'); + return { data: changedMembership === 'absent' ? [] : [{ + id: `membership_${userId}_${organizationId}`, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + userId, organizationId, status: changedMembership === 'pending' ? 'pending' : 'active', + role: { slug: changedMembership === 'member' ? 'member' : 'owner' }, + }] }; + }); + const pending = perform(operation, 'org_target').then((response) => response); + try { + await recheckStarted.promise; + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.transactionQuery.mock.calls.some(([sql]) => sql.includes('FROM users WHERE') && sql.includes('FOR UPDATE'))).toBe(true); + } finally { + releaseRecheck.resolve(); + } + expect((await pending).status, changedMembership).toBe(changedMembership === 'unavailable' ? 503 : 403); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.transactionQuery.mock.calls.some(([sql]) => /^\s*(?:INSERT|UPDATE|DELETE)\b/i.test(sql))).toBe(false); + expect(mocks.release).toHaveBeenCalledOnce(); + } + }); + + it.each(operations)('rejects a lifecycle change while %s waits to enter the fence', async (operation) => { + setMembership('owner'); + const checkoutStarted = Promise.withResolvers(); + const releaseCheckout = Promise.withResolvers(); + mocks.connect.mockImplementation(async () => { + checkoutStarted.resolve(); + await releaseCheckout.promise; + return transactionClient(); + }); + const pending = perform(operation, 'org_target').then((response) => response); + try { + await checkoutStarted.promise; + expect(mocks.fetch).not.toHaveBeenCalled(); + mocks.queryWithTimeout.mockImplementation(async (_sql, [userId]) => ({ + rows: [{ ...snapshotRow(userId), authorization_epoch: '2' }], + })); + } finally { + releaseCheckout.resolve(); + } + expect((await pending).status).toBe(403); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledOnce(); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + }); + + it.each(operations)('checks lifecycle state after the last membership await before %s', async (operation) => { + let lookups = 0; + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => { + if (++lookups === 2) { + mocks.queryWithTimeout.mockImplementation(async () => ({ + rows: [{ ...snapshotRow(userId), authorization_epoch: '2' }], + })); + } + return { data: [{ id: `membership_${userId}_${organizationId}`, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId, organizationId, status: 'active', role: { slug: 'owner' } }] }; + }); + expect((await perform(operation, 'org_target')).status).toBe(403); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + }); + + it.each(operations)('returns unavailable without an effect when the fenced connection is lost before %s', async (operation) => { + let lookups = 0; + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => { + if (++lookups === 2) mocks.connectionError!(new Error('Fence connection lost')); + return { data: [{ id: `membership_${userId}_${organizationId}`, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId, organizationId, status: 'active', role: { slug: 'owner' } }] }; + }); + expect((await perform(operation, 'org_target')).status).toBe(503); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.release).toHaveBeenCalledExactlyOnceWith(true); + }); + + it.each(['connection lost', 'provider budget exhausted'] as const)('refuses a secret operation when %s during the final snapshot query', async (change) => { + setMembership('owner'); + const finalQueryStarted = Promise.withResolvers(); + const releaseFinalQuery = Promise.withResolvers(); + const query = mocks.transactionQuery.getMockImplementation()!; + let snapshots = 0; + let now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockImplementation(() => now); + mocks.transactionQuery.mockImplementation(async (sql, parameters) => { + const result = await query(sql, parameters); + if (sql.includes('AS authenticated_user_id') && ++snapshots === 2) { + finalQueryStarted.resolve(); + await releaseFinalQuery.promise; + } + return result; + }); + const pending = perform('create', 'org_target').then((response) => response); + try { + await finalQueryStarted.promise; + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); + expect(mocks.fetch).not.toHaveBeenCalled(); + if (change === 'connection lost') mocks.connectionError!(new Error('Connection lost while final query returns')); + else now += 11_000; + releaseFinalQuery.resolve(); + expect((await pending).status).toBe(503); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.connect).toHaveBeenCalledOnce(); + } finally { + releaseFinalQuery.resolve(); + clock.mockRestore(); + } + }); + + it.each(operations)('bounds a failed lock query and denies %s without retrying authorization or provider effects', async (operation) => { + setMembership('owner'); + const query = mocks.transactionQuery.getMockImplementation()!; + mocks.transactionQuery.mockImplementation(async (sql, parameters) => { + if (sql.includes('FROM users WHERE')) throw Object.assign(new Error('Lock timeout'), { code: '55P03' }); + return query(sql, parameters); + }); + expect((await perform(operation, 'org_target')).status).toBe(503); + expect(mocks.clientQuery.mock.calls.length).toBeGreaterThan(1); + for (const [statement] of mocks.clientQuery.mock.calls) { + expect(statement.query_timeout).toBeGreaterThan(0); + expect(statement.query_timeout).toBeLessThanOrEqual(2000); + } + expect(mocks.connect).toHaveBeenCalledOnce(); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledOnce(); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.release).toHaveBeenCalledExactlyOnceWith(true); + }); + + it.each(operations)('treats an unusable membership response as unavailable before %s', async (operation) => { + mocks.listOrganizationMemberships.mockResolvedValue({ data: null }); + expect((await perform(operation, 'org_target')).status).toBe(503); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + }); + + it.each(['create', 'revoke'] as const)('does not dispatch %s or claim an unknown outcome while mutations are disabled', async (operation) => { + setMembership('owner'); + mocks.fetch.mockRejectedValue(new Error('This transport must never be reached')); + const response = await perform(operation, 'org_target'); + expect(response.status).toBe(503); + expect(response.body).toEqual({ + error: 'API key mutations unavailable', + message: 'API key creation and revocation are disabled until membership changes can be fenced.', + }); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); + expect(mocks.info).not.toHaveBeenCalled(); + }); + + it.each(operations)('does not redispatch or alter the %s response if lock cleanup fails', async (operation) => { + setMembership('owner'); + const query = mocks.transactionQuery.getMockImplementation()!; + mocks.transactionQuery.mockImplementation(async (sql, parameters) => { + if (sql === 'ROLLBACK') throw new Error('Connection lost during lock cleanup'); + return query(sql, parameters); + }); + expect((await perform(operation, 'org_target')).status).toBe(operation === 'list' ? 200 : 503); + expect(mocks.fetch).toHaveBeenCalledTimes(operation === 'list' ? 1 : 0); + expect(mocks.release).toHaveBeenCalledExactlyOnceWith(true); + }); + + it.each(['create', 'revoke'] as const)('contains %s even when external authority changes after the final local check', async (operation) => { + for (const change of ['revoked', 'downgraded'] as const) { + vi.clearAllMocks(); + const finalQueryStarted = Promise.withResolvers(); + const releaseFinalQuery = Promise.withResolvers(); + let revoked = false; + let snapshots = 0; + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => ({ data: revoked + ? change === 'revoked' ? [] : [{ + id: 'membership_current', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + userId, organizationId, status: 'active', role: { slug: 'member' }, + }] + : [{ id: 'membership_current', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId, organizationId, status: 'active', role: { slug: 'owner' } }], + })); + mocks.transactionQuery.mockImplementation(async (sql, parameters) => { + if (sql.includes('AS authenticated_user_id')) { + const result = await mocks.queryWithTimeout.getMockImplementation()!(sql, parameters); + if (++snapshots === 2) { + finalQueryStarted.resolve(); + await releaseFinalQuery.promise; + } + return result; + } + return { rows: sql.includes('FROM users WHERE') ? [{ workos_user_id: parameters[0] }] : [] }; + }); + const pending = perform(operation, 'org_target').then((response) => response); + try { + await finalQueryStarted.promise; + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); + revoked = true; + expect(mocks.fetch).not.toHaveBeenCalled(); + } finally { + releaseFinalQuery.resolve(); + } + const response = await pending; + expect(response.status, change).toBe(503); + expect(response.body.error).toBe('API key mutations unavailable'); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.transactionQuery.mock.calls.some(([sql]) => /^\s*(?:INSERT|UPDATE|DELETE)\b/i.test(sql))).toBe(false); + } + }); + + it.each(['response', 'body'] as const)('withholds inventory when external authority changes during the provider %s', async (boundary) => { + for (const change of ['revoked', 'member', 'admin', 'membership replaced', 'creation changed', 'version changed', 'roles changed', 'unavailable'] as const) { + vi.clearAllMocks(); + const readStarted = Promise.withResolvers(); + const releaseRead = Promise.withResolvers(); + let changed = false; + let settled = false; + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => { + if (changed && change === 'unavailable') throw new Error('Membership read unavailable'); + return { data: changed && change === 'revoked' ? [] : [{ + id: changed && change === 'membership replaced' ? 'membership_replaced' : 'membership_original', + createdAt: changed && change === 'creation changed' ? '2026-01-02T00:00:00.000Z' : '2026-01-01T00:00:00.000Z', + updatedAt: changed && change === 'version changed' ? '2026-01-02T00:00:00.000Z' : '2026-01-01T00:00:00.000Z', + userId, organizationId, status: 'active', + role: { slug: changed && (change === 'admin' || change === 'member') ? change : 'owner' }, + roles: changed && change === 'roles changed' ? [{ slug: 'owner' }, { slug: 'member' }] : undefined, + }] }; + }); + mocks.fetch.mockImplementation(async () => { + if (boundary === 'response') { + readStarted.resolve(); + await releaseRead.promise; + } + return { ok: true, status: 200, json: async () => { + if (boundary === 'body') { + readStarted.resolve(); + await releaseRead.promise; + } + return { data: [{ id: 'key_inventory_private', value: 'private_inventory_value' }] }; + } }; + }); + const pending = perform('list', 'org_target').then((response) => { settled = true; return response; }); + try { + await readStarted.promise; + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); + expect(settled).toBe(false); + changed = true; + } finally { + releaseRead.resolve(); + } + const response = await pending; + expect(response.status, change).toBe(change === 'unavailable' ? 503 : 403); + expect(JSON.stringify(response.body)).not.toContain('key_inventory_private'); + expect(JSON.stringify(response.body)).not.toContain('private_inventory_value'); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(3); + expect(mocks.fetch).toHaveBeenCalledExactlyOnceWith(expect.any(String), expect.objectContaining({ method: 'GET' })); + expect(mocks.info).not.toHaveBeenCalled(); + } + }); + + it('withholds inventory when local exact credential state changes during the provider body read', async () => { + setMembership('owner'); + const readStarted = Promise.withResolvers(); + const releaseRead = Promise.withResolvers(); + mocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => { + readStarted.resolve(); + await releaseRead.promise; + return { data: [{ id: 'key_inventory_private' }] }; + } }); + const pending = perform('list', 'org_target').then((response) => response); + try { + await readStarted.promise; + mocks.queryWithTimeout.mockImplementation(async (_sql, [userId]) => ({ + rows: [{ ...snapshotRow(userId), authorization_epoch: '2' }], + })); + } finally { + releaseRead.resolve(); + } + const response = await pending; + expect(response.status).toBe(403); + expect(JSON.stringify(response.body)).not.toContain('key_inventory_private'); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(2); + expect(mocks.fetch).toHaveBeenCalledOnce(); + expect(mocks.info).not.toHaveBeenCalled(); + }); + + it.each(['connection lost', 'deadline expired'] as const)('withholds inventory if the local fence is unusable after the final membership lookup: %s', async (failure) => { + let lookups = 0; + let now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockImplementation(() => now); + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => { + if (++lookups === 3) { + if (failure === 'connection lost') mocks.connectionError!(new Error('Fence lost during final membership lookup')); + else now += 15_001; + } + return { data: [{ + id: 'membership_current', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + userId, organizationId, status: 'active', role: { slug: 'owner' }, + }] }; + }); + mocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ data: [{ id: 'key_inventory_private' }] }) }); + try { + const response = await perform('list', 'org_target'); + expect(response.status).toBe(503); + expect(JSON.stringify(response.body)).not.toContain('key_inventory_private'); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledTimes(3); + expect(mocks.fetch).toHaveBeenCalledOnce(); + expect(mocks.info).not.toHaveBeenCalled(); + expect(mocks.release).toHaveBeenCalledExactlyOnceWith(failure === 'connection lost'); + } finally { + clock.mockRestore(); + } + }); + + it.each(['id', 'createdAt', 'updatedAt'] as const)('treats a direct membership missing %s as unavailable', async (field) => { + const membership: Record = { + id: 'membership_current', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId: 'user_caller', + organizationId: 'org_target', status: 'active', role: { slug: 'owner' }, + }; + delete membership[field]; + mocks.listOrganizationMemberships.mockResolvedValue({ data: [membership] }); + for (const operation of operations) expect((await perform(operation, 'org_target')).status).toBe(503); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + }); + + it.each([undefined, {}, { slug: null }, { slug: 7 }, { slug: '' }])('treats unusable direct membership role %j as unavailable', async (role) => { + mocks.listOrganizationMemberships.mockResolvedValue({ data: [{ + id: 'membership_current', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + userId: 'user_caller', organizationId: 'org_target', status: 'active', role, + }] }); + for (const operation of operations) expect((await perform(operation, 'org_target')).status).toBe(503); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + }); + + it('keeps concurrent requests isolated when the linked credential lookup completes first', async () => { + const primaryStarted = Promise.withResolvers(); + const releasePrimary = Promise.withResolvers(); + mocks.listOrganizationMemberships.mockImplementation(async ({ userId, organizationId }) => { + if (userId === 'user_primary') { + primaryStarted.resolve(); + await releasePrimary.promise; + return { data: [{ id: `membership_${userId}_${organizationId}`, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', userId, organizationId, status: 'active', role: { slug: 'owner' } }] }; + } + return { data: [] }; + }); + const primary = perform('list', 'org_target') + .set('x-canonical-user', 'user_primary').then((response) => response); + await primaryStarted.promise; + try { + const linked = await perform('list', 'org_target') + .set('x-canonical-user', 'user_primary').set('x-authenticated-user', 'user_linked'); + expect(linked.status).toBe(403); + expect(mocks.fetch).not.toHaveBeenCalled(); + } finally { + releasePrimary.resolve(); + } + expect((await primary).status).toBe(200); + expect(mocks.fetch).toHaveBeenCalledOnce(); + }); +}); + +describe('API key selector agreement and immutable audit actor', () => { + const app = createApp(); + const methods = ['GET', 'POST', 'DELETE'] as const; + const locations = [ + ['query', 'org'], ['query', 'organization_id'], ['query', 'organizationId'], + ['body', 'organizationId'], ['body', 'organization_id'], + ['header', 'x-organization-id'], + ] as const; + type Selection = { query?: Record; body?: Record; header?: string | string[] }; + + function select(entries: Array): Selection { + const selection: Selection = {}; + for (const [[source, field], value] of entries) { + if (source === 'header') selection.header = value as string | string[]; + else (selection[source] ??= {})[field] = value; + } + return selection; + } + + function perform(method: typeof methods[number], selection: Selection) { + const route = '/api/me/api-keys'; + const call = method === 'GET' ? request(app).get(route) + : method === 'POST' ? request(app).post(route) + : request(app).delete(`${route}/key_scoped`); + if (selection.query) call.query(selection.query); + if (selection.header !== undefined) call.set('X-Organization-ID', selection.header); + return call.send({ ...(method === 'POST' ? { name: 'Scoped automation' } : {}), ...selection.body }); + } + + beforeEach(() => { + vi.clearAllMocks(); + mocks.listOrganizationMemberships.mockReset(); + setMembership('owner'); + mocks.fetch.mockReset().mockImplementation(async (_url, options: RequestInit) => ({ + ok: true, + status: options.method === 'DELETE' ? 204 : 200, + json: async () => ({ data: [] }), + })); + }); + + it.each(methods)('accepts each explicit selector alone and all agreeing selectors on %s', async (method) => { + const selections = [ + ...locations.map((location) => select([[location, 'org_target']])), + select(locations.map((location) => [location, 'org_target'])), + ]; + for (const selection of selections) { + const response = await perform(method, selection); + expect(response.status, JSON.stringify(selection)).toBe(method === 'GET' ? 200 : 503); + expect(mocks.listOrganizationMemberships).toHaveBeenLastCalledWith({ userId: 'user_caller', organizationId: 'org_target' }); + if (method === 'GET') expect(new URL(mocks.fetch.mock.lastCall![0]).pathname).toContain('/organizations/org_target/api_keys'); + else expect(mocks.fetch).not.toHaveBeenCalled(); + } + }); + + it.each(methods)('rejects every conflicting selector pair before authorization on %s', async (method) => { + for (const [index, left] of locations.entries()) { + for (const right of locations.slice(index + 1)) { + const selection = select([[left, 'org_target'], [right, 'org_other']]); + expect((await perform(method, selection)).status, JSON.stringify(selection)).toBe(400); + } + } + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.queryWithTimeout).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(methods)('rejects malformed and repeated selectors even alongside a valid selection on %s', async (method) => { + for (const location of locations) { + const invalidValues: unknown[] = location[0] === 'header' + ? ['', ' ', 'org_target,org_other', 'org_target, org_target', ['org_target', 'org_target'], ['org_target', 'org_other']] + : location[0] === 'query' + ? ['', ' ', ' org_target', 'org_target ', ['org_target', 'org_target']] + : ['', ' ', ' org_target', 'org_target ', null, 7, [], ['org_target'], { id: 'org_target' }]; + for (const value of invalidValues) { + const fallback = location[0] === 'query' ? locations[2] : locations[0]; + const selection = select([[fallback, 'org_target'], [location, value]]); + expect((await perform(method, selection)).status, JSON.stringify(selection)).toBe(400); + } + } + expect(mocks.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(mocks.queryWithTimeout).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it('keeps the exact credential immutable across lookup and provider awaits while reading', async () => { + const lookupStarted = Promise.withResolvers(); + const releaseLookup = Promise.withResolvers(); + const providerStarted = Promise.withResolvers(); + const releaseProvider = Promise.withResolvers(); + mocks.listOrganizationMemberships.mockImplementation(async () => { + lookupStarted.resolve(); + await releaseLookup.promise; + return { data: [{ + id: 'membership_linked', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + userId: 'user_linked', organizationId: 'org_target', status: 'active', role: { slug: 'owner' }, + }] }; + }); + mocks.fetch.mockImplementation(async () => { + providerStarted.resolve(); + await releaseProvider.promise; + return { ok: true, status: 200, json: async () => ({ data: [] }) }; + }); + const pending = perform('GET', { query: { org: 'org_target' } }) + .set('x-canonical-user', 'user_primary').set('x-authenticated-user', 'user_linked') + .set('x-identity-id', 'identity_original').then((response) => response); + try { + await lookupStarted.promise; + Object.assign(mocks.request!.user!, { + id: 'user_changed_during_lookup', authWorkosUserId: 'user_wrong_lookup', identityId: 'identity_wrong_lookup', + }); + releaseLookup.resolve(); + await providerStarted.promise; + Object.assign(mocks.request!.user!, { + id: 'user_changed_during_provider', authWorkosUserId: 'user_wrong_provider', identityId: 'identity_wrong_provider', + }); + } finally { + releaseLookup.resolve(); + releaseProvider.resolve(); + } + expect((await pending).status).toBe(200); + expect(mocks.listOrganizationMemberships.mock.calls).toEqual(Array.from({ length: 3 }, () => [{ + userId: 'user_linked', organizationId: 'org_target', + }])); + expect(mocks.queryWithTimeout.mock.calls.every(([, [userId]]) => userId === 'user_linked')).toBe(true); + expect(mocks.info).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/api-key-validation-timeout.test.ts b/server/tests/unit/api-key-validation-timeout.test.ts new file mode 100644 index 00000000000..1effd7815ab --- /dev/null +++ b/server/tests/unit/api-key-validation-timeout.test.ts @@ -0,0 +1,147 @@ +import type { Request, Response } from 'express'; +import { WorkOS } from '@workos-inc/node'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.hoisted(() => vi.fn()); +const snapshotMock = vi.hoisted(() => vi.fn()); +vi.hoisted(() => { + process.env.WORKOS_API_KEY = 'sk_server_test'; + process.env.WORKOS_CLIENT_ID = 'client_test'; + process.env.WORKOS_COOKIE_PASSWORD = 'test-cookie-password-at-least-32-characters'; + delete process.env.DEV_USER_EMAIL; + delete process.env.DEV_USER_ID; + vi.stubGlobal('fetch', fetchMock); +}); +vi.mock('../../src/db/bans-db.js', () => ({ + bansDb: { + checkPlatformBanForApiKey: vi.fn().mockResolvedValue({ banned: false }), + checkPlatformBan: vi.fn().mockResolvedValue({ banned: false }), + }, +})); +vi.mock('../../src/db/org-filters.js', () => ({ + resolveEffectiveMembership: vi.fn().mockResolvedValue({ is_member: false }), +})); +vi.mock('../../src/db/user-authorization-snapshot-db.js', async (importOriginal) => ({ + ...await importOriginal(), + loadAuthorizationSnapshot: snapshotMock, +})); +import { optionalAuth, requireAuth, stopAuthTimers } from '../../src/middleware/auth.js'; + +function request(token: string): Request { + return { + headers: { authorization: `Bearer ${token}` }, + cookies: { 'wos-session': 'valid-broader-cookie' }, + path: '/api/test', originalUrl: '/api/test', accepts: () => false, + } as unknown as Request; +} +function response(): Response { + return { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } as unknown as Response; +} +beforeEach(() => { vi.useFakeTimers(); fetchMock.mockReset(); snapshotMock.mockReset(); }); +afterEach(() => vi.useRealTimers()); +afterAll(() => { stopAuthTimers(); vi.unstubAllGlobals(); }); + +describe('actual WorkOS client API-key request budget', () => { + it('aborts at five seconds without SDK retries and permits a fresh request', async () => { + fetchMock.mockImplementation((_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal!.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); + })); + const res = response(); + const next = vi.fn(); + const pending = requireAuth(request('sk_retry'), res, next); + await vi.advanceTimersByTimeAsync(4_999); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await pending; + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe('https://api.workos.com/api_keys/validations'); + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + + fetchMock.mockResolvedValue(new globalThis.Response(JSON.stringify({ api_key: { + id: 'key_retry', owner: { type: 'organization', id: 'org_selected' }, name: 'Retried key', permissions: [], + } }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + await requireAuth(request('sk_retry'), response(), next); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(next).toHaveBeenCalledOnce(); + }); + + it.each([requireAuth, optionalAuth])('rejects actual SDK malformed sealed bearer errors as 401 (%s)', async (authenticate) => { + for (const token of [ + 'Fe26.2*1*salt*iv*encrypted*nope*hmacsalt*hmac~2', + 'Fe26.2*1*salt*iv*encrypted**hmacsalt*!~2', + ]) { + const res = response(); + const next = vi.fn(); + await authenticate(request(token), res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 503 for an unsupported provider JWK inside a sealed Bearer despite a valid cookie', async () => { + const { publicKey, privateKey } = await generateKeyPair('RS256'); + const jwk = await exportJWK(publicKey); + const accessTokens = new Map(); + for (const credential of ['cookie', 'bearer']) { + accessTokens.set(credential, await new SignJWT({ sub: `user_${credential}`, org_id: `org_${credential}` }) + .setProtectedHeader({ alg: 'RS256', kid: credential }).setExpirationTime('1h').sign(privateKey)); + } + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + if (url === 'https://api.workos.com/sso/jwks/client_test') { + return new globalThis.Response(JSON.stringify({ keys: [ + { ...jwk, kid: 'cookie' }, { ...jwk, kid: 'bearer', oth: [] }, + ] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (url === 'https://api.workos.com/user_management/authenticate') { + const { code } = JSON.parse(init!.body as string); + return new globalThis.Response(JSON.stringify({ + access_token: accessTokens.get(code), refresh_token: `refresh_${code}`, + user: { id: `user_${code}`, email: `${code}@example.test`, email_verified: true }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`Unexpected WorkOS request: ${url}`); + }); + const sdk = new WorkOS(process.env.WORKOS_API_KEY!, { clientId: process.env.WORKOS_CLIENT_ID }); + const seal = async (code: string) => (await sdk.userManagement.authenticateWithCode({ + code, session: { sealSession: true, cookiePassword: process.env.WORKOS_COOKIE_PASSWORD! }, + })).sealedSession!; + const cookie = await seal('cookie'); + const bearer = await seal('bearer'); + await expect(sdk.userManagement.loadSealedSession({ + sessionData: bearer, cookiePassword: process.env.WORKOS_COOKIE_PASSWORD!, + }).authenticate()).rejects.toMatchObject({ code: 'ERR_JOSE_NOT_SUPPORTED' }); + snapshotMock.mockImplementation(async (id: string, org: string | null) => ({ + authenticatedUserId: id, canonicalUserId: id, identityId: null, authorizationEpoch: '1', + selectedOrganizationId: org, credentialGrant: null, + credential: { email: `${id}@example.test`, emailVerified: true, firstName: null, lastName: null }, + })); + + // Prove the accompanying cookie really authenticates through the installed SDK. + const cookieRequest = request(bearer); + cookieRequest.headers = {}; + cookieRequest.cookies['wos-session'] = cookie; + const cookieNext = vi.fn(); + await requireAuth(cookieRequest, response(), cookieNext); + expect(cookieNext).toHaveBeenCalledOnce(); + expect(cookieRequest.user?.id).toBe('user_cookie'); + snapshotMock.mockClear(); + + for (const authenticate of [requireAuth, optionalAuth]) { + const req = request(bearer); + req.cookies['wos-session'] = cookie; + const res = response(); + const next = vi.fn(); + await authenticate(req, res, next); + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + } + expect(snapshotMock).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.filter(([url]) => url === 'https://api.workos.com/user_management/authenticate')) + .toHaveLength(2); // Creating the fixtures only; no credential refresh/fallback. + }); +}); diff --git a/server/tests/unit/api-key-widget-containment.test.ts b/server/tests/unit/api-key-widget-containment.test.ts new file mode 100644 index 00000000000..7d7082612b7 --- /dev/null +++ b/server/tests/unit/api-key-widget-containment.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const mocks = vi.hoisted(() => ({ createToken: vi.fn(), info: vi.fn(), auth: vi.fn(), admin: vi.fn() })); +vi.hoisted(() => { + vi.stubEnv('WORKOS_API_KEY', 'sk_test_widget_containment'); + vi.stubEnv('WORKOS_CLIENT_ID', 'client_test_widget_containment'); + vi.stubEnv('WORKOS_COOKIE_PASSWORD', 'test-cookie-password-at-least-32-characters'); +}); +vi.mock('@workos-inc/node', () => ({ WorkOS: class WorkOS { widgets = { createToken: mocks.createToken }; } })); +vi.mock('../../src/logger.js', () => ({ createLogger: () => ({ info: mocks.info, warn: vi.fn(), error: vi.fn() }) })); +vi.mock('../../src/middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + mocks.auth(); + req.user = { + id: req.get('x-canonical-user') ?? 'user_primary', authWorkosUserId: 'user_linked', + email: 'caller@example.test', isAdmin: true, emailVerified: true, + createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), + }; + next(); + }, + requireAdmin: (_req: express.Request, _res: express.Response, next: express.NextFunction) => { mocks.admin(); next(); }, +})); +vi.mock('../../src/db/client.js', () => ({ getPool: vi.fn() })); +vi.mock('../../src/utils/html-config.js', () => ({ serveHtmlWithConfig: vi.fn() })); +vi.mock('../../src/routes/network-health.js', () => ({ registerNetworkHealthAdminPage: vi.fn() })); +vi.mock('../../src/addie/member-context.js', () => ({ getMemberContext: vi.fn(), getWebMemberContext: vi.fn() })); +vi.mock('../../src/db/outbound-db.js', () => ({ getMemberCapabilities: vi.fn() })); +vi.mock('../../src/addie/services/relationship-orchestrator.js', () => ({ canEngageSlackUser: vi.fn() })); +vi.mock('../../src/addie/services/engagement-planner.js', () => ({ computeEngagementOpportunities: vi.fn() })); +vi.mock('../../src/db/relationship-db.js', () => ({})); +vi.mock('../../src/addie/services/relationship-context.js', () => ({ loadRelationshipContext: vi.fn() })); +vi.mock('../../src/routes/admin/prospects.js', () => ({ setupProspectRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/organizations.js', () => ({ setupOrganizationRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/enrichment.js', () => ({ setupEnrichmentRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/domains.js', () => ({ setupDomainRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/cleanup.js', () => ({ setupCleanupRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/stats.js', () => ({ setupStatsRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/discounts.js', () => ({ setupDiscountRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/accounts.js', () => ({ setupAccountRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/accounts-billing.js', () => ({ setupAccountsBillingRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/brand-enrichment.js', () => ({ setupBrandEnrichmentRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/bans.js', () => ({ setupBanRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/geo.js', () => ({ setupGeoRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/announcements.js', () => ({ setupAnnouncementsRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/relationships.js', () => ({ setupRelationshipRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/simulations.js', () => ({ setupSimulationRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/illustrations.js', () => ({ setupIllustrationRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/addie-costs.js', () => ({ setupAddieCostRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/prompt-metrics.js', () => ({ setupPromptMetricsRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/integrity.js', () => ({ setupIntegrityRoutes: vi.fn() })); +vi.mock('../../src/routes/admin/agents.js', () => ({ setupAdminAgentsRoutes: vi.fn() })); +vi.mock('../../src/routes/aao-admin.js', () => ({ createAAOAdminRouter: () => express.Router() })); +vi.mock('../../src/newsletters/registry.js', () => ({ getAllNewsletters: () => [] })); +vi.mock('../../src/newsletters/admin-routes.js', () => ({ createNewsletterAdminRoutes: () => express.Router() })); +vi.mock('../../src/newsletters/the-build/index.js', () => ({})); +vi.mock('../../src/newsletters/the-prompt/index.js', () => ({})); + +const { createAdminRouter } = await import('../../src/routes/admin.js'); +const app = express(); +app.use(express.json()); +app.use('/api/admin', createAdminRouter().apiRouter); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.createToken.mockResolvedValue('widget_token_supported_scope'); +}); + +describe('API key widget mutation containment', () => { + it.each([undefined, null, '', false, 'widgets:api-keys:manage'])('refuses API key scope or default %j before issuing a delegation token', async (scope) => { + const response = await request(app).post('/api/admin/widgets/token').send({ organizationId: 'org_target', scope }); + expect(response.status).toBe(503); + expect(response.body).toEqual({ + error: 'API key mutations unavailable', + message: 'API key creation and revocation are disabled until membership changes can be fenced.', + }); + expect(mocks.auth).toHaveBeenCalledOnce(); + expect(mocks.admin).toHaveBeenCalledOnce(); + expect(mocks.createToken).not.toHaveBeenCalled(); + expect(mocks.info).not.toHaveBeenCalled(); + }); + + it.each(['admin_api_key', 'api_key_tenant', 'user_platform_admin'])('does not let admin attribution %s reopen the widget mutation path', async (principal) => { + const response = await request(app).post('/api/admin/widgets/token') + .set('x-canonical-user', principal).send({ organizationId: 'org_target', scope: 'widgets:api-keys:manage' }); + expect(response.status).toBe(503); + expect(mocks.createToken).not.toHaveBeenCalled(); + }); + + it.each(['widgets:users-table:manage', 'widgets:sso:manage', 'widgets:domain-verification:manage'])('retains existing delegation for supported unrelated scope %s', async (scope) => { + const response = await request(app).post('/api/admin/widgets/token').send({ organizationId: 'org_target', scope }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ token: 'widget_token_supported_scope' }); + expect(mocks.createToken).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org_target', userId: 'user_linked', scopes: [scope], + }); + }); + + it.each(['widgets:api-keys:MANAGE', ['widgets:api-keys:manage'], 'widgets:api-keys:manage,widgets:sso:manage'])('rejects scope confusion %j without issuing a token', async (scope) => { + const response = await request(app).post('/api/admin/widgets/token').send({ organizationId: 'org_target', scope }); + expect(response.status).toBe(400); + expect(mocks.createToken).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/auth-dev-bearer-terminal.test.ts b/server/tests/unit/auth-dev-bearer-terminal.test.ts new file mode 100644 index 00000000000..b05a85365a3 --- /dev/null +++ b/server/tests/unit/auth-dev-bearer-terminal.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextFunction, Request, Response } from 'express'; + +const mocks = vi.hoisted(() => { + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('DEV_USER_EMAIL', 'dev@example.test'); + vi.stubEnv('DEV_USER_ID', 'user_dev_test'); + vi.stubEnv('WORKOS_API_KEY', 'sk_test_dev_bearer'); + vi.stubEnv('WORKOS_CLIENT_ID', 'client_test_dev_bearer'); + vi.stubEnv('WORKOS_COOKIE_PASSWORD', 'test-cookie-password-at-least-32-characters'); + return { + authenticate: vi.fn(), + refresh: vi.fn(), + getRefreshedSession: vi.fn(), + }; +}); + +vi.mock('@workos-inc/node', () => ({ + WorkOS: class WorkOS { + apiKeys = { createValidation: vi.fn() }; + userManagement = { + loadSealedSession: () => ({ authenticate: mocks.authenticate, refresh: mocks.refresh }), + }; + }, +})); + +vi.mock('../../src/db/session-refresh-db.js', () => ({ + cleanExpiredRefreshes: vi.fn().mockResolvedValue(0), + getRefreshedSession: (...args: unknown[]) => mocks.getRefreshedSession(...args), + storeRefreshedSession: vi.fn().mockResolvedValue(undefined), +})); + +import { + encodeDevSessionCookie, + optionalAuth, + requireAuth, + stopAuthTimers, +} from '../../src/middleware/auth.js'; + +function bearerRequest(): Request { + return { + headers: { authorization: 'bEaReR\tinvalid-opaque-session' }, + cookies: { 'dev-session': encodeDevSessionCookie('admin') }, + query: {}, body: {}, params: {}, + accepts: vi.fn().mockReturnValue(false), + originalUrl: '/api/protected', + path: '/api/protected', + } as unknown as Request; +} + +function response(): Response { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + cookie: vi.fn().mockReturnThis(), + redirect: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + } as unknown as Response; +} + +beforeEach(() => { + mocks.authenticate.mockReset().mockResolvedValue({ authenticated: false }); + mocks.refresh.mockReset().mockResolvedValue({ authenticated: false, reason: 'invalid_grant', retryable: false }); + mocks.getRefreshedSession.mockReset().mockResolvedValue(null); +}); + +afterAll(() => { + stopAuthTimers(); + vi.unstubAllEnvs(); +}); + +describe.each([ + ['required', requireAuth], + ['optional', optionalAuth], +] as const)('%s auth in dev mode', (_label, middleware) => { + it('does not replace an explicit opaque Bearer with a valid dev-session cookie', async () => { + const req = bearerRequest(); + const res = response(); + const next = vi.fn() as NextFunction; + + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/authorization-epoch-session.test.ts b/server/tests/unit/authorization-epoch-session.test.ts index 43044c6d819..f9d33a086c3 100644 --- a/server/tests/unit/authorization-epoch-session.test.ts +++ b/server/tests/unit/authorization-epoch-session.test.ts @@ -32,7 +32,8 @@ vi.mock('../../src/db/user-authorization-snapshot-db.js', async (importOriginal) ...await importOriginal(), loadAuthorizationSnapshot: mocks.loadAuthorizationSnapshot, })); -vi.mock('../../src/auth/workos-jwt.js', () => ({ +vi.mock('../../src/auth/workos-jwt.js', async (importOriginal) => ({ + ...await importOriginal(), verifyWorkOSJWT: mocks.verifyWorkOSJWT, looksLikeJWT: (token: string) => token.split('.').length === 3, })); @@ -145,6 +146,7 @@ const organizationSelectors: Array<[string, (req: Request, organizationId: strin ['header', (req, org) => { req.headers['x-organization-id'] = org; }], ['query org', (req, org) => { req.query.org = org; }], ['query organization_id', (req, org) => { req.query.organization_id = org; }], + ['query organizationId', (req, org) => { req.query.organizationId = org; }], ['body organization_id', (req, org) => { req.body.organization_id = org; }], ['body organizationId', (req, org) => { req.body.organizationId = org; }], ['path orgId', (req, org) => { req.params.orgId = org; }], @@ -232,7 +234,7 @@ describe('optional authentication credential presence', () => { it.each([false, true])('rejects a failed JWT without anonymous or cookie fallback (cookie present: %s)', async (cookiePresent) => { const req = request(`header.invalid${++sequence}.signature`, true); if (cookiePresent) req.cookies['wos-session'] = 'otherwise-valid-cookie'; - mocks.verifyWorkOSJWT.mockRejectedValue(new Error('Invalid JWT signature')); + mocks.verifyWorkOSJWT.mockRejectedValue(Object.assign(new Error('Invalid JWT signature'), { code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' })); const res = response(); const next = vi.fn(); await optionalAuth(req, res, next); @@ -649,7 +651,7 @@ describe('optional authentication HTTP route boundary', () => { }); it.each(['', 'Basic invalid', 'Bearer header.invalid.signature'])('rejects supplied Authorization %j without using an otherwise valid cookie', async (authorization) => { - mocks.verifyWorkOSJWT.mockRejectedValue(new Error('Invalid JWT signature')); + mocks.verifyWorkOSJWT.mockRejectedValue(Object.assign(new Error('Invalid JWT signature'), { code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' })); const cookie = `otherwise-valid-${++sequence}`; const { app, handler } = route({ 'wos-session': cookie }); const result = await supertest(app).get('/optional-auth-test') diff --git a/server/tests/unit/caller-org-auth-errors.test.ts b/server/tests/unit/caller-org-auth-errors.test.ts new file mode 100644 index 00000000000..7a90baba61f --- /dev/null +++ b/server/tests/unit/caller-org-auth-errors.test.ts @@ -0,0 +1,188 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const mocks = vi.hoisted(() => ({ + validateApiKey: vi.fn(), jwtVerify: vi.fn(), decodeJwt: vi.fn(), query: vi.fn(), listAllAgents: vi.fn(), + getProfileByDomain: vi.fn(), issueDomainClaim: vi.fn(), + listProposals: vi.fn(), getProposalById: vi.fn(), submitProposal: vi.fn(), +})); +vi.hoisted(() => { + process.env.WORKOS_API_KEY = 'sk_test'; + process.env.WORKOS_CLIENT_ID = 'client_test'; +}); +vi.mock('../../src/middleware/auth.js', async (importOriginal) => ({ + ...await importOriginal(), + validateWorkOSApiKey: mocks.validateApiKey, +})); +vi.mock('jose', async (importOriginal) => ({ + ...await importOriginal(), + createRemoteJWKSet: () => vi.fn(), + decodeJwt: mocks.decodeJwt, + jwtVerify: mocks.jwtVerify, +})); +vi.mock('../../src/db/client.js', async (importOriginal) => ({ + ...await importOriginal(), + query: mocks.query, +})); +vi.mock('../../src/db/member-db.js', () => ({ + MemberDatabase: class { getProfileByDomain = mocks.getProfileByDomain; }, +})); +vi.mock('../../src/db/community-mirror-db.js', () => ({ + CommunityMirrorDatabase: class { + listProposals = mocks.listProposals; + getProposalById = mocks.getProposalById; + submitProposal = mocks.submitProposal; + }, +})); +vi.mock('../../src/services/brand-logo-auth.js', async (importOriginal) => ({ + ...await importOriginal(), + isRegistryModerator: vi.fn().mockResolvedValue(false), +})); +vi.mock('../../src/addie/admin-status-lookup.js', () => ({ + isWebUserAAOAdmin: vi.fn().mockResolvedValue(false), + isAuthenticatedUserAAOAdmin: vi.fn().mockResolvedValue(false), +})); +vi.mock('../../src/middleware/rate-limit.js', async (importOriginal) => { + const original = await importOriginal>(); + const pass: express.RequestHandler = (_req, _res, next) => next(); + return Object.fromEntries(Object.entries(original).map(([name, value]) => [name, name.endsWith('RateLimiter') ? pass : value])); +}); + +import { createRegistryApiRouter, type RegistryApiConfig } from '../../src/routes/registry-api.js'; +import { createCommunityMirrorRouter } from '../../src/routes/community-mirrors.js'; +import { stopAuthTimers } from '../../src/middleware/auth.js'; + +const attachedCookieUser: express.RequestHandler = (req, _res, next) => { + // Exercise the resolver boundary after another middleware attached a user. + req.user = { id: 'cookie_user', email: 'cookie@example.test' } as typeof req.user; + next(); +}; +function app() { + const application = express(); + application.use(express.json()); + const config = { + brandManager: {}, brandDb: {}, propertyDb: { issueDomainClaim: mocks.issueDomainClaim }, + adagentsManager: {}, healthChecker: {}, capabilityDiscovery: {}, + crawler: { getFederatedIndex: () => ({ listAllAgents: mocks.listAllAgents }) }, + registryRequestsDb: { trackRequest: async () => {}, markResolved: async () => true }, + requireAuth: attachedCookieUser, optionalAuth: attachedCookieUser, + } as unknown as RegistryApiConfig; + application.use('/api', createRegistryApiRouter(config)); + application.use('/api/registry', createCommunityMirrorRouter({ requireAuth: attachedCookieUser })); + return application; +} +const application = app(); +afterAll(stopAuthTimers); +beforeEach(() => { + vi.clearAllMocks(); + mocks.validateApiKey.mockResolvedValue(null); + mocks.decodeJwt.mockImplementation((token: string) => { + if (token === 'eyJmalformed') { + throw Object.assign(new Error('Malformed JWT'), { code: 'ERR_JWT_INVALID' }); + } + return { iss: 'https://auth.example.test/user_management/client_test' }; + }); + mocks.jwtVerify.mockRejectedValue(Object.assign(new Error('Expired token'), { code: 'ERR_JWT_EXPIRED' })); + mocks.query.mockResolvedValue({ rows: [{ primary_organization_id: 'org_cookie', joins_valid: true }] }); + mocks.getProfileByDomain.mockResolvedValue(null); + mocks.listAllAgents.mockResolvedValue([]); + mocks.getProposalById.mockResolvedValue({ id: '11111111-1111-4111-8111-111111111111', proposed_by_user_id: 'cookie_user' }); +}); + +describe.each([ + ['agent listing', 'get', '/api/registry/agents'], + ['operator listing', 'get', '/api/registry/operator?domain=example.test'], + ['domain claim', 'post', '/api/properties/hosted/example.test/claim'], + ['proposal listing', 'get', '/api/registry/mirror-proposals'], + ['proposal detail', 'get', '/api/registry/mirror-proposals/11111111-1111-4111-8111-111111111111'], + ['proposal submission', 'put', '/api/registry/mirrors/test-platform'], +] as const)('%s preserves bearer authorization failures', (_name, method, path) => { + it.each([ + ['Authorization', 'Bearer sk_revoked'], + ['authorization', 'bearer wrong-static-token'], + ['AUTHORIZATION', 'BEARER eyJexpired.payload.sig'], + ['aUtHoRiZaTiOn', 'bEaReR\t sk_revoked'], + ['Authorization', 'Bearer'], + ['Authorization', 'Bearer malformed opaque token'], + ['Authorization', 'Bearer eyJmalformed'], + ['Authorization', 'Bearer sk_revoked, Bearer sk_other'], + ])( + 'returns 401 for %s: %s instead of anonymous or cookie authority', async (header, authorization) => { + const response = await request(application)[method](path) + .set(header, authorization) + .set('Cookie', 'wos-session=verified-cookie') + .send({}); + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'invalid_bearer_token' }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.listAllAgents).not.toHaveBeenCalled(); + expect(mocks.listProposals).not.toHaveBeenCalled(); + expect(mocks.submitProposal).not.toHaveBeenCalled(); + expect(mocks.issueDomainClaim).not.toHaveBeenCalled(); + }, + ); + it.each(['Bearer sk_unavailable', 'Bearer eyJunavailable.payload.sig'])( + 'returns 503 for unavailable %s instead of 500 or anonymous access', async (authorization) => { + mocks.validateApiKey.mockRejectedValue(new Error('Provider unavailable')); + mocks.jwtVerify.mockRejectedValue(new Error('JWKS unavailable')); + const response = await request(application)[method](path) + .set('Authorization', authorization) + .set('Cookie', 'wos-session=verified-cookie') + .send({}); + expect(response.status).toBe(503); + expect(response.body).toEqual({ error: 'authorization_unavailable' }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.listAllAgents).not.toHaveBeenCalled(); + expect(mocks.listProposals).not.toHaveBeenCalled(); + expect(mocks.submitProposal).not.toHaveBeenCalled(); + expect(mocks.issueDomainClaim).not.toHaveBeenCalled(); + }, + ); +}); + +it('preserves an API-key provider/request organization conflict as 403', async () => { + mocks.validateApiKey.mockResolvedValue({ organizationId: 'org_provider' }); + + const response = await request(application) + .get('/api/registry/agents?organizationId=org_request') + .set('Authorization', 'bEaReR\tsk_live'); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'organization_selection_conflict' }); + expect(mocks.listAllAgents).not.toHaveBeenCalled(); +}); + +it('does not let spoofed authorization headers replace an invalid selected bearer', async () => { + mocks.validateApiKey.mockImplementation(async (req: express.Request) => ( + req.headers.authorization === 'Bearer sk_other' ? { organizationId: 'org_other' } : null + )); + + const response = await request(application) + .get('/api/registry/agents') + .set('Authorization', 'Bearer invalid-selected-token') + .set('X-Authorization', 'Bearer sk_other') + .set('X-Forwarded-Authorization', 'Bearer sk_other') + .set('Cookie', 'wos-session=verified-cookie'); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'invalid_bearer_token' }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.listAllAgents).not.toHaveBeenCalled(); +}); + +it.each([undefined, 'Basic dXNlcjpwYXNz'])('preserves cookie-only resolution when no Bearer is selected: %s', async (authorization) => { + mocks.issueDomainClaim.mockResolvedValue({ token: 'claim_cookie', lockedToOrgId: null }); + const claim = request(application) + .post('/api/properties/hosted/example.test/claim') + .set('Cookie', 'wos-session=verified-cookie'); + if (authorization !== undefined) claim.set('Authorization', authorization); + + const response = await claim.send({}); + + expect(response.status).toBe(200); + expect(mocks.query).toHaveBeenCalledWith(expect.stringContaining('primary_organization_id'), ['cookie_user']); + expect(mocks.issueDomainClaim).toHaveBeenCalledWith('example.test', 'org_cookie'); + expect(mocks.jwtVerify).not.toHaveBeenCalled(); + expect(mocks.validateApiKey).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/certification-api-key-membership-gate.test.ts b/server/tests/unit/certification-api-key-membership-gate.test.ts index 3dc2cb8b648..ddcff9c1a87 100644 --- a/server/tests/unit/certification-api-key-membership-gate.test.ts +++ b/server/tests/unit/certification-api-key-membership-gate.test.ts @@ -72,7 +72,7 @@ describe('certification API-key membership gate', () => { mocks.createValidation.mockResolvedValue({ apiKey: { id: 'key_123', - owner: { id: 'org_free_tier' }, + owner: { type: 'organization', id: 'org_free_tier' }, name: 'Free tier API key', permissions: [], }, diff --git a/server/tests/unit/conformance-token-route.test.ts b/server/tests/unit/conformance-token-route.test.ts index 54eb8883bf5..77e54b2d4e8 100644 --- a/server/tests/unit/conformance-token-route.test.ts +++ b/server/tests/unit/conformance-token-route.test.ts @@ -7,19 +7,33 @@ process.env.CONFORMANCE_JWT_SECRET = 'test-conformance-secret'; vi.mock('../../src/middleware/auth.js', () => ({ requireAuth: (req: any, _res: any, next: any) => { req.user = { id: 'user_test', email: 'test@test' }; + req.isStaticAdminApiKey = req.headers['x-test-static-admin'] === 'true'; next(); }, })); -vi.mock('../../src/routes/helpers/resolve-caller-org.js', () => ({ - resolveCallerOrgId: vi.fn(async (req: any) => req.headers['x-test-org'] ?? null), -})); +vi.mock('../../src/routes/helpers/resolve-caller-org.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveCallerOrgId: vi.fn(async (req: express.Request) => { + const status = Number(req.headers['x-test-auth-error']); + if (status === 401 || status === 503) throw new actual.CallerOrganizationAuthError(status); + return req.headers['x-test-org'] ?? null; + }), + }; +}); async function buildApp() { const { buildConformanceTokenRouter } = await import('../../src/conformance/token-route.js'); const app = express(); app.use(express.json()); app.use('/api/conformance', buildConformanceTokenRouter()); + // Model the production generic handler, which otherwise flattens 5xx to 500. + const errorHandler: express.ErrorRequestHandler = (_error, _req, res, _next) => { + res.status(500).json({ error: 'internal_error' }); + }; + app.use(errorHandler); return app; } @@ -66,4 +80,24 @@ describe('POST /api/conformance/token', () => { expect(res.status).toBe(500); expect(res.body.error).toBe('token_issuance_failed'); }); + + it.each([401, 503])('preserves resolver status %s at every conformance credential boundary', async (status) => { + const app = await buildApp(); + for (const [method, path] of [['post', '/token'], ['get', '/_debug'], ['post', '/_debug/run-storyboard']] as const) { + const res = await request(app)[method](`/api/conformance${path}`) + .set('Authorization', 'Bearer sk_selected').set('Cookie', 'wos-session=otherwise-valid') + .set('x-test-auth-error', String(status)); + expect(res.status, path).toBe(status); + expect(res.body.error).toBe(status === 401 ? 'invalid_bearer_token' : 'authorization_unavailable'); + expect(res.body.token).toBeUndefined(); + } + }); + + it('keeps authenticated static-admin dev smoke selection explicit without resolving a tenant', async () => { + const app = await buildApp(); + const res = await request(app).post('/api/conformance/_debug/run-storyboard') + .set('x-test-static-admin', 'true').set('x-test-auth-error', '401').send({ org_id: 'org_selected' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('storyboard_id required'); + }); }); diff --git a/server/tests/unit/member-profile-admin-global-auth.test.ts b/server/tests/unit/member-profile-admin-global-auth.test.ts index 0f25ecd69a5..2c3cfdd3e88 100644 --- a/server/tests/unit/member-profile-admin-global-auth.test.ts +++ b/server/tests/unit/member-profile-admin-global-auth.test.ts @@ -82,7 +82,7 @@ function validatedTenantKey(permission: 'admin:*' | 'admin:read') { return { apiKey: { id: `key_${permission}`, - owner: { id: 'org_tenant' }, + owner: { type: 'organization', id: 'org_tenant' }, name: 'Tenant admin key', permissions: [permission], }, diff --git a/server/tests/unit/network-health-security.test.ts b/server/tests/unit/network-health-security.test.ts index 9ad5ce4f92a..f95460c602d 100644 --- a/server/tests/unit/network-health-security.test.ts +++ b/server/tests/unit/network-health-security.test.ts @@ -129,7 +129,7 @@ function validatedTenantKey(permission: 'admin:*' | 'admin:read') { return { apiKey: { id: `key_${permission}`, - owner: { id: 'org_tenant' }, + owner: { type: 'organization', id: 'org_tenant' }, name: 'Tenant admin key', permissions: [permission], }, diff --git a/server/tests/unit/optional-auth-api-key-membership.test.ts b/server/tests/unit/optional-auth-api-key-membership.test.ts index f6c7881a560..2f8dd6d6283 100644 --- a/server/tests/unit/optional-auth-api-key-membership.test.ts +++ b/server/tests/unit/optional-auth-api-key-membership.test.ts @@ -49,7 +49,7 @@ describe('WorkOS API key membership hydration', () => { mocks.createValidation.mockResolvedValue({ apiKey: { id: 'key_123', - owner: { id: 'org_member' }, + owner: { type: 'organization', id: 'org_member' }, name: 'Member API key', permissions: [], }, @@ -85,7 +85,7 @@ describe('WorkOS API key membership hydration', () => { mocks.createValidation.mockResolvedValue({ apiKey: { id: 'key_456', - owner: { id: 'org_free_tier' }, + owner: { type: 'organization', id: 'org_free_tier' }, name: 'Free tier API key', permissions: [], }, diff --git a/server/tests/unit/portrait-admin-global-auth.test.ts b/server/tests/unit/portrait-admin-global-auth.test.ts index 834d7bdeecc..afed4813d8e 100644 --- a/server/tests/unit/portrait-admin-global-auth.test.ts +++ b/server/tests/unit/portrait-admin-global-auth.test.ts @@ -67,7 +67,7 @@ function validatedTenantKey(permission: 'admin:*' | 'admin:read') { return { apiKey: { id: `key_${permission}`, - owner: { id: 'org_tenant' }, + owner: { type: 'organization', id: 'org_tenant' }, name: 'Tenant admin key', permissions: [permission], }, diff --git a/server/tests/unit/resolve-caller-org.test.ts b/server/tests/unit/resolve-caller-org.test.ts index db9fd038938..7856f109417 100644 --- a/server/tests/unit/resolve-caller-org.test.ts +++ b/server/tests/unit/resolve-caller-org.test.ts @@ -19,12 +19,31 @@ const validateWorkOSApiKeyMock = vi.fn(); const jwtVerifyMock = vi.fn(); const decodeJwtMock = vi.fn(); const dbQueryMock = vi.fn(); +const ConflictingOrganizationSelectionErrorMock = vi.hoisted(() => ( + class ConflictingOrganizationSelectionError extends Error {} +)); vi.mock('../../src/middleware/auth.js', () => ({ + ConflictingOrganizationSelectionError: ConflictingOrganizationSelectionErrorMock, + selectedOrganizationForAuthentication: (req: Record, providerOrg: string) => { + const headers = req.headers as Record | undefined; + const query = req.query as Record | undefined; + const body = req.body as Record | undefined; + const params = req.params as Record | undefined; + const supplied = [providerOrg, headers?.['x-organization-id'], query?.org, + query?.organization_id, query?.organizationId, body?.organization_id, + body?.organizationId, params?.orgId, params?.organizationId] + .filter((value) => value !== undefined); + if (new Set(supplied).size > 1) { + throw new ConflictingOrganizationSelectionErrorMock(); + } + return providerOrg; + }, validateWorkOSApiKey: (...args: unknown[]) => validateWorkOSApiKeyMock(...args), })); -vi.mock('jose', () => ({ +vi.mock('jose', async (importOriginal) => ({ + ...await importOriginal(), createRemoteJWKSet: () => 'fake-jwks-set', jwtVerify: (...args: unknown[]) => jwtVerifyMock(...args), decodeJwt: (...args: unknown[]) => decodeJwtMock(...args), @@ -39,9 +58,18 @@ const { resolveCallerOrgId, orgIdFromBearerJwt, __resetJwksForTests } = await im '../../src/routes/helpers/resolve-caller-org.js' ); -function reqWith(authHeader?: string, user?: { id?: string }) { +function reqWith( + authHeader?: string, + user?: { id?: string }, + selectors: Record = {}, +) { return { - headers: authHeader ? { authorization: authHeader } : {}, + query: {}, body: {}, params: {}, + ...selectors, + headers: { + ...(authHeader ? { authorization: authHeader } : {}), + ...((selectors.headers as Record | undefined) ?? {}), + }, user, }; } @@ -73,26 +101,24 @@ describe('resolveCallerOrgId', () => { expect(dbQueryMock).not.toHaveBeenCalled(); }); - it('falls through to API key / session when JWT verification fails', async () => { + it('rejects an invalid JWT without selecting an attached user organization', async () => { decodeJwtMock.mockReturnValueOnce({ iss: ISS }); - jwtVerifyMock.mockRejectedValueOnce(new Error('bad signature')); + jwtVerifyMock.mockRejectedValueOnce(Object.assign(new Error('bad signature'), { code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' })); validateWorkOSApiKeyMock.mockResolvedValueOnce(null); - const orgId = await resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi')); - - expect(orgId).toBeNull(); + await expect(resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi'))) + .rejects.toMatchObject({ status: 401 }); expect(jwtVerifyMock).toHaveBeenCalledTimes(1); expect(validateWorkOSApiKeyMock).toHaveBeenCalledTimes(1); }); - it('falls through when the JWT has no org_id claim', async () => { + it('does not infer an organization when the JWT has no org_id claim', async () => { decodeJwtMock.mockReturnValueOnce({ iss: ISS }); jwtVerifyMock.mockResolvedValueOnce({ payload: { sub: 'user_123' } }); validateWorkOSApiKeyMock.mockResolvedValueOnce(null); - const orgId = await resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi')); - - expect(orgId).toBeNull(); + await expect(resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi'))) + .rejects.toMatchObject({ status: 401 }); expect(validateWorkOSApiKeyMock).toHaveBeenCalledTimes(1); }); @@ -100,9 +126,8 @@ describe('resolveCallerOrgId', () => { decodeJwtMock.mockReturnValueOnce({ iss: 'https://evil.example.com/issuer' }); validateWorkOSApiKeyMock.mockResolvedValueOnce(null); - const orgId = await resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi')); - - expect(orgId).toBeNull(); + await expect(resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi'))) + .rejects.toMatchObject({ status: 401 }); expect(jwtVerifyMock).not.toHaveBeenCalled(); }); @@ -110,9 +135,8 @@ describe('resolveCallerOrgId', () => { decodeJwtMock.mockReturnValueOnce({ sub: 'user_no_iss' }); validateWorkOSApiKeyMock.mockResolvedValueOnce(null); - const orgId = await resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi')); - - expect(orgId).toBeNull(); + await expect(resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi'))) + .rejects.toMatchObject({ status: 401 }); expect(jwtVerifyMock).not.toHaveBeenCalled(); }); @@ -131,6 +155,23 @@ describe('resolveCallerOrgId', () => { expect(dbQueryMock).not.toHaveBeenCalled(); }); + it.each([ + ['header', { headers: { 'x-organization-id': 'org_other' } }], + ['query org', { query: { org: 'org_other' } }], + ['query organization_id', { query: { organization_id: 'org_other' } }], + ['query organizationId', { query: { organizationId: 'org_other' } }], + ['body organization_id', { body: { organization_id: 'org_other' } }], + ['body organizationId', { body: { organizationId: 'org_other' } }], + ['route orgId', { params: { orgId: 'org_other' } }], + ['route organizationId', { params: { organizationId: 'org_other' } }], + ] as const)('rejects an API-key provider conflict from %s without caller fallback', async (_location, selectors) => { + validateWorkOSApiKeyMock.mockResolvedValueOnce({ organizationId: 'org_from_apikey' }); + + await expect(resolveCallerOrgId(reqWith('Bearer sk_live_abc123', { id: 'cookie_user' }, selectors))) + .rejects.toMatchObject({ status: 403 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + it('returns org from a legacy wos_api_key_ prefix key', async () => { validateWorkOSApiKeyMock.mockResolvedValueOnce({ organizationId: 'org_legacy' }); @@ -141,6 +182,189 @@ describe('resolveCallerOrgId', () => { expect(jwtVerifyMock).not.toHaveBeenCalled(); }); + it.each(['Bearer sk_revoked', 'Bearer wos_api_key_rotated', 'bearer sk_revoked', 'BEARER sk_revoked'])( + 'does not fall back to an attached user or implicit org for rejected key %s', async (authorization) => { + validateWorkOSApiKeyMock.mockResolvedValueOnce(null); + dbQueryMock.mockResolvedValue({ rows: [{ primary_organization_id: 'org_other', joins_valid: true }] }); + + await expect(resolveCallerOrgId(reqWith(authorization, { id: 'user_other' }))).rejects.toMatchObject({ status: 401 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }, + ); + + it('propagates unavailable key authorization without a primary-org fallback', async () => { + const unavailable = Object.assign(new Error('API key validation unavailable'), { status: 503 }); + validateWorkOSApiKeyMock.mockRejectedValueOnce(unavailable); + + await expect(resolveCallerOrgId(reqWith('Bearer sk_unavailable', { id: 'user_other' }))) + .rejects.toMatchObject({ status: 503 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + + it.each(['Bearer', 'bearer ', 'BEARER ', 'Bearer wrong-static-token', 'Bearer malformed-opaque-token'])( + 'does not select the cookie organization for explicit bearer %j', async (authorization) => { + validateWorkOSApiKeyMock.mockResolvedValue(null); + dbQueryMock.mockResolvedValue({ rows: [{ primary_organization_id: 'org_other', joins_valid: true }] }); + + await expect(resolveCallerOrgId(reqWith(authorization, { id: 'user_other' }))).rejects.toMatchObject({ status: 401 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + expect(jwtVerifyMock).not.toHaveBeenCalled(); + }, + ); + + it.each(['ERR_JWT_EXPIRED', 'ERR_JWT_INVALID', 'ERR_JWT_CLAIM_VALIDATION_FAILED', 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', 'ERR_JWKS_NO_MATCHING_KEY'])( + 'does not select the cookie organization for definitive JWT failure %s', async (code) => { + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockRejectedValue(Object.assign(new Error('Invalid token'), { code })); + validateWorkOSApiKeyMock.mockResolvedValue(null); + + await expect(resolveCallerOrgId(reqWith('bearer eyJinvalid.payload.sig', { id: 'user_other' }))).rejects.toMatchObject({ status: 401 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }, + ); + + it('does not infer the cookie organization when a verified JWT lacks an organization', async () => { + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockResolvedValue({ payload: { sub: 'user_jwt' } }); + validateWorkOSApiKeyMock.mockResolvedValue(null); + + await expect(resolveCallerOrgId(reqWith('BEARER eyJnoorg.payload.sig', { id: 'user_other' }))).rejects.toMatchObject({ status: 401 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + + it.each([ + new Error('Unknown provider failure'), + Object.assign(new Error('JWKS HTTP failure'), { code: 'ERR_JOSE_GENERIC' }), + Object.assign(new Error('Malformed JWKS'), { code: 'ERR_JWKS_INVALID' }), + Object.assign(new Error('JWKS timeout'), { code: 'ERR_JWKS_TIMEOUT' }), + Object.assign(new Error('Connection failed'), { code: 'ECONNRESET' }), + Object.assign(new Error('Provider error'), { status: 503 }), + ])('propagates unavailable JWT verification without cookie or API-key fallback: %s', async (error) => { + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockRejectedValue(error); + + await expect(resolveCallerOrgId(reqWith('bEaReR\teyJunavailable.payload.sig', { id: 'user_other' }))) + .rejects.toMatchObject({ status: 503 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + expect(validateWorkOSApiKeyMock).not.toHaveBeenCalled(); + }); + + it('recognizes alternate bearer formatting for valid JWTs without cookie fallback', async () => { + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockResolvedValue({ payload: { org_id: 'org_selected' } }); + + expect(await resolveCallerOrgId(reqWith('bEaReR\t eyJvalid.payload.sig', { id: 'user_other' }))).toBe('org_selected'); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['removed', undefined], + ['replaced with a valid API key', 'Bearer sk_injected'], + ['replaced with a different authentication scheme', 'Basic dXNlcjpwYXNz'], + ] as const)('keeps an invalid JWT terminal when Authorization is %s during verification', async (_change, replacement) => { + let rejectVerification!: (error: Error) => void; + const verification = new Promise((_resolve, reject) => { rejectVerification = reject; }); + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockReturnValueOnce(verification); + validateWorkOSApiKeyMock.mockImplementation(async (validationReq: { headers: { authorization?: string } }) => ( + validationReq.headers.authorization === 'Bearer sk_injected' + ? { organizationId: 'org_injected' } + : null + )); + dbQueryMock.mockResolvedValue({ rows: [{ primary_organization_id: 'org_cookie', joins_valid: true }] }); + const incoming = reqWith('Bearer eyJoriginal.payload.sig', { id: 'user_cookie' }); + + const result = resolveCallerOrgId(incoming).then( + (organizationId) => ({ organizationId }), + (error: { status: number }) => ({ status: error.status }), + ); + expect(jwtVerifyMock).toHaveBeenCalledWith('eyJoriginal.payload.sig', expect.any(Function), expect.any(Object)); + if (replacement === undefined) delete incoming.headers.authorization; + else incoming.headers.authorization = replacement; + rejectVerification(Object.assign(new Error('Expired original JWT'), { code: 'ERR_JWT_EXPIRED' })); + + expect(await result).toEqual({ status: 401 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + + it('keeps unavailable JWT verification terminal after the bearer header is removed', async () => { + let rejectVerification!: (error: Error) => void; + const verification = new Promise((_resolve, reject) => { rejectVerification = reject; }); + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockReturnValueOnce(verification); + const incoming = reqWith('bEaReR\teyJoriginal.payload.sig', { id: 'user_cookie' }); + + const result = resolveCallerOrgId(incoming).then( + (organizationId) => ({ organizationId }), + (error: { status: number }) => ({ status: error.status }), + ); + expect(jwtVerifyMock).toHaveBeenCalledTimes(1); + delete incoming.headers.authorization; + rejectVerification(new Error('JWKS unavailable')); + + expect(await result).toEqual({ status: 503 }); + expect(validateWorkOSApiKeyMock).not.toHaveBeenCalled(); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['headers', 'x-organization-id'], + ['query', 'organizationId'], + ['body', 'organizationId'], + ['params', 'organizationId'], + ] as const)('retains an original %s selector conflict across JWT verification', async (location, field) => { + let finishVerification!: (result: { payload: { org_id: string } }) => void; + const verification = new Promise((resolve) => { finishVerification = resolve; }); + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockReturnValueOnce(verification); + const incoming = reqWith('Bearer eyJoriginal.payload.sig', { id: 'user_cookie' }, { + [location]: { [field]: 'org_conflicting' }, + }); + + const result = resolveCallerOrgId(incoming).then( + (organizationId) => ({ organizationId }), + (error: { status: number }) => ({ status: error.status }), + ); + expect(jwtVerifyMock).toHaveBeenCalledTimes(1); + (incoming[location] as Record)[field] = 'org_verified'; + finishVerification({ payload: { org_id: 'org_verified' } }); + + expect(await result).toEqual({ status: 403 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + + it('retains a matching organization selector when another middleware replaces it during JWT verification', async () => { + let finishVerification!: (result: { payload: { org_id: string } }) => void; + const verification = new Promise((resolve) => { finishVerification = resolve; }); + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockReturnValueOnce(verification); + const incoming = reqWith('Bearer eyJoriginal.payload.sig', { id: 'user_cookie' }, { + query: { organizationId: 'org_verified' }, + }); + + const result = resolveCallerOrgId(incoming); + expect(jwtVerifyMock).toHaveBeenCalledTimes(1); + incoming.query = { organizationId: 'org_other' }; + finishVerification({ payload: { org_id: 'org_verified' } }); + + expect(await result).toBe('org_verified'); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + + it.each([ + [Object.assign(new Error('Expired token'), { code: 'ERR_JWT_EXPIRED' }), 401], + [new Error('JWT provider unavailable'), 503], + ] as const)('reports credential failure before inspecting conflicting selectors: %s', async (error, status) => { + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockRejectedValue(error); + validateWorkOSApiKeyMock.mockResolvedValue(null); + + await expect(resolveCallerOrgId(reqWith('Bearer eyJfailure.payload.sig', { id: 'user_cookie' }, { + query: { organizationId: 'org_conflicting' }, + }))).rejects.toMatchObject({ status }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + // ── Sealed-session path (existing behavior) ───────────────────── it('falls back to users.primary_organization_id when only req.user is set', async () => { @@ -232,9 +456,9 @@ describe('orgIdFromBearerJwt', () => { await orgIdFromBearerJwt(reqWith('Bearer eyJa.b.c')); await orgIdFromBearerJwt(reqWith('Bearer eyJa.b.c')); - // Same jwks instance passed on repeats of same client, new one for other. + // Each verification wraps the cached resolver to preserve key-service failures. const jwksArgs = jwtVerifyMock.mock.calls.map(c => c[1]); - expect(jwksArgs[0]).toBe(jwksArgs[1]); + expect(jwksArgs.every((resolver) => typeof resolver === 'function')).toBe(true); // (Both JWKSets are the same fake string from the mock factory, but the // behavior under test is that we call createRemoteJWKSet once per client. // We rely on the cache map to dedupe — if it didn't, Map.size would be 2.) diff --git a/server/tests/unit/user-authorization-snapshot-timeout.test.ts b/server/tests/unit/user-authorization-snapshot-timeout.test.ts index 610eb4a4302..ae5adc89708 100644 --- a/server/tests/unit/user-authorization-snapshot-timeout.test.ts +++ b/server/tests/unit/user-authorization-snapshot-timeout.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { queryWithTimeout } from '../../src/db/client.js'; +import { loadApiKeyManagementSnapshot } from '../../src/db/api-key-management-db.js'; import { AuthorizationSnapshotUnavailableError, loadAuthorizationSnapshot, @@ -60,6 +61,37 @@ describe('authorization snapshot query deadline and connection retry', () => { expect(boundedQuery).toHaveBeenCalledOnce(); }); + it.each([ + { terminal_marker: true }, { primary_count: '0' }, { primary_count: '2' }, + { primary_count: undefined }, { authenticated_user_id: null }, + { identity_id: null }, { canonical_user_id: null }, + ])('management denies terminal or ambiguous lifecycle state without retry: %j', async invalid => { + boundedQuery.mockResolvedValue(queryResult(invalid)); + expect(await loadApiKeyManagementSnapshot(USER_ID, ORGANIZATION_ID)).toBeNull(); + expect(boundedQuery).toHaveBeenCalledOnce(); + }); + + it('management retains exact bigint epochs and ignores grants in a valid lifecycle snapshot', async () => { + boundedQuery.mockResolvedValue(queryResult({ authorization_epoch: '9007199254740993', grant_role: 'owner' })); + const snapshot = await loadApiKeyManagementSnapshot(USER_ID, ORGANIZATION_ID); + expect(snapshot).toMatchObject({ + authenticatedUserId: USER_ID, canonicalUserId: USER_ID, + authorizationEpoch: '9007199254740993', credentialGrant: null, + }); + expect(boundedQuery).toHaveBeenCalledOnce(); + expect(boundedQuery.mock.calls[0][0]).not.toContain('organization_credential_grants'); + }); + + it('management fails unavailable on recovery or audit-read failure without retry', async () => { + boundedQuery.mockResolvedValueOnce(queryResult({ in_recovery: true })); + await expect(loadApiKeyManagementSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toThrow(AuthorizationSnapshotUnavailableError); + boundedQuery.mockRejectedValueOnce(new Error('audit store unavailable')); + await expect(loadApiKeyManagementSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toThrow(AuthorizationSnapshotUnavailableError); + expect(boundedQuery).toHaveBeenCalledTimes(2); + }); + it('retries one transient connection failure with only the remaining absolute budget', async () => { boundedQuery.mockImplementationOnce(async () => { vi.setSystemTime(Date.now() + 700); diff --git a/server/tests/unit/working-group-admin-global-auth.test.ts b/server/tests/unit/working-group-admin-global-auth.test.ts index 659a8f9fffb..8fab4c86c1e 100644 --- a/server/tests/unit/working-group-admin-global-auth.test.ts +++ b/server/tests/unit/working-group-admin-global-auth.test.ts @@ -112,7 +112,7 @@ function validatedTenantKey(permission: 'admin:*' | 'admin:read') { return { apiKey: { id: `key_${permission}`, - owner: { id: 'org_tenant' }, + owner: { type: 'organization', id: 'org_tenant' }, name: 'Tenant admin key', permissions: [permission], }, diff --git a/server/tests/unit/workos-jwt.test.ts b/server/tests/unit/workos-jwt.test.ts index db2dea87903..a4118b9fe8b 100644 --- a/server/tests/unit/workos-jwt.test.ts +++ b/server/tests/unit/workos-jwt.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { SignJWT, generateKeyPair, exportJWK, type KeyLike } from 'jose'; +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { SignJWT, generateKeyPair, exportJWK, createLocalJWKSet, errors, type KeyLike } from 'jose'; import { looksLikeJWT, verifyWorkOSJWT, __setJWKSForTesting, + isInvalidWorkOSJWTError, } from '../../src/auth/workos-jwt.js'; // WORKOS_CLIENT_ID must be set before workos-jwt.ts is imported for the @@ -55,6 +56,9 @@ describe('verifyWorkOSJWT', () => { const kp = await generateKeyPair('RS256'); privateKey = kp.privateKey; publicKey = kp.publicKey; + }); + + beforeEach(async () => { const jwk = await exportJWK(publicKey); // Swap in a static-key resolver that matches any `alg: RS256` kid. __setJWKSForTesting(async () => ({ ...jwk, alg: 'RS256' })); @@ -104,7 +108,7 @@ describe('verifyWorkOSJWT', () => { sub: 'user_01ABC', azp: 'client_01DIFFERENT_APP', }); - await expect(verifyWorkOSJWT(token)).rejects.toThrow(/application id/); + await expect(verifyWorkOSJWT(token)).rejects.toMatchObject({ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: expect.stringMatching(/application id/) }); }); it('rejects a token whose client_id does not match WORKOS_CLIENT_ID', async () => { @@ -112,26 +116,72 @@ describe('verifyWorkOSJWT', () => { sub: 'user_01ABC', client_id: 'client_01DIFFERENT_APP', }); - await expect(verifyWorkOSJWT(token)).rejects.toThrow(/application id/); + await expect(verifyWorkOSJWT(token)).rejects.toMatchObject({ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: expect.stringMatching(/application id/) }); }); it('rejects a token with no azp and no client_id', async () => { const token = await mint({ sub: 'user_01ABC' }); - await expect(verifyWorkOSJWT(token)).rejects.toThrow(/application id/); + await expect(verifyWorkOSJWT(token)).rejects.toMatchObject({ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: expect.stringMatching(/application id/) }); }); it('rejects an expired token', async () => { const token = await mint({ sub: 'user_01ABC', azp: EXPECTED_AZP }, '-1s'); - await expect(verifyWorkOSJWT(token)).rejects.toThrow(); + await expect(verifyWorkOSJWT(token)).rejects.toMatchObject({ code: 'ERR_JWT_EXPIRED' }); }); it('rejects a token signed by an unknown key', async () => { const other = await generateKeyPair('RS256'); + const jwk = await exportJWK(publicKey); + __setJWKSForTesting(createLocalJWKSet({ keys: [{ ...jwk, alg: 'RS256', kid: 'known' }] })); const token = await new SignJWT({ sub: 'user_01ABC', azp: EXPECTED_AZP }) - .setProtectedHeader({ alg: 'RS256' }) + .setProtectedHeader({ alg: 'RS256', kid: 'unknown' }) .setExpirationTime('5m') .sign(other.privateKey); - await expect(verifyWorkOSJWT(token)).rejects.toThrow(); + const error = await verifyWorkOSJWT(token).catch((error: unknown) => error); + expect(error).toMatchObject({ code: 'ERR_JWKS_NO_MATCHING_KEY' }); + expect(isInvalidWorkOSJWTError(error)).toBe(true); + }); + + it('separates malformed provider key material from an invalid token', async () => { + const token = await mint({ sub: 'user_01ABC', azp: EXPECTED_AZP }); + const jwk = await exportJWK(publicKey); + __setJWKSForTesting(createLocalJWKSet({ keys: [{ ...jwk, alg: 'RS256' }] })); + await expect(verifyWorkOSJWT(token)).resolves.toMatchObject({ sub: 'user_01ABC' }); + + // An RSA key without its required modulus cannot be imported by Web Crypto. + __setJWKSForTesting(createLocalJWKSet({ keys: [{ kty: 'RSA', e: jwk.e, alg: 'RS256' }] })); + await expect(verifyWorkOSJWT(token)).rejects.toMatchObject({ status: 503 }); + }); + + it('treats unsupported provider key resolver errors as unavailable', async () => { + const token = await mint({ sub: 'user_01ABC', azp: EXPECTED_AZP }); + __setJWKSForTesting(async () => { + throw new errors.JOSENotSupported('Unsupported provider key material'); + }); + + const error = await verifyWorkOSJWT(token).catch((error: unknown) => error); + + expect(error).toMatchObject({ status: 503 }); + expect(isInvalidWorkOSJWTError(error)).toBe(false); + }); + + it('still identifies unsupported token headers as invalid before key lookup', async () => { + const token = await mint({ sub: 'user_01ABC', azp: EXPECTED_AZP }); + const parts = token.split('.'); + parts[0] = Buffer.from(JSON.stringify({ alg: 'RS256', crit: ['unknown'], unknown: true })).toString('base64url'); + const error = await verifyWorkOSJWT(parts.join('.')).catch((error: unknown) => error); + expect(error).toMatchObject({ code: 'ERR_JOSE_NOT_SUPPORTED' }); + expect(isInvalidWorkOSJWTError(error)).toBe(true); + }); + + it.each(['HS256', 'unknown', 'none'])('rejects token algorithm %s before calling the key service', async (alg) => { + __setJWKSForTesting(async () => { throw new Error('Key service unavailable'); }); + const token = await mint({ sub: 'user_01ABC', azp: EXPECTED_AZP }); + const parts = token.split('.'); + parts[0] = Buffer.from(JSON.stringify({ alg })).toString('base64url'); + const error = await verifyWorkOSJWT(parts.join('.')).catch((error: unknown) => error); + expect(error).toMatchObject({ code: 'ERR_JOSE_ALG_NOT_ALLOWED' }); + expect(isInvalidWorkOSJWTError(error)).toBe(true); }); it('flags M2M tokens (client_credentials)', async () => {