From 1e0116c28de9cbcf9e7f09b1dc7b83618f75ef91 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 17 Sep 2026 08:05:52 +0000 Subject: [PATCH] fix(auth): isolate API-key authority and contain unfenced mutations Bind API-key authentication and management to the exact credential and explicit organization. Require direct active membership for management, validate opaque resource identifiers, and keep unfenced key mutations disabled. Buffer inventory until final authority checks pass. Preserve the snapshot/authn-cache parent and add route, concurrency, lifecycle, and PostgreSQL regressions. --- server/public/dashboard-api-keys.html | 15 +- server/src/auth/workos-jwt.ts | 47 +- server/src/conformance/token-route.ts | 12 +- server/src/db/api-key-management-db.ts | 217 ++++ server/src/middleware/api-key-format.ts | 20 + server/src/middleware/auth.ts | 295 +++-- server/src/routes/admin.ts | 10 + server/src/routes/api-keys.ts | 460 +++++--- server/src/routes/community-mirrors.ts | 22 +- .../src/routes/helpers/resolve-caller-org.ts | 121 +- server/src/routes/registry-api.ts | 7 +- .../api-key-exact-credential.test.ts | 523 +++++++++ .../api-key-management-auth.test.ts | 416 +++++++ .../integration/content-my-content.test.ts | 1 + .../tests/integration/event-speakers.test.ts | 1 + .../integration/events-draft-preview.test.ts | 1 + .../integration/perspective-assets.test.ts | 1 + .../unit/addie-admin-global-auth.test.ts | 2 +- .../unit/api-key-dashboard-role-gate.test.ts | 64 +- .../api-key-exact-credential-auth.test.ts | 545 +++++++++ .../unit/api-key-issuance-permissions.test.ts | 1036 ++++++++++++++++- .../unit/api-key-validation-timeout.test.ts | 147 +++ .../unit/api-key-widget-containment.test.ts | 105 ++ .../unit/auth-dev-bearer-terminal.test.ts | 87 ++ .../unit/authorization-epoch-session.test.ts | 8 +- .../tests/unit/caller-org-auth-errors.test.ts | 188 +++ ...tification-api-key-membership-gate.test.ts | 2 +- .../unit/conformance-token-route.test.ts | 40 +- .../member-profile-admin-global-auth.test.ts | 2 +- .../unit/network-health-security.test.ts | 2 +- .../optional-auth-api-key-membership.test.ts | 4 +- .../unit/portrait-admin-global-auth.test.ts | 2 +- server/tests/unit/resolve-caller-org.test.ts | 264 ++++- ...ser-authorization-snapshot-timeout.test.ts | 32 + .../working-group-admin-global-auth.test.ts | 2 +- server/tests/unit/workos-jwt.test.ts | 66 +- 36 files changed, 4342 insertions(+), 425 deletions(-) create mode 100644 server/src/db/api-key-management-db.ts create mode 100644 server/tests/integration/api-key-exact-credential.test.ts create mode 100644 server/tests/integration/api-key-management-auth.test.ts create mode 100644 server/tests/unit/api-key-exact-credential-auth.test.ts create mode 100644 server/tests/unit/api-key-validation-timeout.test.ts create mode 100644 server/tests/unit/api-key-widget-containment.test.ts create mode 100644 server/tests/unit/auth-dev-bearer-terminal.test.ts create mode 100644 server/tests/unit/caller-org-auth-errors.test.ts 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 () => {