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 () => {