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/organization-principal.ts b/server/src/auth/organization-principal.ts index dccf139df37..5f4ef47cb06 100644 --- a/server/src/auth/organization-principal.ts +++ b/server/src/auth/organization-principal.ts @@ -2,7 +2,7 @@ import type { WorkOSUser } from "../types.js"; export type OrgAuthorizationPrincipal = Pick< WorkOSUser, - "id" | "authWorkosUserId" + "id" | "authWorkosUserId" | "authorizationSnapshot" >; /** diff --git a/server/src/auth/organization-selection.ts b/server/src/auth/organization-selection.ts new file mode 100644 index 00000000000..7c8b08124d6 --- /dev/null +++ b/server/src/auth/organization-selection.ts @@ -0,0 +1,24 @@ +import type { Request } from 'express'; + +export class ConflictingOrganizationSelectionError extends Error {} + +/** Every explicit selector must agree with the authenticated provider selection. + * Request fields cannot switch an organization-bound credential to another org. + * Neither memberships nor caches pick an organization. */ +export function selectedOrganizationForAuthentication( + req: Partial>, + providerOrg?: string, +): string | null { + const selectors = [ + providerOrg, + req.headers?.['x-organization-id'], req.query?.org, req.query?.org_id, req.query?.organization_id, req.query?.organizationId, + req.body?.org_id, req.body?.organization_id, req.body?.organizationId, + req.params?.org_id, req.params?.orgId, req.params?.organizationId, + ].filter((value) => value !== undefined); + if (selectors.some((value) => typeof value !== 'string' || !value.trim())) { + throw new ConflictingOrganizationSelectionError(); + } + const ids = new Set(selectors.map((value) => (value as string).trim())); + if (ids.size > 1) throw new ConflictingOrganizationSelectionError(); + return ids.values().next().value ?? null; +} 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/client.ts b/server/src/db/client.ts index cfc6dfb77f9..172688bccfa 100644 --- a/server/src/db/client.ts +++ b/server/src/db/client.ts @@ -147,8 +147,65 @@ export function withDatabaseDeadline( return queryDeadline.run({ deadlineMs, readOnly: options.readOnly ?? true }, work); } +function databaseDeadlineExceededError(): Error & { code: '57014' } { + return Object.assign(new Error('Database query deadline exceeded'), { code: '57014' as const }); +} + +/** + * Check out a pooled client without allowing pool saturation to outlive the + * caller's absolute deadline. A client delivered after the timer wins is + * released immediately so a timed-out request cannot leak a pool slot. + */ +async function checkoutClientBeforeDeadline(deadlineMs: number): Promise { + const remainingMs = deadlineMs - Date.now(); + if (remainingMs <= 0) throw databaseDeadlineExceededError(); + + const checkout = getPool().connect(); + let timeout: ReturnType | undefined; + let timedOut = false; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + timedOut = true; + reject(databaseDeadlineExceededError()); + }, remainingMs); + }); + + try { + return await Promise.race([checkout, deadline]); + } catch (error) { + if (timedOut) { + void checkout.then( + (lateClient) => lateClient.release(), + () => undefined, + ); + } + throw error; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +async function getClientBeforeDeadline( + deadlineMs: number, + retryTransientCheckout: boolean, +): Promise { + const attempts = retryTransientCheckout ? 2 : 1; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await checkoutClientBeforeDeadline(deadlineMs); + } catch (error) { + if (attempt === attempts - 1 || !isTransientConnectionError(error) || Date.now() >= deadlineMs) { + throw error; + } + console.warn('Transient DB connection error, retrying client checkout:', (error as Error).message); + } + } + throw databaseDeadlineExceededError(); +} + /** - * Execute one query with server-enforced statement and lock deadlines. + * Execute one query with deadline-bounded pool checkout plus server-enforced + * statement and lock deadlines. * * Use this for public read paths whose inputs can select unusually large * registry fan-outs. The transaction-local settings ensure PostgreSQL stops @@ -159,18 +216,22 @@ export async function queryWithTimeout( text: string, params: any[] | undefined, timeoutMs: number, + options: { retryTransientCheckout?: boolean } = {}, ): Promise> { const inheritedDeadline = queryDeadline.getStore(); const deadlineMs = Math.min( Date.now() + timeoutMs, inheritedDeadline?.deadlineMs ?? Number.POSITIVE_INFINITY, ); - const client = await getClient(); + const client = await getClientBeforeDeadline( + deadlineMs, + options.retryTransientCheckout ?? true, + ); let transactionStarted = false; try { const effectiveTimeoutMs = deadlineMs - Date.now(); if (effectiveTimeoutMs <= 0) { - throw Object.assign(new Error('Database query deadline exceeded'), { code: '57014' }); + throw databaseDeadlineExceededError(); } await client.query(inheritedDeadline?.readOnly === false ? 'BEGIN' : 'BEGIN READ ONLY'); transactionStarted = true; diff --git a/server/src/db/user-authorization-snapshot-db.ts b/server/src/db/user-authorization-snapshot-db.ts new file mode 100644 index 00000000000..263982e1281 --- /dev/null +++ b/server/src/db/user-authorization-snapshot-db.ts @@ -0,0 +1,190 @@ +import { isTransientConnectionError, queryWithTimeout } from './client.js'; + +const SNAPSHOT_TIMEOUT_MS = 2_000; + +/** The exact credential and its authority, observed in one primary-database statement. */ +export interface AuthorizationSnapshot { + readonly authenticatedUserId: string; + readonly canonicalUserId: string; + readonly identityId: string | null; + readonly selectedOrganizationId: string | null; + /** PostgreSQL bigint text: converting to a number would lose revocations above 2^53. */ + readonly authorizationEpoch: string; + readonly credential: Readonly<{ + email: string | null; + emailVerified: boolean; + firstName: string | null; + lastName: string | null; + }>; + readonly credentialGrant: Readonly<{ + id: string; + organizationId: string; + role: 'owner' | 'admin' | 'member'; + effectiveFrom: string; + effectiveUntil: string | null; + }> | null; +} + +/** Distinct from a missing credential or denied organization access. */ +export class AuthorizationSnapshotUnavailableError extends Error { + constructor(message = 'Authorization snapshot unavailable') { + super(message); + this.name = 'AuthorizationSnapshotUnavailableError'; + } +} + +interface SnapshotRow { + in_recovery: boolean; + 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; + grant_id: string | null; + grant_organization_id: string | null; + grant_role: 'owner' | 'admin' | 'member' | null; + grant_effective_from: string | null; + grant_effective_until: string | null; +} + +/** A retry may replace a broken connection, never renew the statement budget. */ +async function querySnapshot(statement: string, parameters: [string, string | null]) { + const deadlineMs = Date.now() + SNAPSHOT_TIMEOUT_MS; + for (let attempt = 0; attempt < 2; attempt++) { + const remainingMs = deadlineMs - Date.now(); + if (remainingMs <= 0) throw new AuthorizationSnapshotUnavailableError(); + try { + const result = await queryWithTimeout(statement, parameters, remainingMs, { + // querySnapshot owns the one retry so checkout and statement failures + // share this request's single absolute authorization budget. + retryTransientCheckout: false, + }); + if (Date.now() >= deadlineMs) throw new AuthorizationSnapshotUnavailableError(); + return result; + } catch (error) { + if (attempt === 1 || !isTransientConnectionError(error) || Date.now() >= deadlineMs) throw error; + } + } + throw new AuthorizationSnapshotUnavailableError(); +} + +/** + * Never hydrate identity and epoch separately: a binding change can commit + * between the reads and stamp old attribution with the new epoch. One SELECT + * gives every field the same PostgreSQL MVCC snapshot. The bounded query wrapper + * uses the primary pool with transaction-local statement and lock timeouts; + * a recovering replica fails closed in this statement. + * + * Organization selection must be explicit. Neither linked credentials nor the + * organization_memberships cache supplies authority on a cold start/cache miss. + */ +export async function loadAuthorizationSnapshot( + authenticatedUserId: string, + selectedOrganizationId: string | null, +): Promise { + const organizationId = selectedOrganizationId || null; + try { + const result = await querySnapshot( + `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(epoch.epoch, 0)::text AS authorization_epoch, + credential.email, credential.email_verified, credential.first_name, credential.last_name, + credential_grant.id AS grant_id, + credential_grant.workos_organization_id AS grant_organization_id, + credential_grant.role AS grant_role, + to_char(credential_grant.effective_from AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS grant_effective_from, + to_char(credential_grant.effective_until AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS grant_effective_until + 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 identity_workos_users primary_binding + ON primary_binding.identity_id = binding.identity_id AND primary_binding.is_primary + LEFT JOIN authorization_epochs epoch + ON epoch.workos_user_id = credential.workos_user_id + LEFT JOIN organization_credential_grants credential_grant + ON credential_grant.workos_user_id = credential.workos_user_id + AND credential_grant.workos_organization_id = $2 + AND credential_grant.revoked_at IS NULL + AND credential_grant.effective_from <= statement_timestamp() + AND (credential_grant.effective_until IS NULL + OR credential_grant.effective_until > statement_timestamp())`, + [authenticatedUserId, organizationId], + ); + const row = result.rows[0]; + if (!row || row.in_recovery) { + throw new AuthorizationSnapshotUnavailableError(); + } + if (!row.authenticated_user_id) return null; + // Migration 460 backfills bindings and creates them on every user insert. + // Missing attribution is an integrity failure, not a singleton fallback. + if (!row.identity_id || !row.canonical_user_id) { + throw new AuthorizationSnapshotUnavailableError(); + } + + const credentialGrant = row.grant_id + ? Object.freeze({ + id: row.grant_id, + organizationId: row.grant_organization_id!, + role: row.grant_role!, + effectiveFrom: row.grant_effective_from!, + effectiveUntil: row.grant_effective_until, + }) + : 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, + }); + } catch (error) { + if (error instanceof AuthorizationSnapshotUnavailableError) throw error; + // Callers must distinguish unavailable from denied without exposing raw DB + // errors or reusing stale identity, organization, or grant cache entries. + throw new AuthorizationSnapshotUnavailableError(); + } +} + +export function sameAuthorizationIdentity( + previous: AuthorizationSnapshot, + current: AuthorizationSnapshot, +): boolean { + return previous.authenticatedUserId === current.authenticatedUserId + && previous.canonicalUserId === current.canonicalUserId + && previous.identityId === current.identityId + && previous.authorizationEpoch === current.authorizationEpoch + && previous.credential.email === current.credential.email + && previous.credential.emailVerified === current.credential.emailVerified; +} + +/** Compare equality, not epoch ordering: deletions and replay can move backwards. */ +export function sameAuthorizationSnapshot( + previous: AuthorizationSnapshot, + current: AuthorizationSnapshot, +): boolean { + if (!sameAuthorizationIdentity(previous, current) + || previous.selectedOrganizationId !== current.selectedOrganizationId) return false; + const before = previous.credentialGrant; + const after = current.credentialGrant; + if (!before || !after) return before === after; + return before.id === after.id + && before.organizationId === after.organizationId + && before.role === after.role + && before.effectiveFrom === after.effectiveFrom + && before.effectiveUntil === after.effectiveUntil; +} 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 b1e3c756f70..aed1d64c0cd 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -10,11 +10,11 @@ import { } from '../auth/admin-access.js'; import { isWebUserAAOAdmin } from '../addie/mcp/admin-tools.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 { getPool } from '../db/client.js'; -import { getAuthorizationFingerprint } from '../db/authorization-epoch-db.js'; +import { loadAuthorizationSnapshot, AuthorizationSnapshotUnavailableError, type AuthorizationSnapshot } from '../db/user-authorization-snapshot-db.js'; +import { ConflictingOrganizationSelectionError, selectedOrganizationForAuthentication } from '../auth/organization-selection.js'; import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js'; import { constantTimeEqual } from '../utils/constant-time-equal.js'; import { resolveEffectiveMembership } from '../db/org-filters.js'; @@ -25,6 +25,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!; @@ -35,8 +42,8 @@ interface CachedSession { accessToken: string; expiresAt: number; newSealedSession?: string; // Set if session was refreshed - /** Persisted authorization epoch observed when this entry was stored (#6827). */ - authorizationFingerprint?: string; + /** Explicit organization from the authenticated provider session, never inferred. */ + orgId?: string; } const sessionCache = new Map(); @@ -183,6 +190,14 @@ function isTransientAuthError(err: unknown): boolean { if (!(err instanceof Error)) return false; const name = err.name; const code = (err as { code?: unknown }).code; + const status = (err as { status?: unknown; statusCode?: unknown }).status + ?? (err as { statusCode?: unknown }).statusCode; + if (typeof status === 'number' && (status === 408 || status === 429 || (status >= 500 && status < 600))) return true; + // jose does not retain the HTTP status when the remote JWKS endpoint fails. + // This exact error denotes key-service failure, not token signature failure. + if (name === 'JOSEError' && code === 'ERR_JOSE_GENERIC' + && (err.message === 'Expected 200 OK from the JSON Web Key Set HTTP response' + || err.message === 'Failed to parse the JSON Web Key Set HTTP response as JSON')) return true; if (name === 'JWKSTimeout' || name === 'TimeoutError' || name === 'AbortError') return true; if (typeof code === 'string') { if (code === 'ERR_JWKS_TIMEOUT') return true; @@ -195,6 +210,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) */ @@ -218,6 +243,11 @@ declare global { interface Request { user?: WorkOSUser; accessToken?: string; + /** Set only after sealed bearer authentication and fresh snapshot hydration. */ + authenticatedSealedBearer?: Readonly<{ + tokenHash: string; + authorizationSnapshot: AuthorizationSnapshot; + }>; company?: Company; companyUser?: CompanyUser; /** Authority path used by requireAdmin, retained for audited mutations. */ @@ -243,33 +273,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], + }; } /** @@ -280,13 +334,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}`, @@ -296,10 +344,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 @@ -313,19 +399,15 @@ export interface ValidatedBearerJWT { /** * Short-lived positive cache for successfully-validated bearer JWTs. - * Keyed on SHA-256 of the raw token; value includes the synthesized user + * Keyed on SHA-256 of the raw token; value includes verified provider claims * and an expiry that is the minimum of (token exp, now + BEARER_JWT_CACHE_TTL_MS). * - * Without this, every REST request with a Bearer JWT incurs a DB round-trip - * for the local-user lookup — the cookie path has an equivalent cache - * (`sessionCache`) and its absence here creates a DoS amplifier. + * Only signature-verified provider claims live here. Request authority is + * hydrated afresh from the primary database; no canonical user or grant is cached. */ interface CachedBearerJWT { - user: WorkOSUser; - orgId?: string; + verified: Awaited>; expiresAt: number; - /** Persisted authorization epoch observed when this entry was stored (#6827). */ - authorizationFingerprint?: string; } const bearerJwtCache = new Map(); const BEARER_JWT_CACHE_TTL_MS = 60 * 1000; @@ -351,9 +433,8 @@ function hashBearerToken(token: string): string { * `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); + 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 @@ -362,66 +443,37 @@ export async function validateWorkOSBearerJWT(req: Request): Promise>; if (cached && cached.expiresAt > now) { - if (await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint)) { - return { user: cached.user, rawToken: token, orgId: cached.orgId }; + verified = cached.verified; + } else { + try { + verified = await verifyWorkOSJWT(token); + } catch (err) { + if (isInvalidWorkOSJWTError(err)) return null; + throw new WorkOSJWTUnavailableError(); } - // Identity bindings changed since this entry was stored — re-verify. - bearerJwtCache.delete(cacheKey); - } - - let verified: Awaited>; - try { - verified = await verifyWorkOSJWT(token); - } catch (err) { - logger.debug({ err }, 'Bearer JWT verification failed'); - return null; } - if (verified.isM2M || !verified.sub) return null; - // Confirm the subject corresponds to a real local user. This catches - // tokens from WorkOS accounts that have been deleted or never synced, - // and gives us names for the synthesized WorkOSUser. - const pool = getPool(); - const localUser = await pool.query<{ - first_name: string | null; - last_name: string | null; - email: string | null; - }>( - `SELECT first_name, last_name, email FROM users WHERE workos_user_id = $1`, - [verified.sub], - ); - if (localUser.rowCount === 0) { - logger.warn({ sub: verified.sub }, 'Bearer JWT verified but user not found in local DB'); - return null; - } - - const row = localUser.rows[0]; - const email = verified.email ?? row.email ?? ''; - const user: WorkOSUser = { + // Only provider authentication is cached. Identity, grants and the epoch + // always come from the same fresh primary-database snapshot, even on hits. + const user = await hydrateAuthenticatedUser({ id: verified.sub, - email, - firstName: row.first_name?.trim() || undefined, - lastName: row.last_name?.trim() || undefined, + email: verified.email ?? '', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }; + }, selectedOrganizationForAuthentication(req, verified.orgId)); const tokenExpMs = verified.expiresAt ? verified.expiresAt * 1000 : Infinity; const cacheUntil = Math.min(now + BEARER_JWT_CACHE_TTL_MS, tokenExpMs); - if (cacheUntil > now) { + if ((!cached || cached.expiresAt <= now) && cacheUntil > now) { if (bearerJwtCache.size >= BEARER_JWT_CACHE_MAX_SIZE) { const oldest = bearerJwtCache.keys().next().value; if (oldest) bearerJwtCache.delete(oldest); } - bearerJwtCache.set(cacheKey, { - user, - orgId: verified.orgId, - expiresAt: cacheUntil, - authorizationFingerprint: await authorizationFingerprintFor(user), - }); + bearerJwtCache.set(cacheKey, { verified, expiresAt: cacheUntil }); } return { user, rawToken: token, orgId: verified.orgId }; @@ -703,136 +755,98 @@ if (ADMIN_API_KEY) { */ function hasValidAdminApiKey(req: Request): boolean { if (!ADMIN_API_KEY) return false; - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) return false; - const token = authHeader.slice(7); + const token = getBearerToken(req.headers.authorization); + if (!token) return false; // Don't match WorkOS API keys - those are handled separately if (isWorkOSApiKeyFormat(token)) return false; return constantTimeEqual(token, ADMIN_API_KEY); } -/** - * True if `id` is a synthetic user (static admin API key or per-org WorkOS - * API key). Synthetic users don't represent a person, so they have no - * identity binding. - */ -function isSyntheticUser(id: string): boolean { - return id === 'admin_api_key' || id.startsWith('api_key_'); -} - /** * Drop any cached sessions whose WorkOS user id matches one of the given - * ids, on either auth credential (post-swap canonical or actual auth). - * Called when an identity binding changes (mergeUsers, admin rebind) so - * subsequent requests re-resolve identity instead of serving a stale swap. + * ids. This saves a provider round trip after local mutations; correctness + * comes from the fresh database snapshot on every request, on every instance. */ export function invalidateSessionsForUsers(workosUserIds: string[]): void { if (workosUserIds.length === 0) return; const ids = new Set(workosUserIds); for (const [key, value] of sessionCache.entries()) { - if (ids.has(value.user.id) || (value.user.authWorkosUserId && ids.has(value.user.authWorkosUserId))) { + if (ids.has(value.user.id)) { sessionCache.delete(key); } } for (const [key, value] of bearerJwtCache.entries()) { - if (ids.has(value.user.id) || (value.user.authWorkosUserId && ids.has(value.user.authWorkosUserId))) { + if (ids.has(value.verified.sub)) { bearerJwtCache.delete(key); } } } -/** - * Resolve the identity for a WorkOS user. Sets `user.identityId` and, when - * the authenticated user is a non-primary binding, swaps `user.id` to the - * identity's primary workos_user_id so app-state reads land on the right - * person. The original authenticated id is preserved on `user.authWorkosUserId`. - * - * Skipped for synthetic users (admin API key, WorkOS API key) — they don't - * represent a person. Failures are swallowed: identity resolution must - * never block an authenticated request, and a degraded request that sees - * only the auth user's slice of data is still better than a 500. - */ -async function attachIdentityId(user: WorkOSUser): Promise { - if (isSyntheticUser(user.id)) return; - try { - const result = await getPool().query<{ - identity_id: string; - primary_workos_user_id: string | null; - }>( - `SELECT iwu.identity_id, primary_iwu.workos_user_id AS primary_workos_user_id - FROM identity_workos_users iwu - LEFT JOIN identity_workos_users primary_iwu - ON primary_iwu.identity_id = iwu.identity_id - AND primary_iwu.is_primary = TRUE - WHERE iwu.workos_user_id = $1`, - [user.id] - ); - const row = result.rows[0]; - if (!row) return; - - user.identityId = row.identity_id; +class InvalidAuthorizationCredentialError extends Error {} +export { ConflictingOrganizationSelectionError, selectedOrganizationForAuthentication } from '../auth/organization-selection.js'; + +/** Hydrate a new request object; never mutate the provider object in a cache. */ +async function hydrateAuthenticatedUser(user: WorkOSUser, organizationId: string | null): Promise { + const authenticatedUserId = getOrganizationAuthorizationUserId(user); + const snapshot = await loadAuthorizationSnapshot(authenticatedUserId, organizationId); + if (!snapshot) throw new InvalidAuthorizationCredentialError(); + const hydrated: WorkOSUser = { + ...user, + id: snapshot.canonicalUserId, + authWorkosUserId: snapshot.canonicalUserId !== authenticatedUserId ? authenticatedUserId : undefined, + identityId: snapshot.identityId ?? undefined, + email: snapshot.credential.email ?? '', + emailVerified: snapshot.credential.emailVerified, + firstName: snapshot.credential.firstName?.trim() || user.firstName?.trim() || undefined, + lastName: snapshot.credential.lastName?.trim() || user.lastName?.trim() || undefined, + }; + // Server-only authority must not leak through JSON responses or user spreads. + Object.defineProperty(hydrated, 'authorizationSnapshot', { value: snapshot }); + return hydrated; +} - if (row.primary_workos_user_id && row.primary_workos_user_id !== user.id) { - // Non-primary binding signed in. Swap id so app-state reads see the - // canonical person; preserve the actual auth user on authWorkosUserId. - logger.debug( - { authWorkosUserId: user.id, canonicalUserId: row.primary_workos_user_id, identityId: row.identity_id }, - 'Identity id-swap: routing non-primary binding to canonical user' - ); - user.authWorkosUserId = user.id; - user.id = row.primary_workos_user_id; - } - } catch (err) { - logger.warn({ err, userId: user.id }, 'Failed to resolve identity_id'); - } +/** Request-local provenance, never retained in the shared cookie/session cache. */ +function markAuthenticatedSealedBearer(req: Request, bearerToken: string | null): void { + if (!bearerToken || getBearerToken(req.headers.authorization) !== bearerToken || !req.user?.authorizationSnapshot) return; + Object.defineProperty(req, 'authenticatedSealedBearer', { + value: Object.freeze({ + tokenHash: hashSessionCookie(bearerToken), + authorizationSnapshot: req.user.authorizationSnapshot, + }), + configurable: true, + }); } -/** - * Authorization fingerprint for the credential that actually authenticated - * this session. `attachIdentityId` may swap `user.id` to the identity's - * canonical credential, so the authenticated credential is read through - * `getOrganizationAuthorizationUserId` — that value is stable across the - * swap, and every identity-binding mutation bumps the epoch for each - * credential bound to the identity, so a change always moves this value. - * - * Synthetic principals (admin API key, WorkOS API key) have no credential - * row and no identity binding; they return '' and never mismatch. - */ -async function authorizationFingerprintFor( - user: WorkOSUser, -): Promise { - if (isSyntheticUser(user.id)) return ''; +/** 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 { try { - return await getAuthorizationFingerprint([getOrganizationAuthorizationUserId(user)]); - } catch (err) { - // Never fail authentication over the stamp: an unstamped entry reads as - // stale on its next hit, so the session is re-validated rather than - // served from a fingerprint we could not confirm. - logger.warn({ err }, 'Authorization epoch stamp failed — session will not be served from cache'); - return undefined; + return await hydrateAuthenticatedUser(user, null); + } catch { + return user; } } -/** - * True when a cached entry still reflects persisted authorization state. - * - * Entries cached before an identity-binding change carry the pre-change - * fingerprint and must not be served — on any instance, not just the one - * that ran the mutation. A lookup failure returns false so the request - * falls through to full re-validation rather than serving state we could - * not confirm; that degrades the cache, it does not sign anyone out. - */ -async function isAuthorizationFingerprintCurrent( - user: WorkOSUser, - stamped: string | undefined, -): Promise { - if (isSyntheticUser(user.id)) return true; - try { - return (await authorizationFingerprintFor(user)) === (stamped ?? ''); - } catch (err) { - logger.warn({ err }, 'Authorization epoch check failed — bypassing session cache'); - return false; +function sendAuthorizationStateError(error: unknown, res: Response, includeTransientErrors = false): boolean { + 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; + } + if (error instanceof AuthorizationSnapshotUnavailableError) { + logger.warn({ err: error }, 'Primary authorization snapshot unavailable'); + res.status(503).json({ error: 'Authorization service temporarily unavailable' }); + return true; } + if (error instanceof InvalidAuthorizationCredentialError) { + res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); + return true; + } + if (error instanceof ConflictingOrganizationSelectionError) { + res.status(403).json({ error: 'An unambiguous organization selection is required' }); + return true; + } + return false; } /** @@ -844,7 +858,9 @@ async function isAuthorizationFingerprintCurrent( * 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/'); + delete req.authenticatedSealedBearer; + 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)) { @@ -864,32 +880,17 @@ 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). - const jwtAuth = await validateWorkOSBearerJWT(req); + let jwtAuth: ValidatedBearerJWT | null; + try { + jwtAuth = await validateWorkOSBearerJWT(req); + } catch (error) { + if (sendAuthorizationStateError(error, res, true)) return; + throw error; + } if (jwtAuth) { logger.debug({ path: req.path, userId: jwtAuth.user.id }, 'Authenticated via OAuth user JWT'); req.user = jwtAuth.user; @@ -897,8 +898,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio try { const userBan = await checkPlatformBan( - `user:${jwtAuth.user.id}`, - () => bansDb.checkPlatformBan(jwtAuth.user.id), + `user:${getOrganizationAuthorizationUserId(jwtAuth.user)}`, + () => bansDb.checkPlatformBan(getOrganizationAuthorizationUserId(jwtAuth.user)), ); if (userBan) { logger.info({ userId: jwtAuth.user.id, banId: userBan.id }, 'User request blocked by platform ban'); @@ -908,22 +909,25 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio logger.warn({ err: banError, userId: jwtAuth.user.id, path: req.path }, 'Ban check failed — allowing request through'); } - await attachIdentityId(req.user); 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 = devUser; + req.user = await hydrateDevUser(devUser); req.accessToken = 'dev-mode-token'; // Carry over dev config flags (isMember, isAdmin) so enrichUserWithMembership skips DB lookup const devConfig = getDevUser(req); if (devConfig) { (req.user as unknown as Record).isMember = devConfig.isMember; } - await attachIdentityId(req.user); return next(); } // No dev session - redirect to dev login page @@ -938,7 +942,7 @@ 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'); @@ -978,20 +982,14 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio deadSessionCache.delete(cacheKey); } - if ( - cached && - cached.expiresAt > now && - !(await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint)) - ) { - // Identity bindings changed since this entry was stored. Drop it so - // this request re-resolves identity and organization context instead - // of inheriting pre-change authority. - sessionCache.delete(cacheKey); - } else if (cached && cached.expiresAt > now) { + if (cached && cached.expiresAt > now) { // Cache hit - use cached session data logger.debug({ userId: cached.user.id }, 'Using cached session'); - req.user = cached.user; + req.user = await hydrateAuthenticatedUser( + cached.user, selectedOrganizationForAuthentication(req, cached.orgId), + ); req.accessToken = cached.accessToken; + markAuthenticatedSealedBearer(req, bearerToken); // If session was refreshed, update the cookie if (cached.newSealedSession) { @@ -1041,6 +1039,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; @@ -1068,6 +1069,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; } @@ -1098,6 +1100,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; @@ -1113,6 +1118,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'); } @@ -1122,6 +1128,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'); } } @@ -1165,54 +1172,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio impersonator?: { email: string; reason: string | null }; }; - let firstName = result.user.firstName ?? undefined; - let lastName = result.user.lastName ?? undefined; - - // Verify the user exists in our local DB. This catches stale sessions - // from deleted/merged WorkOS accounts whose JWTs haven't expired yet. - // Also resolves names from local DB when WorkOS doesn't provide them. - try { - const pool = getPool(); - const localUser = await pool.query<{ - first_name: string | null; - last_name: string | null; - }>( - `SELECT first_name, last_name FROM users WHERE workos_user_id = $1`, - [result.user.id] - ); - if (localUser.rows.length === 0) { - logger.warn({ userId: result.user.id, email: result.user.email, path: req.path }, - 'Authenticated user not found in local DB — forcing re-login'); - sessionCache.delete(cacheKey); - if (deadSessionCache.size >= DEAD_SESSION_MAX_SIZE) { - const oldest = deadSessionCache.keys().next().value; - if (oldest) deadSessionCache.delete(oldest); - } - deadSessionCache.set(cacheKey, Date.now()); - if (isHtmlRequest) { - return res.redirect(`/auth/login?return_to=${encodeURIComponent(req.originalUrl)}`); - } - return res.status(401).json({ - error: 'Invalid session', - message: 'Your account could not be verified. Please log in again.', - login_url: '/auth/login', - }); - } - if (!firstName?.trim()) { - const row = localUser.rows[0]; - if (row.first_name?.trim()) firstName = row.first_name; - if (row.last_name?.trim()) lastName = row.last_name; - } - } catch (err) { - // Fail open: DB errors should not block authenticated users - logger.warn({ err, userId: result.user.id }, 'Local user check failed — allowing request through'); - } - const user: WorkOSUser = { id: result.user.id, email: result.user.email, - firstName, - lastName, + firstName: result.user.firstName ?? undefined, + lastName: result.user.lastName ?? undefined, emailVerified: result.user.emailVerified, createdAt: result.user.createdAt, updatedAt: result.user.updatedAt, @@ -1227,20 +1191,22 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio ); } - // Resolve identityId once before caching so cache hits inherit it. - await attachIdentityId(user); + const hydrated = await hydrateAuthenticatedUser( + user, selectedOrganizationForAuthentication(req, result.organizationId), + ); - // Cache the validated session + // Retain authentication only; derived request authority is never cached. sessionCache.set(cacheKey, { - user, + user: Object.freeze(user), accessToken: result.accessToken, expiresAt: now + SESSION_CACHE_TTL_MS, newSealedSession, - authorizationFingerprint: await authorizationFingerprintFor(user), + orgId: result.organizationId, }); - req.user = user; + req.user = hydrated; req.accessToken = result.accessToken; + markAuthenticatedSealedBearer(req, bearerToken); // Check platform ban for cookie-authenticated user (fail-open: DB timeout should not log users out) try { @@ -1258,6 +1224,10 @@ 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) { @@ -1544,9 +1514,8 @@ export async function requireAdmin(req: Request, res: Response, next: NextFuncti if (!req.user) { const mockUser = createDevUser(req); if (mockUser) { - req.user = mockUser; + req.user = await hydrateDevUser(mockUser); req.accessToken = 'dev-mode-token'; - await attachIdentityId(req.user); } } @@ -1856,34 +1825,32 @@ export function createRequireWorkingGroupMember( * Native apps send: Authorization: Bearer */ function extractSealedSession(req: Request): string | undefined { - // First check for cookie (web browsers) - const sessionCookie = req.cookies['wos-session']; - if (sessionCookie) { - return sessionCookie; - } - - // Then check Authorization header (native apps like Tauri) + // An explicitly supplied bearer credential takes precedence over a cookie. + // A failed JWT must never be retried as a sealed session or replaced by a cookie. // Format: Authorization: Bearer - // Note: We differentiate from WorkOS API keys by checking prefix const authHeader = req.headers.authorization; - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.slice(7); - // WorkOS API keys use known prefixes, sealed sessions don't - if (!isWorkOSApiKeyFormat(token)) { + if (authHeader !== undefined) { + const token = getBearerToken(authHeader); + if (token && token.trim() && !isWorkOSApiKeyFormat(token) && !looksLikeJWT(token) && !token.startsWith('eyJ')) { return token; } + return undefined; } - - return undefined; + const sessionCookie = req.cookies?.['wos-session']; + return typeof sessionCookie === 'string' && sessionCookie ? sessionCookie : undefined; } /** - * Optional auth middleware - loads user if authenticated, but doesn't require it + * Optional auth permits anonymous requests only when no credential is supplied. + * Invalid presented credentials return 401; verification or authorization + * outages return 503. Neither case falls back to anonymous or another credential. * Uses in-memory cache to reduce WorkOS API calls for session refresh * Automatically refreshes expired access tokens using the refresh token * Supports both cookie-based auth (web) and Authorization header (native apps) */ export async function optionalAuth(req: Request, res: Response, next: NextFunction) { + delete req.authenticatedSealedBearer; + const bearerToken = getBearerToken(req.headers.authorization); if (hasValidAdminApiKey(req)) { logger.debug({ path: req.path }, 'Authenticated via static admin API key (optional auth)'); req.user = { @@ -1900,26 +1867,15 @@ 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; + if (await authenticateApiKeyRequest(req, res, next)) return; - 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(); + let jwtAuth: ValidatedBearerJWT | null; + try { + jwtAuth = await validateWorkOSBearerJWT(req); + } catch (error) { + if (sendAuthorizationStateError(error, res, true)) return; + throw error; } - - const jwtAuth = await validateWorkOSBearerJWT(req); if (jwtAuth) { logger.debug({ path: req.path, userId: jwtAuth.user.id }, 'Authenticated via OAuth user JWT (optional auth)'); req.user = jwtAuth.user; @@ -1927,8 +1883,8 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti try { const userBan = await checkPlatformBan( - `user:${jwtAuth.user.id}`, - () => bansDb.checkPlatformBan(jwtAuth.user.id), + `user:${getOrganizationAuthorizationUserId(jwtAuth.user)}`, + () => bansDb.checkPlatformBan(getOrganizationAuthorizationUserId(jwtAuth.user)), ); if (userBan) { logger.info({ userId: jwtAuth.user.id, banId: userBan.id }, 'User optional-auth request blocked by platform ban'); @@ -1938,30 +1894,33 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti logger.warn({ err: banError, userId: jwtAuth.user.id, path: req.path }, 'Ban check failed — allowing optional-auth request through'); } - await attachIdentityId(req.user); return next(); } // 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 = devUser; + req.user = await hydrateDevUser(devUser); req.accessToken = 'dev-mode-token'; // Carry over dev config flags (isMember, isAdmin) so enrichUserWithMembership skips DB lookup const devConfig = getDevUser(req); if (devConfig) { (req.user as unknown as Record).isMember = devConfig.isMember; } + return next(); } - // No dev session = not logged in (which is fine for optional auth) - return next(); } const sessionCookie = extractSealedSession(req); + // Only complete credential absence permits anonymous access. Keep one return + // for the entire branch so CodeQL recognizes this as an early-exit guard; + // supplied but unusable credentials still return 401 without a fallback. if (!sessionCookie) { - return next(); + return req.headers.authorization !== undefined || req.cookies?.['wos-session'] !== undefined + ? res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }) + : next(); } try { @@ -1974,22 +1933,19 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti const deadAt = deadSessionCache.get(cacheKey); if (deadAt) { if (now - deadAt < DEAD_SESSION_TTL_MS) { - return next(); // optional auth: just proceed without user + return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); } deadSessionCache.delete(cacheKey); } - if ( - cached && - cached.expiresAt > now && - !(await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint)) - ) { - sessionCache.delete(cacheKey); - } else if (cached && cached.expiresAt > now) { + if (cached && cached.expiresAt > now) { // Cache hit - use cached session data logger.debug({ userId: cached.user.id }, 'Using cached session (optional auth)'); - req.user = cached.user; + req.user = await hydrateAuthenticatedUser( + cached.user, selectedOrganizationForAuthentication(req, cached.orgId), + ); req.accessToken = cached.accessToken; + markAuthenticatedSealedBearer(req, bearerToken); // If session was refreshed, update the cookie if (cached.newSealedSession) { @@ -2019,6 +1975,9 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti cookiePassword: WORKOS_COOKIE_PASSWORD, }); + if (!refreshResult.authenticated && 'retryable' in refreshResult && refreshResult.retryable) { + return res.status(503).json({ error: 'Authentication service temporarily unavailable' }); + } if (refreshResult.authenticated && refreshResult.sealedSession) { logger.debug('Session refreshed successfully (optional auth)'); newSealedSession = refreshResult.sealedSession; @@ -2036,6 +1995,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti refreshFailed = true; } } catch (refreshError) { + if (getBearerToken(req.headers.authorization) !== null || isTransientAuthError(refreshError)) throw refreshError; logger.debug({ err: refreshError }, 'Optional auth refresh failed'); refreshFailed = true; } @@ -2057,6 +2017,9 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti const sharedRefresh = await sharedSessionObj.refresh({ cookiePassword: WORKOS_COOKIE_PASSWORD, }); + if (!sharedRefresh.authenticated && 'retryable' in sharedRefresh && sharedRefresh.retryable) { + return res.status(503).json({ error: 'Authentication service temporarily unavailable' }); + } if (sharedRefresh.authenticated && sharedRefresh.sealedSession) { newSealedSession = sharedRefresh.sealedSession; setSessionCookie(res, sharedRefresh.sealedSession); @@ -2070,6 +2033,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti result = await refreshedObj.authenticate(); } } catch (innerRefreshErr) { + if (getBearerToken(req.headers.authorization) !== null || isTransientAuthError(innerRefreshErr)) throw innerRefreshErr; logger.debug({ err: innerRefreshErr }, 'Shared session refresh also failed (optional auth)'); } } else { @@ -2079,6 +2043,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti } } catch (dbError) { logger.debug({ err: dbError }, 'Failed to look up shared session (optional auth)'); + return res.status(503).json({ error: 'Authentication service temporarily unavailable' }); } } } @@ -2092,6 +2057,8 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti if (oldest) deadSessionCache.delete(oldest); } deadSessionCache.set(cacheKey, Date.now()); + sessionCache.delete(cacheKey); + return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); } if (result.authenticated && 'user' in result && result.user) { @@ -2119,26 +2086,30 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti ); } - // Resolve identityId before caching — sessionCache is shared with - // requireAuth, so skipping it here would let an optionalAuth request - // poison subsequent requireAuth cache hits with identityId=undefined. - await attachIdentityId(user); + const hydrated = await hydrateAuthenticatedUser( + user, selectedOrganizationForAuthentication(req, result.organizationId), + ); - // Cache the validated session + // Retain authentication only; derived request authority is never cached. sessionCache.set(cacheKey, { - user, + user: Object.freeze(user), accessToken: result.accessToken, expiresAt: now + SESSION_CACHE_TTL_MS, newSealedSession, - authorizationFingerprint: await authorizationFingerprintFor(user), + orgId: result.organizationId, }); - req.user = user; + req.user = hydrated; req.accessToken = result.accessToken; + markAuthenticatedSealedBearer(req, bearerToken); } } catch (error) { - // Silently fail for optional auth + 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' }); } next(); diff --git a/server/src/routes/api-keys.ts b/server/src/routes/api-keys.ts index 32d43a0ad8d..1cc2e871f55 100644 --- a/server/src/routes/api-keys.ts +++ b/server/src/routes/api-keys.ts @@ -2,8 +2,8 @@ * 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. + * Requires an authenticated user credential and an explicitly selected org. + * Verifies fresh, exact-credential authority before every operation. */ import type { Request, Response } from "express"; @@ -11,10 +11,12 @@ import { Router } from "express"; import { WorkOS } from "@workos-inc/node"; import { createLogger } from "../logger.js"; import { requireAuth } from "../middleware/auth.js"; +import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js"; import { - resolveUserOrgMembership, - type UserOrgMembership, -} from "../utils/resolve-user-org-membership.js"; + evaluateUserOrgRoleAuthorization, + resolveUserOrgAuthorization, + type UserOrgAuthorizationMembership, +} from "../utils/resolve-user-org-authorization.js"; const logger = createLogger("api-keys-routes"); @@ -36,6 +38,8 @@ const AUTH_ENABLED = !!( const workos = AUTH_ENABLED ? new WorkOS(WORKOS_API_KEY!, { clientId: process.env.WORKOS_CLIENT_ID!, + timeout: 5_000, + maxRetries: 0, }) : null; @@ -88,32 +92,49 @@ async function workosRequest( } /** - * 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" || !value || value !== value.trim()) || + 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 extends UserOrgAuthorizationMembership { + actor: { + userId: string; + authWorkosUserId: string; + identityId?: string; + }; } /** @@ -122,18 +143,52 @@ 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; + } + + // No cached organization_memberships or linked credentials participate here. + // Existing grants explicitly assign a role to this exact credential and org; + // they do not delegate use of another binding or inherit its membership. + const resolution = await resolveUserOrgAuthorization( + workos, + { id: actor.userId, authWorkosUserId: actor.authWorkosUserId, authorizationSnapshot }, + organizationId, + ); + const decision = evaluateUserOrgRoleAuthorization(resolution, "admin"); + if (decision.status === "unavailable") { + res.status(503).json({ error: "Organization authorization unavailable" }); + return null; + } + if (decision.status === "authorized" && decision.membership.organizationId === organizationId) { + return { ...decision.membership, actor }; + } res.status(403).json({ error: "Access denied", message: `Only organization owners and admins can ${action} API keys`, }); - return false; + return null; } /** @@ -159,20 +214,14 @@ export function createApiKeysRouter(): Router { router.get("/", requireAuth, 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 = {}; @@ -182,7 +231,7 @@ export function createApiKeysRouter(): Router { const result = await workosRequest( "GET", - `/organizations/${authorizedOrganizationId}/api_keys`, + `/organizations/${encodeURIComponent(authorizedOrganizationId)}/api_keys`, { query: params }, ); @@ -197,21 +246,14 @@ export function createApiKeysRouter(): Router { router.post("/", requireAuth, 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) || 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", - }); - } + const requestedOrganizationId = selectedOrganizationId(req, res); + if (!requestedOrganizationId) return; - const membership = await verifyOrgMembership(req, res, requestedOrganizationId); - if (!membership || !requireOrgAdmin(membership, res, "create")) return; + const membership = await authorizeKeyManagement(req, res, requestedOrganizationId, "create"); + if (!membership) return; const authorizedOrganizationId = membership.organizationId; const { name, permissions } = req.body; @@ -250,13 +292,13 @@ export function createApiKeysRouter(): Router { const result = await workosRequest( "POST", - `/organizations/${authorizedOrganizationId}/api_keys`, + `/organizations/${encodeURIComponent(authorizedOrganizationId)}/api_keys`, { body }, ); logger.info( { - userId: req.user!.id, + ...membership.actor, organizationId: authorizedOrganizationId, keyName: name, }, @@ -274,28 +316,29 @@ export function createApiKeysRouter(): Router { router.delete("/:id", requireAuth, 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, "revoke")) return; + const membership = await authorizeKeyManagement(req, res, requestedOrganizationId, "revoke"); + if (!membership) return; const authorizedOrganizationId = membership.organizationId; const apiKeyId = req.params.id; + // Every accepted route ID first completes exact admin authorization. + // URL normalizes dot segments even after encodeURIComponent, so reject + // them before a key DELETE can become a request to the organization. + if (apiKeyId === "." || apiKeyId === "..") { + return res.status(400).json({ error: "Invalid API key ID" }); + } + try { - await workosRequest("DELETE", `/organizations/${authorizedOrganizationId}/api_keys/${apiKeyId}`); + await workosRequest("DELETE", `/organizations/${encodeURIComponent(authorizedOrganizationId)}/api_keys/${encodeURIComponent(apiKeyId)}`); logger.info( { - userId: req.user!.id, + ...membership.actor, organizationId: authorizedOrganizationId, apiKeyId, }, @@ -309,7 +352,7 @@ export function createApiKeysRouter(): Router { if (status !== 404) throw error; logger.info( { - userId: req.user!.id, + ...membership.actor, organizationId: authorizedOrganizationId, apiKeyId, }, diff --git a/server/src/routes/community-mirrors.ts b/server/src/routes/community-mirrors.ts index 28604698804..c21a7238c78 100644 --- a/server/src/routes/community-mirrors.ts +++ b/server/src/routes/community-mirrors.ts @@ -8,7 +8,7 @@ * * GET /api/registry/mirrors — list mirrors (public, with etags) * GET /api/registry/mirrors/:platform — read one mirror (public) - * PUT /api/registry/mirrors/:platform — publish for moderators, propose for other callers + * PUT /api/registry/mirrors/:platform — publish with admin keys, propose for other callers * GET /api/registry/mirror-proposals — review queue or caller's own proposals * POST /api/registry/mirror-proposals/:id/{approve,reject} — moderation * @@ -26,12 +26,10 @@ import type { CommunityMirror, CommunityMirrorProposal } from '../db/community-m import { PublisherDatabase } from '../db/publisher-db.js'; import type { CatalogEventsDatabase } from '../db/catalog-events-db.js'; import { getClient } from '../db/client.js'; -import { isRegistryModerator } from '../services/brand-logo-auth.js'; -import { isWebUserAAOAdmin } from '../addie/admin-status-lookup.js'; 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, @@ -83,17 +81,14 @@ export interface CommunityMirrorRouterConfig { } /** - * Organization API keys represent an organization, not a human working-group - * member, so they intentionally submit proposals instead of passing this gate. + * Human management is disabled until #7452's exact-principal registry gate is + * integrated with the authorization snapshot. The legacy admin/moderator + * lookups consume canonical person IDs and cannot authorize this boundary. + * Only the authenticated platform admin API key retains management access; + * organization API keys intentionally submit proposals instead. */ -async function canManageMirrors(userId: string): Promise { - if (userId === 'admin_api_key') return true; - if (userId.startsWith('api_key_')) return false; - const [isOrganizationAdmin, isModerator] = await Promise.all([ - isWebUserAAOAdmin(userId), - isRegistryModerator(userId), - ]); - return isOrganizationAdmin || isModerator; +function canManageMirrors(userId: string): boolean { + return userId === 'admin_api_key'; } async function resolveManager( @@ -105,18 +100,24 @@ async function resolveManager( res.status(401).json({ error: 'Authentication required' }); return null; } - if (!(await canManageMirrors(userId))) { + if (!canManageMirrors(userId)) { res.status(403).json({ - error: 'Only registry moderators or AgenticAdvertising.org administrators can manage community mirrors', + error: 'Community mirror management requires a platform admin API key', }); return null; } 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; + if (req.user?.id.startsWith('api_key_') && attached) return attached; + return await resolveCallerOrgId(req); + } catch (error) { + if (sendCallerOrganizationAuthError(error, res)) return undefined; + throw error; + } } function reviewedContentDigest(document: Record): string { @@ -205,11 +206,15 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig) if (req.query.review_queue !== undefined && !['true', 'false'].includes(String(req.query.review_queue))) { return res.status(400).json({ error: 'Invalid review_queue flag' }); } - const isManager = await canManageMirrors(userId); + const isManager = canManageMirrors(userId); if (reviewQueue && !isManager) { - return res.status(403).json({ error: 'Registry moderator access is required for the review queue' }); + return res.status(403).json({ error: 'The review queue requires a platform admin API key' }); + } + const organizationId = isManager ? null : await callerOrganizationId(req, res); + if (organizationId === undefined) return; + if (!isManager && !organizationId) { + return res.status(404).json({ error: 'Community mirror proposal not found' }); } - const organizationId = isManager ? null : await callerOrganizationId(req); 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 { @@ -220,7 +225,6 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig) | 'rejected' | undefined, proposedByOrganizationId: isManager ? undefined : organizationId ?? undefined, - proposedByUserId: isManager || organizationId ? undefined : userId, limit: Number.isFinite(limit) ? limit : undefined, offset: Number.isFinite(offset) ? offset : undefined, }); @@ -239,13 +243,15 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig) return res.status(400).json({ error: 'Invalid proposal identifier' }); } try { + const isManager = canManageMirrors(userId); + const organizationId = isManager ? null : await callerOrganizationId(req, res); + if (organizationId === undefined) return; + if (!isManager && !organizationId) { + return res.status(404).json({ error: 'Community mirror proposal not found' }); + } const proposal = await mirrorDb.getProposalById(proposalId); if (!proposal) return res.status(404).json({ error: 'Community mirror proposal not found' }); - const isManager = await canManageMirrors(userId); - const organizationId = isManager ? null : await callerOrganizationId(req); - const ownsProposal = organizationId - ? proposal.proposed_by_organization_id === organizationId - : proposal.proposed_by_user_id === userId; + const ownsProposal = organizationId !== null && proposal.proposed_by_organization_id === organizationId; if (!ownsProposal && !isManager) { return res.status(404).json({ error: 'Community mirror proposal not found' }); } @@ -454,8 +460,9 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig) const userId = req.user?.id; if (!userId) return res.status(401).json({ error: 'Authentication required' }); - const isManager = await canManageMirrors(userId); - const organizationId = isManager ? null : await callerOrganizationId(req); + const isManager = canManageMirrors(userId); + 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..5411ba2b09e 100644 --- a/server/src/routes/helpers/resolve-caller-org.ts +++ b/server/src/routes/helpers/resolve-caller-org.ts @@ -7,17 +7,27 @@ * 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. Sealed cookie or authenticated native bearer session — middleware supplies a fresh + * authorization snapshot. Only an explicitly selected organization + * authorized for the exact credential is returned; primary orgs are ignored. + * + * Native bearer sessions require middleware transport provenance before using + * the exact snapshot. No sealed transport selects an implicit organization. */ -import type { Request } from 'express'; +import type { Request, Response } from 'express'; +import { createHash } from 'node:crypto'; import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTVerifyGetKey } from 'jose'; -import { isWorkOSApiKeyFormat } from '../../middleware/api-key-format.js'; +import { getBearerToken, isWorkOSApiKeyFormat } from '../../middleware/api-key-format.js'; +import { + ConflictingOrganizationSelectionError, + selectedOrganizationForAuthentication, +} from '../../auth/organization-selection.js'; import { validateWorkOSApiKey } from '../../middleware/auth.js'; -import { resolvePrimaryOrganization } from '../../db/users-db.js'; +import { isInvalidWorkOSJWTError, unavailableJWTKeyService, WorkOSJWTUnavailableError } from '../../auth/workos-jwt.js'; +import { getAuthorizationEnforcementWorkos } from '../../auth/workos-client.js'; +import { getOrganizationAuthorizationUserId, type OrgAuthorizationPrincipal } from '../../auth/organization-principal.js'; +import { resolveUserOrgAuthorization } from '../../utils/resolve-user-org-authorization.js'; import { createLogger } from '../../logger.js'; const logger = createLogger('resolve-caller-org'); @@ -42,17 +52,38 @@ function jwksForIssuer(iss: string): { jwks: JWTVerifyGetKey; clientId: string } return { jwks, clientId }; } -export type MinimalReq = Pick & { user?: { id?: string } }; +export type MinimalReq = Pick & Partial> & { + user?: Partial; +}; + +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 +100,116 @@ 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 apiKey.organizationId; + const apiKey = await validateWorkOSApiKey(req as Request); + if (apiKey) return { kind: 'verified', organizationId: apiKey.organizationId }; + + return { kind: 'invalid' }; + } catch { + throw new CallerOrganizationAuthError(503); + } +} - if (req.user?.id) { +/** Missing, denied, stale or unavailable sealed-session authority is public-only. + * Nullable consumers must never reinterpret it as canonical-user ownership. */ +async function resolveSealedSessionOrganization(req: MinimalReq): Promise { + const user = req.user; + if (user?.id && user.id !== 'admin_api_key' && !user.id.startsWith('api_key_')) { + const principal: OrgAuthorizationPrincipal = { + id: user.id, + authWorkosUserId: user.authWorkosUserId, + authorizationSnapshot: user.authorizationSnapshot, + }; + const snapshot = principal.authorizationSnapshot; + if (!snapshot || snapshot.authenticatedUserId !== getOrganizationAuthorizationUserId(principal) + || snapshot.canonicalUserId !== principal.id) return null; try { - return await resolvePrimaryOrganization(req.user.id); + // Route params may become available only after authentication middleware. + // Reuse its conflict policy before consuming the persisted selection. + const organizationId = selectedOrganizationForAuthentication(req, snapshot.selectedOrganizationId ?? undefined); + if (!organizationId) return null; + // A snapshot selection is not a grant. This resolver rechecks exact + // membership/grant and brackets WorkOS reads with fresh snapshot checks. + const authorization = await resolveUserOrgAuthorization( + getAuthorizationEnforcementWorkos(), principal, organizationId, + ); + return authorization.status === 'authorized' ? authorization.membership.organizationId : null; } catch (err) { - logger.warn({ err, userId: req.user.id }, 'caller org resolution failed — falling back to public-only'); + logger.warn({ err }, 'Explicit caller organization authorization unavailable or conflicting'); + return null; } } return null; } +/** + * Resolve the caller's organization from a verified credential result. + * Authenticated sealed sessions can have no selected organization. Other + * supplied bearers must authorize an organization or fail explicitly. + */ +export async function resolveCallerOrgId(req: MinimalReq): Promise { + const authorization = req.headers.authorization; + const bearerToken = getBearerToken(authorization); + if (bearerToken === null) return resolveSealedSessionOrganization(req); + + const sealedBearer = req.authenticatedSealedBearer; + if (sealedBearer) { + // Bind provenance to both the presented token and the exact middleware + // snapshot. Replaced credentials or principals cannot reuse it. + if (sealedBearer.tokenHash !== createHash('sha256').update(bearerToken).digest('hex') + || sealedBearer.authorizationSnapshot !== req.user?.authorizationSnapshot) { + throw new CallerOrganizationAuthError(401); + } + return resolveSealedSessionOrganization(req); + } + + // 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 06d2b7396ae..a20206778cd 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -200,7 +200,7 @@ import { isWebUserAAOAdmin } from "../addie/admin-status-lookup.js"; import { isBreakGlassAdminEmail } from "../auth/admin-access.js"; import { getDevUser, isDevModeEnabled } from "../middleware/auth.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 { @@ -6050,6 +6050,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" }); } @@ -6091,6 +6092,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' }); } @@ -6131,6 +6133,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' }); } @@ -6783,6 +6786,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" }); } @@ -9644,6 +9648,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/src/types.ts b/server/src/types.ts index f9fef3edb8a..8ff1eba3646 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -393,6 +393,8 @@ export interface Impersonator { } export interface WorkOSUser { + /** Immutable primary-DB snapshot for this request only; never serialize or cache. */ + readonly authorizationSnapshot?: import('./db/user-authorization-snapshot-db.js').AuthorizationSnapshot; /** * The canonical workos_user_id for app-state queries. For singleton * identities this equals the authenticated WorkOS user. For non-primary diff --git a/server/src/utils/resolve-user-org-authorization.ts b/server/src/utils/resolve-user-org-authorization.ts index 142b94b63ee..a0bc851c1f6 100644 --- a/server/src/utils/resolve-user-org-authorization.ts +++ b/server/src/utils/resolve-user-org-authorization.ts @@ -1,19 +1,24 @@ import type { WorkOS } from "@workos-inc/node"; import type { OrgAuthorizationPrincipal } from "../auth/organization-principal.js"; import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js"; -import { query } from "../db/client.js"; +import { + loadAuthorizationSnapshot, + sameAuthorizationIdentity, + sameAuthorizationSnapshot, + type AuthorizationSnapshot, +} from "../db/user-authorization-snapshot-db.js"; import { createLogger } from "../logger.js"; import { resolveUserRole } from "./resolve-user-role.js"; const logger = createLogger("resolve-user-org-authorization"); export type MembershipRole = "owner" | "admin" | "member"; -export type OrgAuthorizationSource = "workos" | "credential_grant"; +export type OrgAuthorizationSource = "workos" | "credential_grant" | "authorization_snapshot"; export interface UserOrgAuthorizationMembership { organizationId: string; role: MembershipRole; - source: OrgAuthorizationSource; + source: "workos" | "credential_grant"; } export type UserOrgAuthorizationResolution = @@ -52,7 +57,34 @@ export async function resolveUserOrgAuthorization( principal: OrgAuthorizationPrincipal, organizationId: string ): Promise { + const forbidden: UserOrgAuthorizationResolution = { + status: "forbidden", complete: true, unavailableSources: [], + }; + const unavailable = { + status: "unavailable", complete: false, unavailableSources: ["authorization_snapshot"], + } satisfies UserOrgAuthorizationResolution; + // Signing in alone does not select an organization or confer tenant access. + if (!organizationId?.trim()) return forbidden; const userId = getOrganizationAuthorizationUserId(principal); + let snapshot: AuthorizationSnapshot | null; + try { + snapshot = await loadAuthorizationSnapshot(userId, organizationId); + } catch (err) { + logger.warn({ err }, "Primary authorization snapshot unavailable"); + return unavailable; + } + if (!snapshot) return forbidden; + const previous = principal.authorizationSnapshot; + if (previous) { + // A sign-in with no org can bind an explicit route selection here. Once + // selected, a context belongs to that org and grant: replay cannot switch + // it or silently upgrade a revoked/replaced grant without a fresh request. + const current = previous.selectedOrganizationId === null + ? sameAuthorizationIdentity(previous, snapshot) + : sameAuthorizationSnapshot(previous, snapshot); + if (!current) return forbidden; + } + let directMembership: UserOrgAuthorizationMembership | null = null; let workosAvailable = false; @@ -64,7 +96,7 @@ export async function resolveUserOrgAuthorization( organizationId, }); const matchingMemberships = memberships.data.filter( - (membership) => membership.organizationId === organizationId + (membership) => membership.userId === userId && membership.organizationId === organizationId ); const activeRow = matchingMemberships.find( (membership) => membership.status === "active" @@ -88,32 +120,25 @@ export async function resolveUserOrgAuthorization( logger.warn("WorkOS client unavailable; checking explicit credential grant"); } - let grantAvailable = false; - let grantMembership: UserOrgAuthorizationMembership | null = null; + // WorkOS is an asynchronous authority source. Reject this decision if the + // persisted identity, epoch, selection or grant moved while it was in flight. + // In particular, never combine a pre-change provider decision with a new grant. try { - const grant = await query<{ workos_organization_id: string; role: string }>( - `SELECT workos_organization_id, role - FROM organization_credential_grants - WHERE workos_user_id = $1 - AND workos_organization_id = $2 - AND revoked_at IS NULL - AND effective_from <= NOW() - AND (effective_until IS NULL OR effective_until > NOW()) - LIMIT 1`, - [userId, organizationId] - ); - grantAvailable = true; - const row = grant.rows[0]; - if (row && VALID_ROLES.has(row.role)) { - grantMembership = { - organizationId: row.workos_organization_id, - role: row.role as MembershipRole, - source: "credential_grant", - }; + const current = await loadAuthorizationSnapshot(userId, organizationId); + if (!current || !sameAuthorizationSnapshot(snapshot, current)) { + return forbidden; } } catch (err) { - logger.warn({ err }, "Credential grant lookup failed"); + logger.warn({ err }, "Primary authorization snapshot revalidation unavailable"); + return unavailable; } + const grantMembership: UserOrgAuthorizationMembership | null = snapshot.credentialGrant + ? { + organizationId: snapshot.credentialGrant.organizationId, + role: snapshot.credentialGrant.role, + source: "credential_grant", + } + : null; let membership = directMembership; if ( @@ -126,7 +151,6 @@ export async function resolveUserOrgAuthorization( const unavailableSources: OrgAuthorizationSource[] = []; if (!workosAvailable) unavailableSources.push("workos"); - if (!grantAvailable) unavailableSources.push("credential_grant"); if (membership) { return { 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..43dcb3a0ae2 --- /dev/null +++ b/server/tests/integration/api-key-exact-credential.test.ts @@ -0,0 +1,245 @@ +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 resolver, and grant validity queries run against PostgreSQL. +vi.mock('../../src/middleware/auth.js', () => ({ + requireAuth(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 exact credential grants 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 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 lifecycle(credential: string, organizationId: string) { + return Promise.all([ + list(credential, organizationId), + request(app).post(`/api/me/api-keys?org=${organizationId}`) + .set('x-authenticated-user', credential).send({ name: 'Scoped key' }), + request(app).delete(`/api/me/api-keys/key_scoped?org=${organizationId}`) + .set('x-authenticated-user', credential), + ]); + } + + 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; + } + + it('does not share either credential\'s exact organization grant through their common identity', async () => { + await grant(PRIMARY, PINNACLE, 'owner'); + await grant(LINKED, STREAMHAUS); + + const results = await Promise.all([ + list(PRIMARY, PINNACLE), list(LINKED, STREAMHAUS), + list(PRIMARY, STREAMHAUS), list(LINKED, PINNACLE), list(LINKED), + ]); + expect(results.map((result) => result.status)).toEqual([200, 200, 403, 403, 400]); + expect(mocks.fetch).toHaveBeenCalledTimes(2); + expect((await pool.query( + 'SELECT * FROM organization_memberships WHERE workos_user_id = ANY($1)', [[PRIMARY, LINKED]], + )).rows).toHaveLength(0); + }); + + it.each(['revoked', 'expired', 'future'] as const)('rejects every next key operation after an active exact grant becomes %s without changing source membership', async (state) => { + await pool.query( + `INSERT INTO organization_memberships + (workos_user_id, workos_organization_id, workos_membership_id, email, role) + VALUES ($1, $2, $3, $4, 'owner')`, + [PRIMARY, STREAMHAUS, 'mem_api_key_boundary_source', 'sam@pinnacle.example'], + ); + const sourceMemberships = async () => (await pool.query( + 'SELECT * FROM organization_memberships WHERE workos_user_id = ANY($1) ORDER BY workos_user_id', [[PRIMARY, LINKED]], + )).rows; + const before = await sourceMemberships(); + const id = await grant(LINKED, STREAMHAUS); + const active = await lifecycle(LINKED, STREAMHAUS); + expect(active.map((result) => result.status)).toEqual([200, 201, 204]); + expect(await sourceMemberships()).toEqual(before); + mocks.fetch.mockClear(); + + 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], + ); + } else { + 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'], + ); + } + const results = await lifecycle(LINKED, STREAMHAUS); + expect(results.map((result) => result.status)).toEqual([403, 403, 403]); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(await sourceMemberships()).toEqual(before); + }); + + it('reads committed grant revocation and replacement without trusting stale membership or primary org', async () => { + await pool.query( + `INSERT INTO organization_memberships + (workos_user_id, workos_organization_id, workos_membership_id, email, role) + VALUES ($1, $2, $3, $4, 'owner')`, + [LINKED, PINNACLE, 'mem_api_key_boundary_stale', 'sam@streamhaus.example'], + ); + const id = await grant(LINKED, STREAMHAUS); + expect((await list(LINKED, STREAMHAUS)).status).toBe(200); + await pool.query( + 'UPDATE organization_credential_grants SET revoked_at = NOW(), revoked_by_workos_user_id = $2 WHERE id = $1', + [id, PRIMARY], + ); + mocks.fetch.mockClear(); + + expect((await list(LINKED, STREAMHAUS)).status).toBe(403); + expect((await list(LINKED, PINNACLE)).status).toBe(403); + expect((await list(LINKED)).status).toBe(400); + await grant(LINKED, STREAMHAUS, 'member'); + expect((await list(LINKED, STREAMHAUS)).status).toBe(403); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect((await pool.query( + 'SELECT workos_organization_id, role FROM organization_memberships WHERE workos_user_id = $1', [LINKED], + )).rows).toEqual([{ workos_organization_id: PINNACLE, role: 'owner' }]); + expect((await pool.query( + 'SELECT primary_organization_id FROM users WHERE workos_user_id = $1', [LINKED], + )).rows[0].primary_organization_id).toBe(PINNACLE); + }); + + it.each(['epoch changed', 'grant expired', 'grant revoked'] as const)('denies replayed selected context after %s even when fresh WorkOS membership could authorize', async (change) => { + const id = await grant(LINKED, STREAMHAUS); + mocks.previousSnapshot = (await loadAuthorizationSnapshot(LINKED, STREAMHAUS))!; + mocks.memberships.mockImplementation(async ({ userId, organizationId }) => ({ + data: [{ userId, organizationId, status: 'active', role: { slug: 'owner' } }], + })); + expect((await lifecycle(LINKED, STREAMHAUS)).map((result) => result.status)).toEqual([200, 201, 204]); + + if (change === 'epoch changed') { + await bumpAuthorizationEpochs(pool, [LINKED]); + } else if (change === 'grant revoked') { + await pool.query( + 'UPDATE organization_credential_grants SET revoked_at = NOW(), revoked_by_workos_user_id = $2 WHERE id = $1', + [id, PRIMARY], + ); + } else { + await pool.query( + `UPDATE organization_credential_grants SET effective_from = NOW() - INTERVAL '2 days', + effective_until = NOW() - INTERVAL '1 day' WHERE id = $1`, [id], + ); + } + mocks.memberships.mockClear(); + mocks.fetch.mockClear(); + + expect((await lifecycle(LINKED, STREAMHAUS)).map((result) => result.status)).toEqual([403, 403, 403]); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect((await pool.query( + 'SELECT * FROM organization_memberships WHERE workos_user_id = ANY($1)', [[PRIMARY, LINKED]], + )).rows).toHaveLength(0); + }); +}); diff --git a/server/tests/integration/community-mirrors.test.ts b/server/tests/integration/community-mirrors.test.ts index 0248fc2913e..8c5e442084c 100644 --- a/server/tests/integration/community-mirrors.test.ts +++ b/server/tests/integration/community-mirrors.test.ts @@ -11,10 +11,10 @@ vi.hoisted(() => { process.env.WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID ?? 'client_test'; }); -// Mutable identity for the authenticated caller so a re-publish can come from a -// different user (used to assert created_by_* is preserved). +// Mock the identity produced by credential validation. Static admin keys retain +// manager access; organization API keys submit and read their own proposals. const authState = vi.hoisted(() => ({ - userId: 'user_test_mirrors', + userId: 'admin_api_key', email: 'mirrors@test.com', organizationId: null as string | null, })); @@ -194,7 +194,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( notifyPendingCommunityMirrorProposal.mockResolvedValue(null); notifyCommunityMirrorProposalReviewed.mockReset(); notifyCommunityMirrorProposalReviewed.mockResolvedValue(undefined); - authState.userId = 'user_test_mirrors'; + authState.userId = 'admin_api_key'; authState.email = 'mirrors@test.com'; authState.organizationId = null; await clear(); @@ -357,11 +357,13 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( expect(read.body.adagents_json.authorized_agents).toEqual([]); }); - it('allows an AAO admin who is not a registry moderator to publish', async () => { + it('denies human admin publishing without exact manager authorization', async () => { + authState.userId = 'user_human_admin'; isRegistryModerator.mockResolvedValue(false); isWebUserAAOAdmin.mockResolvedValue(true); const res = await request(app).put(`/api/registry/mirrors/${PLATFORM}`).send(publishBody()); - expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect((await pool.query('SELECT 1 FROM community_mirrors WHERE platform = $1', [PLATFORM])).rows).toHaveLength(0); }); it('re-publish is idempotent — updates in place, no duplicate row', async () => { @@ -461,10 +463,12 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( expect(projectedProperties.rows.map((row) => row.publisher_domain)).toEqual([...finalDomains].sort()); }); - it('preserves created_by_* across a re-publish by a different user', async () => { - authState.userId = 'creator-A'; + it('preserves legacy created_by_* when an admin key re-publishes', async () => { await request(app).put(`/api/registry/mirrors/${PLATFORM}`).send(publishBody({ catalog_etag: 'v1' })); - authState.userId = 'editor-B'; + await pool.query( + 'UPDATE community_mirrors SET created_by_user_id = $1 WHERE platform = $2', + ['creator-A', PLATFORM], + ); await request(app).put(`/api/registry/mirrors/${PLATFORM}`).send(publishBody({ catalog_etag: 'v2' })); const { rows } = await pool.query( @@ -559,7 +563,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( .send(publishBody({ catalog_etag: 'changed-v2', formats: [{ ...MINIMAL_FORMAT, display_name: 'Changed' }] })); expect(changed.body.proposal_id).toBe(reviewed.body.proposal_id); - authState.userId = 'user_registry_moderator'; + authState.userId = 'admin_api_key'; authState.organizationId = null; isRegistryModerator.mockResolvedValue(true); const staleApproval = await request(app) @@ -577,7 +581,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( authState.organizationId = 'org_test_mirrors'; const submitted = await request(app).put(`/api/registry/mirrors/${PLATFORM}`).send(publishBody()); - authState.userId = 'user_registry_moderator'; + authState.userId = 'admin_api_key'; authState.organizationId = null; isRegistryModerator.mockResolvedValue(true); await request(app) @@ -599,7 +603,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( authState.organizationId = 'org_test_mirrors'; const original = await request(app).put(`/api/registry/mirrors/${PLATFORM}`).send(publishBody()); - authState.userId = 'user_registry_moderator'; + authState.userId = 'admin_api_key'; authState.organizationId = null; isRegistryModerator.mockResolvedValue(true); await request(app) @@ -613,7 +617,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( expect(rebased.body.proposal_id).toBe(original.body.proposal_id); expect(rebased.body.proposal_digest).not.toBe(original.body.proposal_digest); - authState.userId = 'user_registry_moderator'; + authState.userId = 'admin_api_key'; authState.organizationId = null; isRegistryModerator.mockResolvedValue(true); const staleApproval = await request(app) @@ -650,7 +654,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( expect(ownDetail.status).toBe(200); expect(ownDetail.body.proposal).not.toHaveProperty('proposed_by_email'); - authState.userId = 'user_registry_moderator'; + authState.userId = 'admin_api_key'; authState.organizationId = null; isRegistryModerator.mockResolvedValue(true); const managerDetail = await request(app) @@ -687,7 +691,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( expect(row.rows[0]?.slack_thread_ts).toBe('1779110411.874'); }); - it('restricts the cross-organization review queue to moderators', async () => { + it('restricts the cross-organization review queue to a validated admin key', async () => { isRegistryModerator.mockResolvedValue(false); authState.userId = 'api_key_test_mirrors'; authState.organizationId = 'org_test_mirrors'; @@ -697,7 +701,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( .get('/api/registry/mirror-proposals?status=pending&review_queue=true'); expect(denied.status).toBe(403); - authState.userId = 'user_registry_moderator'; + authState.userId = 'admin_api_key'; authState.organizationId = null; isRegistryModerator.mockResolvedValue(true); const queue = await request(app) @@ -722,13 +726,13 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( expect(oversized.status).toBe(413); }); - it('lets a moderator approve and atomically publish a pending proposal', async () => { + it('lets a validated admin key approve and atomically publish a pending proposal', async () => { isRegistryModerator.mockResolvedValue(false); authState.userId = 'api_key_test_mirrors'; authState.organizationId = 'org_test_mirrors'; const submitted = await request(app).put(`/api/registry/mirrors/${PLATFORM}`).send(publishBody()); - authState.userId = 'user_registry_moderator'; + authState.userId = 'admin_api_key'; authState.organizationId = null; isRegistryModerator.mockResolvedValue(true); const approved = await request(app) @@ -751,7 +755,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( ); expect(proposal.rows[0]).toMatchObject({ status: 'approved', - reviewed_by_user_id: 'user_registry_moderator', + reviewed_by_user_id: 'admin_api_key', published_at: expect.any(Date), }); expect((await pool.query('SELECT 1 FROM community_mirrors WHERE platform = $1', [PLATFORM])).rows).toHaveLength(1); @@ -777,13 +781,13 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( expect((await pool.query('SELECT 1 FROM community_mirrors WHERE platform = $1', [PLATFORM])).rows).toHaveLength(0); }); - it('lets a moderator reject a pending proposal without publishing it', async () => { + it('lets a validated admin key reject a pending proposal without publishing it', async () => { isRegistryModerator.mockResolvedValue(false); authState.userId = 'api_key_test_mirrors'; authState.organizationId = 'org_test_mirrors'; const submitted = await request(app).put(`/api/registry/mirrors/${PLATFORM}`).send(publishBody()); - authState.userId = 'user_registry_moderator'; + authState.userId = 'admin_api_key'; authState.organizationId = null; isRegistryModerator.mockResolvedValue(true); const rejected = await request(app) @@ -972,6 +976,7 @@ describe('Community-mirror lifecycle — /api/registry/mirrors + /translated', ( await request(app) .put(`/api/registry/mirrors/${PLATFORM}`) .send(publishBody({ superseded_by: 'https://meta.com/.well-known/adagents.json' })); + authState.userId = 'user_non_manager'; isRegistryModerator.mockResolvedValue(false); isWebUserAAOAdmin.mockResolvedValue(false); const del = await request(app).delete(`/api/registry/mirrors/${PLATFORM}`); diff --git a/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts b/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts index 24687c547bf..6ae452a493d 100644 --- a/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts +++ b/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts @@ -25,6 +25,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; import type { Pool } from 'pg'; +import type { WorkOSUser } from '../../src/types.js'; // Set WorkOS env before vi.mock factories run — auth.ts constructs WorkOS // at module load and the factory's vi.importActual triggers that load. @@ -46,7 +47,7 @@ vi.hoisted(() => { // matrix without rebuilding the route's dependency graph. const DEFAULT_TEST_USER_ID = 'user_test_registry_baseline_endpoints'; const authState = vi.hoisted(() => ({ - optAuthUser: null as { id: string; email: string } | null, + optAuthUser: null as WorkOSUser | null, requireAuthUser: { id: 'user_test_registry_baseline_endpoints', email: 'registry-baseline@test.com', @@ -87,7 +88,7 @@ vi.mock('../../src/middleware/auth.js', async () => { }; }); -function setOptAuthUser(user: { id: string; email: string } | null) { +function setOptAuthUser(user: WorkOSUser | null) { authState.optAuthUser = user; } @@ -114,9 +115,19 @@ vi.mock('../../src/billing/stripe-client.js', () => ({ createBillingPortalSession: vi.fn().mockResolvedValue(null), })); +// Scope fixtures use real exact-credential grants and snapshots in PostgreSQL. +// No direct WorkOS membership is needed for those grants to authorize access. +vi.mock('../../src/auth/workos-client.js', async importOriginal => ({ + ...await importOriginal(), + getAuthorizationEnforcementWorkos: () => ({ + userManagement: { listOrganizationMemberships: vi.fn().mockResolvedValue({ data: [] }) }, + }), +})); + import { HTTPServer } from '../../src/http.js'; import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; import { runMigrations } from '../../src/db/migrate.js'; +import { loadAuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; import { FederatedIndexDatabase } from '../../src/db/federated-index-db.js'; import { FederatedIndexService } from '../../src/federated-index.js'; import { CrawlerService } from '../../src/crawler.js'; @@ -1214,8 +1225,7 @@ describe('Registry reader baseline — public endpoints', () => { ON CONFLICT (workos_user_id) DO UPDATE SET primary_organization_id = EXCLUDED.primary_organization_id`, [userId, `${userId}@example.com`, orgId], ); - // resolvePrimaryOrganization trusts the cached column only when a - // current organization_memberships row backs it. + // Keep legacy attribution populated; it must not supply tenant authority. await pool.query( `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, role, email, created_at, updated_at) @@ -1223,6 +1233,12 @@ describe('Registry reader baseline — public endpoints', () => { ON CONFLICT (workos_user_id, workos_organization_id) DO NOTHING`, [userId, orgId, `${userId}@example.com`], ); + await pool.query( + `INSERT INTO organization_credential_grants + (workos_user_id, workos_organization_id, role, granted_by_workos_user_id, reason) + VALUES ($1, $2, 'member', $1, 'Scope matrix exact-credential fixture')`, + [userId, orgId], + ); } async function clearScopeFixtures() { @@ -1293,20 +1309,30 @@ describe('Registry reader baseline — public endpoints', () => { }); type CallerKind = 'anonymous' | 'owner' | 'other_api' | 'explorer'; - function setCaller(kind: CallerKind) { + async function setCaller(kind: CallerKind, selectOrganization = true) { if (kind === 'anonymous') return setOptAuthUser(null); const id = kind === 'owner' ? SCOPE_OWNER_USER : kind === 'other_api' ? SCOPE_OTHER_API_USER : SCOPE_EXPLORER_USER; - setOptAuthUser({ id, email: `${id}@example.com` }); + const organizationId = kind === 'owner' ? SCOPE_OWNER_ORG + : kind === 'other_api' ? SCOPE_OTHER_API_ORG + : SCOPE_EXPLORER_ORG; + const snapshot = await loadAuthorizationSnapshot(id, selectOrganization ? organizationId : null); + expect(snapshot).not.toBeNull(); + const user: WorkOSUser = { id: snapshot!.canonicalUserId, authWorkosUserId: id, email: `${id}@example.com` }; + Object.defineProperty(user, 'authorizationSnapshot', { value: snapshot }); + setOptAuthUser(user); } async function fetchAgents(scope: string | null, kind: CallerKind): Promise { - setCaller(kind); + await setCaller(kind); const qs = scope === null ? '' : `&scope=${encodeURIComponent(scope)}`; - const res = await request(app).get( + const pending = request(app).get( `/api/registry/operator?domain=${encodeURIComponent(SCOPE_DOMAIN)}${qs}`, ); + const organizationId = authState.optAuthUser?.authorizationSnapshot?.selectedOrganizationId; + if (organizationId) pending.set('X-Organization-Id', organizationId); + const res = await pending; expect(res.status).toBe(200); expect(res.body.agent_visibility_summary).toEqual({ public: 1, members_only: 1 }); return (res.body.agents as Array<{ url: string }>).map(a => a.url).sort(); @@ -1319,6 +1345,16 @@ describe('Registry reader baseline — public endpoints', () => { it('scope omitted, explorer (no API tier) → public only', async () => { expect(await fetchAgents(null, 'explorer')).toEqual([PUBLIC_URL]); }); + it('does not infer owner access without an explicit organization selection', async () => { + await setCaller('owner', false); + for (const scope of ['all', 'private']) { + const res = await request(app).get( + `/api/registry/operator?domain=${encodeURIComponent(SCOPE_DOMAIN)}&scope=${scope}`, + ); + expect(res.status).toBe(200); + expect(res.body.agents.map((agent: { url: string }) => agent.url)).toEqual(scope === 'all' ? [PUBLIC_URL] : []); + } + }); it('scope omitted, API-tier non-owner → public + members_only', async () => { expect(await fetchAgents(null, 'other_api')).toEqual( [MEMBERS_URL, PUBLIC_URL].sort(), @@ -1380,7 +1416,7 @@ describe('Registry reader baseline — public endpoints', () => { // ── validation ──────────────────────────────────────────────────── it('rejects unknown scope with 400', async () => { - setCaller('owner'); + await setCaller('owner'); const res = await request(app).get( `/api/registry/operator?domain=${encodeURIComponent(SCOPE_DOMAIN)}&scope=membr`, ); diff --git a/server/tests/integration/sealed-bearer-organization.test.ts b/server/tests/integration/sealed-bearer-organization.test.ts new file mode 100644 index 00000000000..42c298364ea --- /dev/null +++ b/server/tests/integration/sealed-bearer-organization.test.ts @@ -0,0 +1,462 @@ +/** Real mounted authentication, snapshot SQL and org resolution; only WorkOS is mocked. */ +import express, { type Request } from 'express'; +import { randomUUID } from 'node:crypto'; +import cookieParser from 'cookie-parser'; +import request, { type Test } from 'supertest'; +import type { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), refresh: vi.fn(), loadSealedSession: vi.fn(), + memberships: vi.fn(), validateApiKey: vi.fn(), +})); +vi.mock('@workos-inc/node', () => ({ + WorkOS: class { + userManagement = { + loadSealedSession: mocks.loadSealedSession, + listOrganizationMemberships: mocks.memberships, + }; + apiKeys = { createValidation: mocks.validateApiKey }; + }, +})); + +import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import * as database from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import { bumpAuthorizationEpochs } from '../../src/db/authorization-epoch-db.js'; +import { invalidateSessionCache, optionalAuth, requireAuth, stopAuthTimers } from '../../src/middleware/auth.js'; +import { resolveCallerOrgId, sendCallerOrganizationAuthError } from '../../src/routes/helpers/resolve-caller-org.js'; +import { buildConformanceTokenRouter } from '../../src/conformance/token-route.js'; +import { verifyConformanceToken } from '../../src/conformance/token.js'; + +const PRIMARY = 'user_sealed_bearer_primary'; +const EXACT = 'user_sealed_bearer_exact'; +const ORG = 'org_sealed_bearer_pinnacle'; +const OTHER = 'org_sealed_bearer_streamhaus'; +const PROVIDER_USER = { + id: EXACT, email: 'sam@streamhaus.example', firstName: 'Sam', lastName: 'Adeyemi', + emailVerified: true, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), +}; +let pool: Pool; +let identities: string[] = []; +let sequence = 0; +const tokenPrefix = randomUUID(); +const tokens: string[] = []; +function token() { + const value = `sealed-native-${tokenPrefix}-${++sequence}`; + tokens.push(value); + return value; +} +function membership(userId = EXACT, organizationId = ORG, status = 'active') { + return { userId, organizationId, status, role: { slug: 'member' } }; +} + +async function cleanup() { + await pool.query('DELETE FROM organization_memberships WHERE workos_user_id = ANY($1)', [[PRIMARY, EXACT]]); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = ANY($1)', [[ORG, OTHER]]); + await pool.query('DELETE FROM users WHERE workos_user_id = ANY($1)', [[PRIMARY, EXACT]]); + 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(); +}, 60000); + +beforeEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const value of tokens.splice(0)) invalidateSessionCache(value); + await cleanup(); + mocks.authenticate.mockReset().mockResolvedValue({ + authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'native-access-token', + }); + mocks.refresh.mockReset().mockResolvedValue({ authenticated: false, reason: 'invalid_grant', retryable: false }); + mocks.loadSealedSession.mockReset().mockReturnValue({ authenticate: mocks.authenticate, refresh: mocks.refresh }); + mocks.memberships.mockReset().mockResolvedValue({ data: [] }); + mocks.validateApiKey.mockReset().mockResolvedValue(null); + await pool.query( + 'INSERT INTO organizations (workos_organization_id, name) VALUES ($1, $2), ($3, $4)', + [ORG, 'Pinnacle Agency', OTHER, 'StreamHaus'], + ); + // Both credentials have a tempting primary org, even when no memberships exist. + await pool.query( + `INSERT INTO users (workos_user_id, email, primary_organization_id, email_verified) + VALUES ($1, $2, $3, true), ($4, $5, $3, true)`, + [PRIMARY, 'sam@pinnacle.example', ORG, EXACT, 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, EXACT]], + ); + identities = bindings.rows.map(row => row.identity_id); + await pool.query( + 'UPDATE identity_workos_users SET identity_id = $1, is_primary = FALSE WHERE workos_user_id = $2', + [bindings.rows.find(row => row.workos_user_id === PRIMARY).identity_id, EXACT], + ); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + stopAuthTimers(); + try { if (pool) await cleanup(); } finally { await closeDatabase(); } +}); + +async function grant(state = 'valid', userId = EXACT, organizationId = ORG) { + const result = await pool.query( + `INSERT INTO organization_credential_grants + (workos_user_id, workos_organization_id, role, granted_by_workos_user_id, + effective_from, effective_until, revoked_at, revoked_by_workos_user_id) + VALUES ($1, $2, 'member', $3, $4, $5, + CASE WHEN $6 THEN NOW() ELSE NULL END, + CASE WHEN $6 THEN $3::varchar ELSE NULL END) RETURNING id`, + [userId, organizationId, PRIMARY, + state === 'future' ? '2999-01-01' : '2000-01-01', + state === 'expired' ? '2001-01-01' : null, state === 'revoked'], + ); + return result.rows[0].id as string; +} + +type Mode = 'required' | 'optional'; +function mount(mode: Mode, beforeResolve?: (req: Request) => void | Promise) { + const app = express(); + app.use(cookieParser()); + app.use(express.json()); + app.use(mode === 'required' ? requireAuth : optionalAuth); + const reached: Request[] = []; + const handler = async (req: Request, res: express.Response, next: express.NextFunction) => { + reached.push(req); + try { + await beforeResolve?.(req); + const organizationId = await resolveCallerOrgId(req); + res.status(organizationId ? 200 : 403).json({ organizationId }); + } catch (err) { next(err); } + }; + app.post('/scope', handler); + // These params are intentionally populated AFTER authentication. + for (const field of ['org_id', 'orgId', 'organizationId']) app.post(`/${field}/:${field}/scope`, handler); + app.use((err: unknown, _req: Request, res: express.Response, _next: express.NextFunction) => { + if (!sendCallerOrganizationAuthError(err, res)) res.status(500).json({ error: 'unexpected_test_error' }); + }); + return { app, reached }; +} +type Mounted = ReturnType; +function post(mounted: Mounted, value: string, path = '/scope') { + return request(mounted.app).post(path).set('Authorization', `Bearer ${value}`); +} +function expectProvenance(req: Request) { + expect(req.authenticatedSealedBearer?.authorizationSnapshot).toBe(req.user?.authorizationSnapshot); + expect(req.user?.authorizationSnapshot).toMatchObject({ authenticatedUserId: EXACT, canonicalUserId: PRIMARY }); + expect(req.user?.authWorkosUserId).toBe(EXACT); + expect(Object.keys(req)).not.toContain('authenticatedSealedBearer'); + expect(Object.isFrozen(req.authenticatedSealedBearer)).toBe(true); + expect({ ...req.user }).not.toHaveProperty('authorizationSnapshot'); +} + +const selectors: Array<[string, string, (pending: Test, org: string) => Test]> = [ + ['header', '/scope', (pending, org) => pending.set('X-Organization-Id', org)], + ...['org', 'org_id', 'organization_id', 'organizationId'].map(field => + [`query ${field}`, '/scope', (pending: Test, org: string) => pending.query({ [field]: org })] as [string, string, (pending: Test, org: string) => Test]), + ...['org_id', 'organization_id', 'organizationId'].map(field => + [`body ${field}`, '/scope', (pending: Test, org: string) => pending.send({ [field]: org })] as [string, string, (pending: Test, org: string) => Test]), + ...['org_id', 'orgId', 'organizationId'].map(field => + [`path ${field}`, `/${field}/${ORG}/scope`, (pending: Test) => pending] as [string, string, (pending: Test, org: string) => Test]), +]; + +describe.each(['required', 'optional'] as const)('%s native sealed bearer', mode => { + describe.each(['miss', 'hit'] as const)('cache %s', cache => { + async function prepare(beforeResolve?: (req: Request) => void | Promise) { + const mounted = mount(mode, beforeResolve); + const value = token(); + if (cache === 'hit') { + expect((await post(mount(mode), value)).status).toBe(403); + mocks.memberships.mockClear(); + } + return { mounted, value }; + } + + it.each([0, 1, 2])('leaves organization null with %i orgs and no selector', async count => { + for (const organizationId of [ORG, OTHER].slice(0, count)) { + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role) + VALUES ($1, $2, $3, 'owner')`, [EXACT, organizationId, PROVIDER_USER.email], + ); + await grant('valid', EXACT, organizationId); + } + mocks.memberships.mockResolvedValue({ data: [ORG, OTHER].slice(0, count).map(org => membership(EXACT, org)) }); + const { mounted, value } = await prepare(); + const response = await post(mounted, value); + expect(response.status).toBe(403); + expect(response.body).toEqual({ organizationId: null }); + expectProvenance(mounted.reached[0]); + expect(mounted.reached[0].user?.authorizationSnapshot?.selectedOrganizationId).toBeNull(); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.validateApiKey).not.toHaveBeenCalled(); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + }); + + it.each(selectors)('authorizes exact direct membership selected by %s', async (_name, path, select) => { + mocks.memberships.mockResolvedValue({ data: [membership()] }); + const { mounted, value } = await prepare(); + const response = await select(post(mounted, value, path), ORG); + expect(response.status).toBe(200); + expect(response.body).toEqual({ organizationId: ORG }); + expectProvenance(mounted.reached[0]); + expect(mocks.memberships).toHaveBeenCalledExactlyOnceWith({ userId: EXACT, organizationId: ORG }); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + expect(mocks.validateApiKey).not.toHaveBeenCalled(); + }); + + it.each(['absent', 'unavailable'])('authorizes a valid grant with %s direct membership', async state => { + await grant(); + if (state === 'unavailable') mocks.memberships.mockRejectedValue(new Error('WorkOS unavailable')); + const { mounted, value } = await prepare(); + expect((await post(mounted, value).set('X-Organization-Id', ORG)).status).toBe(200); + expectProvenance(mounted.reached[0]); + expect(mounted.reached[0].user?.authorizationSnapshot?.credentialGrant?.organizationId).toBe(ORG); + expect(mocks.memberships).toHaveBeenCalledExactlyOnceWith({ userId: EXACT, organizationId: ORG }); + }); + + it.each(['missing', 'expired', 'future', 'revoked', 'other org', 'canonical sibling', 'inactive membership'])('denies %s authority', async state => { + if (['expired', 'future', 'revoked'].includes(state)) await grant(state); + if (state === 'other org') await grant('valid', EXACT, OTHER); + if (state === 'canonical sibling') { + await grant('valid', PRIMARY); + mocks.memberships.mockResolvedValue({ data: [membership(PRIMARY)] }); + } + if (state === 'inactive membership') mocks.memberships.mockResolvedValue({ data: [membership(EXACT, ORG, 'inactive')] }); + const { mounted, value } = await prepare(); + const response = await post(mounted, value).set('X-Organization-Id', ORG); + expect(response.status).toBe(403); + expect(response.body).toEqual({ organizationId: null }); + expect(mounted.reached[0].user?.authorizationSnapshot?.credentialGrant).toBeNull(); + expect(mocks.memberships).toHaveBeenCalledExactlyOnceWith({ userId: EXACT, organizationId: ORG }); + }); + + it.each(selectors)('denies mismatched %s selection before provider membership reads', async (_name, path, select) => { + mocks.authenticate.mockResolvedValue({ + authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'native-access-token', organizationId: OTHER, + }); + const mounted = mount(mode); + const value = token(); + if (cache === 'hit') expect((await post(mounted, value)).status).toBe(403); + mocks.memberships.mockClear(); + const response = await select(post(mounted, value, path), ORG); + expect(response.status).toBe(403); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + }); + + it.each(['epoch', 'grant revocation', 'database unavailable'])('denies %s between middleware and resolver without provider reads', async state => { + const id = await grant(); + const { mounted, value } = await prepare(async () => { + if (state === 'epoch') await bumpAuthorizationEpochs(pool, [EXACT]); + if (state === 'grant revocation') await pool.query( + 'UPDATE organization_credential_grants SET revoked_at = NOW(), revoked_by_workos_user_id = $2 WHERE id = $1', [id, PRIMARY], + ); + if (state === 'database unavailable') vi.spyOn(database, 'queryWithTimeout').mockRejectedValue(new Error('private database outage')); + }); + const response = await post(mounted, value).set('X-Organization-Id', ORG); + expect(response.status).toBe(403); + expect(response.body).toEqual({ organizationId: null }); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('returns 503 before the handler when snapshot hydration is unavailable', async () => { + const { mounted, value } = await prepare(); + vi.spyOn(database, 'queryWithTimeout').mockRejectedValue(new Error('private database outage')); + const response = await post(mounted, value).set('X-Organization-Id', ORG); + expect(response.status).toBe(503); + expect(mounted.reached).toHaveLength(0); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(response.text).not.toContain('private'); + }); + + it('denies unavailable membership without a grant instead of selecting a primary org', async () => { + mocks.memberships.mockRejectedValue(new Error('private provider outage')); + const { mounted, value } = await prepare(); + const response = await post(mounted, value).set('X-Organization-Id', ORG); + expect(response.status).toBe(403); + expect(response.body).toEqual({ organizationId: null }); + }); + + it('denies a missing exact credential even when its canonical sibling remains', async () => { + const { mounted, value } = await prepare(); + await pool.query('DELETE FROM users WHERE workos_user_id = $1', [EXACT]); + expect((await post(mounted, value).set('X-Organization-Id', ORG)).status).toBe(401); + expect(mounted.reached).toHaveLength(0); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + }); + + it.each(['invalid', 'revoked'])('rejects an %s opaque bearer and dead-cache replay without using a valid cookie', async state => { + const mounted = mount(mode); + const validCookie = token(); + expect((await request(mounted.app).post('/scope').set('Cookie', `wos-session=${validCookie}`)).status).toBe(403); + mounted.reached.length = 0; + mocks.authenticate.mockClear().mockResolvedValue({ authenticated: false, reason: state === 'revoked' ? 'invalid_grant' : 'invalid_session' }); + const invalid = token(); + for (let i = 0; i < 2; i++) { + const response = await post(mounted, invalid).set('Cookie', `wos-session=${validCookie}`).set('X-Organization-Id', ORG) + .set('authenticatedSealedBearer', 'true').send({ authenticatedSealedBearer: { tokenHash: invalid } }); + expect(response.status).toBe(401); + } + expect(mocks.authenticate).toHaveBeenCalledOnce(); + expect(mounted.reached).toHaveLength(0); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('rejects a previously cached bearer after provider revocation and cache invalidation', async () => { + mocks.memberships.mockResolvedValue({ data: [membership()] }); + const mounted = mount(mode); + const value = token(); + expect((await post(mounted, value).set('X-Organization-Id', ORG)).status).toBe(200); + invalidateSessionCache(value); + mocks.authenticate.mockResolvedValue({ authenticated: false, reason: 'invalid_grant' }); + mocks.memberships.mockClear(); + expect((await post(mounted, value).set('X-Organization-Id', ORG)).status).toBe(401); + expect(mounted.reached).toHaveLength(1); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it.each(['token', 'snapshot', 'principal'])('rejects a replaced %s after middleware authentication', async state => { + const mounted = mount(mode, req => { + if (state === 'token') req.headers.authorization = 'Bearer different-invalid-token'; + if (state === 'snapshot') req.user = { ...req.user! }; + if (state === 'principal') req.user!.authWorkosUserId = PRIMARY; + }); + const response = await post(mounted, token()).set('X-Organization-Id', ORG); + expect(response.status).toBe(state === 'principal' ? 403 : 401); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('does not trust an opaque bearer added to an authenticated cookie request without provenance', async () => { + await grant(); + const mounted = mount(mode, req => { req.headers.authorization = 'Bearer unauthenticated-opaque'; }); + const response = await request(mounted.app).post('/scope') + .set('Cookie', `wos-session=${token()}`).set('X-Organization-Id', ORG); + expect(response.status).toBe(401); + expect(mounted.reached[0].user?.authorizationSnapshot?.credentialGrant).not.toBeNull(); + expect(mounted.reached[0].authenticatedSealedBearer).toBeUndefined(); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it.each(['', null, [], {}])('rejects malformed explicit selector %j without provider membership reads', async selector => { + const mounted = mount(mode); + expect((await post(mounted, token()).send({ organization_id: selector })).status).toBe(403); + expect(mounted.reached).toHaveLength(0); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('returns 503 on provider authentication outage without reaching organization resolution', async () => { + mocks.authenticate.mockRejectedValue(Object.assign(new Error('private provider outage'), { code: 'ETIMEDOUT' })); + const mounted = mount(mode); + const response = await post(mounted, token()).set('X-Organization-Id', ORG); + expect(response.status).toBe(503); + expect(mounted.reached).toHaveLength(0); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('keeps overlapping cache hits bound to independent explicit selections', async () => { + const mounted = mount(mode); + const value = token(); + expect((await post(mounted, value)).status).toBe(403); + mocks.memberships.mockImplementation(async ({ organizationId }) => ({ data: [membership(EXACT, organizationId)] })); + const responses = await Promise.all([ORG, OTHER].map(org => post(mounted, value).set('X-Organization-Id', org))); + expect(responses.map(response => response.body.organizationId)).toEqual([ORG, OTHER]); + const requests = mounted.reached.slice(1); + requests.forEach(expectProvenance); + expect(new Set(requests.map(req => req.user?.authorizationSnapshot?.selectedOrganizationId))).toEqual(new Set([ORG, OTHER])); + expect(requests[0].authenticatedSealedBearer).not.toBe(requests[1].authenticatedSealedBearer); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + }); + + it('rehydrates a fresh snapshot and selection on every cache hit without retaining handler mutation', async () => { + mocks.memberships.mockResolvedValue({ data: [membership(), membership(EXACT, OTHER)] }); + const mounted = mount(mode); + const value = token(); + expect((await post(mounted, value).set('X-Organization-Id', ORG)).status).toBe(200); + const first = mounted.reached[0]; + first.user!.id = 'user_mutated_by_handler'; + first.user!.authWorkosUserId = 'user_mutated_credential'; + await bumpAuthorizationEpochs(pool, [EXACT]); + expect((await post(mounted, value).set('X-Organization-Id', OTHER)).status).toBe(200); + expect((await post(mounted, value)).body).toEqual({ organizationId: null }); + const second = mounted.reached[1]; + expectProvenance(second); + expect(second.user?.authorizationSnapshot?.authorizationEpoch).toBe('1'); + expect(second.authenticatedSealedBearer).not.toBe(first.authenticatedSealedBearer); + expect(second.user?.authorizationSnapshot).not.toBe(first.user?.authorizationSnapshot); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + }); + + it('does not reuse a cached grant after committed revocation', async () => { + const id = await grant(); + const mounted = mount(mode); + const value = token(); + expect((await post(mounted, value).set('X-Organization-Id', ORG)).status).toBe(200); + await pool.query( + 'UPDATE organization_credential_grants SET revoked_at = NOW(), revoked_by_workos_user_id = $2 WHERE id = $1', [id, PRIMARY], + ); + expect((await post(mounted, value).set('X-Organization-Id', ORG)).status).toBe(403); + expect(mounted.reached[1].user?.authorizationSnapshot?.credentialGrant).toBeNull(); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + }); + + it('rejects an epoch change during the provider membership read', async () => { + mocks.memberships.mockImplementation(async () => { + await bumpAuthorizationEpochs(pool, [EXACT]); + return { data: [membership()] }; + }); + expect((await post(mount(mode), token()).set('X-Organization-Id', ORG)).status).toBe(403); + }); + + it('preserves successful refresh provenance on the initial request and cache hit', async () => { + mocks.authenticate.mockResolvedValueOnce({ authenticated: false }); + mocks.refresh.mockResolvedValueOnce({ authenticated: true, sealedSession: token() }); + mocks.memberships.mockResolvedValue({ data: [membership()] }); + const mounted = mount(mode); + const value = token(); + for (let i = 0; i < 2; i++) expect((await post(mounted, value).set('X-Organization-Id', ORG)).status).toBe(200); + mounted.reached.forEach(expectProvenance); + expect(mocks.authenticate).toHaveBeenCalledTimes(2); + expect(mocks.refresh).toHaveBeenCalledOnce(); + }); +}); + +describe('shared sealed-session cache transport isolation', () => { + it.each(['required', 'optional'] as const)('keeps provenance local when %s auth seeds the cache', async mode => { + mocks.memberships.mockResolvedValue({ data: [membership()] }); + const first = mount(mode); + const second = mount(mode === 'required' ? 'optional' : 'required'); + const value = token(); + const cookie = (mounted: Mounted) => request(mounted.app).post('/scope').set('Cookie', `wos-session=${value}`); + expect((await cookie(first).set('X-Organization-Id', ORG)).status).toBe(200); + expect(first.reached[0].authenticatedSealedBearer).toBeUndefined(); + expect((await post(second, value).set('X-Organization-Id', ORG)).status).toBe(200); + expectProvenance(second.reached[0]); + expect((await cookie(first)).body).toEqual({ organizationId: null }); + expect(first.reached[1].authenticatedSealedBearer).toBeUndefined(); + expect(mocks.authenticate).toHaveBeenCalledOnce(); + }); + + it('issues an org-bound conformance token through the real required-auth consumer', async () => { + vi.stubEnv('CONFORMANCE_JWT_SECRET', 'sealed-bearer-conformance-test-secret'); + await grant(); + const app = express(); + app.use(cookieParser(), express.json()); + app.use('/api/conformance', buildConformanceTokenRouter()); + const value = token(); + for (let i = 0; i < 2; i++) { + const response = await request(app).post('/api/conformance/token') + .set('Authorization', `bEaReR\t ${value}`).send({ organization_id: ORG }); + expect(response.status).toBe(200); + expect(verifyConformanceToken(response.body.token).sub).toBe(ORG); + } + expect(mocks.authenticate).toHaveBeenCalledOnce(); + expect((await request(app).post('/api/conformance/token').set('Authorization', `Bearer ${value}`)).status).toBe(403); + }); +}); diff --git a/server/tests/integration/user-authorization-snapshot.test.ts b/server/tests/integration/user-authorization-snapshot.test.ts new file mode 100644 index 00000000000..dd00ef27621 --- /dev/null +++ b/server/tests/integration/user-authorization-snapshot.test.ts @@ -0,0 +1,380 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Pool, PoolClient } from 'pg'; +import { closeDatabase, initializeDatabase } from '../../src/db/client.js'; +import * as database from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import { + AuthorizationSnapshotUnavailableError, + loadAuthorizationSnapshot, + sameAuthorizationIdentity, + sameAuthorizationSnapshot, + type AuthorizationSnapshot, +} from '../../src/db/user-authorization-snapshot-db.js'; +import { bumpAuthorizationEpochs } from '../../src/db/authorization-epoch-db.js'; + +const PERSONAL_ID = 'user_authorization_snapshot_personal'; +const CORPORATE_ID = 'user_authorization_snapshot_corporate'; +const PERSONAL_ORG = 'org_authorization_snapshot_personal'; +const CORPORATE_ORG = 'org_authorization_snapshot_corporate'; +const USER_IDS = [PERSONAL_ID, CORPORATE_ID]; +const ORG_IDS = [PERSONAL_ORG, CORPORATE_ORG]; + +describe('primary database authorization snapshots', () => { + let pool: Pool; + let fixtureIdentityIds: string[] = []; + + beforeAll(async () => { + pool = initializeDatabase({ + connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test', + }); + await runMigrations(); + }, 60000); + + async function cleanup() { + await pool.query('DELETE FROM organization_memberships WHERE workos_organization_id = ANY($1)', [ORG_IDS]); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = ANY($1)', [ORG_IDS]); + const identities = await pool.query<{ identity_id: string }>( + 'SELECT identity_id FROM identity_workos_users WHERE workos_user_id = ANY($1)', [USER_IDS], + ); + await pool.query('DELETE FROM users WHERE workos_user_id = ANY($1)', [USER_IDS]); + await pool.query('DELETE FROM identities WHERE id = ANY($1)', [ + [...fixtureIdentityIds, ...identities.rows.map(row => row.identity_id)], + ]); + fixtureIdentityIds = []; + } + + beforeEach(async () => { + vi.restoreAllMocks(); + await cleanup(); + await pool.query( + `INSERT INTO users (workos_user_id, email, first_name, last_name, email_verified, + workos_created_at, workos_updated_at) + VALUES ($1, 'sam.personal@example.test', 'Sam', 'Adeyemi', true, NOW(), NOW()), + ($2, 'sam@pinnacle.example', 'Sam', 'Adeyemi', true, NOW(), NOW())`, + USER_IDS, + ); + await pool.query( + `INSERT INTO organizations (workos_organization_id, name) + VALUES ($1, 'Sam personal organization'), ($2, 'Pinnacle Agency')`, + ORG_IDS, + ); + const identities = await pool.query<{ identity_id: string }>( + 'SELECT identity_id FROM identity_workos_users WHERE workos_user_id = ANY($1)', [USER_IDS], + ); + fixtureIdentityIds = identities.rows.map(row => row.identity_id); + }); + + afterAll(async () => { + vi.restoreAllMocks(); + if (pool) await cleanup(); + await closeDatabase(); + }); + + async function snapshot(userId = PERSONAL_ID, organizationId: string | null = null) { + const result = await loadAuthorizationSnapshot(userId, organizationId); + expect(result).not.toBeNull(); + return result!; + } + + async function link(primaryId: string, secondaryId: string, db: Pool | PoolClient = pool) { + await db.query( + `UPDATE identity_workos_users + SET identity_id = (SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1), + is_primary = FALSE + WHERE workos_user_id = $2`, + [primaryId, secondaryId], + ); + } + + async function grant( + userId: string, + organizationId: string, + options: { from?: string; until?: string | null; revoked?: boolean; role?: 'member' | 'admin' | 'owner' } = {}, + ) { + const result = await pool.query<{ id: string }>( + `INSERT INTO organization_credential_grants + (workos_user_id, workos_organization_id, role, granted_by_workos_user_id, + effective_from, effective_until, revoked_at, revoked_by_workos_user_id) + VALUES ($1, $2, $3, $1, $4, $5, + CASE WHEN $6 THEN NOW() ELSE NULL END, + CASE WHEN $6 THEN $1::varchar ELSE NULL END) + RETURNING id`, + [userId, organizationId, options.role ?? 'member', options.from ?? '2000-01-01T00:00:00.123456Z', + options.until ?? null, options.revoked ?? false], + ); + return result.rows[0].id; + } + + it('hydrates a cold exact credential with epoch zero and no implicit organization', async () => { + await grant(PERSONAL_ID, PERSONAL_ORG); + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role) + VALUES ($1, $2, 'sam.personal@example.test', 'owner')`, + [PERSONAL_ID, CORPORATE_ORG], + ); + + const cold = await snapshot(); + expect(cold).toMatchObject({ + authenticatedUserId: PERSONAL_ID, + canonicalUserId: PERSONAL_ID, + authorizationEpoch: '0', + selectedOrganizationId: null, + credentialGrant: null, + credential: { email: 'sam.personal@example.test', firstName: 'Sam', lastName: 'Adeyemi' }, + }); + expect(cold.identityId).toBeTruthy(); + expect(Object.isFrozen(cold)).toBe(true); + expect(Object.isFrozen(cold.credential)).toBe(true); + expect(await snapshot(PERSONAL_ID, '')).toEqual(cold); + expect((await snapshot(PERSONAL_ID, CORPORATE_ORG)).credentialGrant).toBeNull(); + }); + + it.each([ + { direction: 'personal primary', primaryId: PERSONAL_ID, secondaryId: CORPORATE_ID }, + { direction: 'corporate primary', primaryId: CORPORATE_ID, secondaryId: PERSONAL_ID }, + ])('keeps exact credential grants isolated with $direction', async ({ primaryId, secondaryId }) => { + await link(primaryId, secondaryId); + const personalGrantId = await grant(PERSONAL_ID, PERSONAL_ORG); + const corporateGrantId = await grant(CORPORATE_ID, CORPORATE_ORG, { role: 'admin' }); + // Stale consolidated membership state is deliberately stronger than the + // exact grants. Neither identity direction may inherit it as authority. + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role) + VALUES ($1, $2, 'sam@pinnacle.example', 'owner'), + ($1, $3, 'sam@pinnacle.example', 'owner')`, + [primaryId, PERSONAL_ORG, CORPORATE_ORG], + ); + + const personal = await snapshot(PERSONAL_ID, PERSONAL_ORG); + const corporate = await snapshot(CORPORATE_ID, CORPORATE_ORG); + expect(personal.canonicalUserId).toBe(primaryId); + expect(corporate.canonicalUserId).toBe(primaryId); + expect(personal.identityId).toBe(corporate.identityId); + expect(personal.credential.email).toBe('sam.personal@example.test'); + expect(corporate.credential.email).toBe('sam@pinnacle.example'); + expect(personal.credentialGrant).toEqual({ + id: personalGrantId, organizationId: PERSONAL_ORG, role: 'member', + effectiveFrom: '2000-01-01T00:00:00.123456Z', effectiveUntil: null, + }); + expect(corporate.credentialGrant?.id).toBe(corporateGrantId); + expect(corporate.credentialGrant?.role).toBe('admin'); + expect(Object.isFrozen(personal.credentialGrant)).toBe(true); + expect((await snapshot(PERSONAL_ID, CORPORATE_ORG)).credentialGrant).toBeNull(); + expect((await snapshot(CORPORATE_ID, PERSONAL_ORG)).credentialGrant).toBeNull(); + expect((await snapshot(secondaryId, null)).credentialGrant).toBeNull(); + expect(sameAuthorizationIdentity(personal, corporate)).toBe(false); + }); + + it.each([ + { state: 'expired', from: '2000-01-01T00:00:00Z', until: '2001-01-01T00:00:00Z' }, + { state: 'future', from: '2999-01-01T00:00:00Z', until: null }, + { state: 'revoked', revoked: true }, + ])('excludes $state grants on a cold read', async options => { + await grant(PERSONAL_ID, PERSONAL_ORG, options); + const cold = await snapshot(PERSONAL_ID, PERSONAL_ORG); + expect(cold.selectedOrganizationId).toBe(PERSONAL_ORG); + expect(cold.credentialGrant).toBeNull(); + }); + + it('does not reuse a previously loaded grant after revocation', async () => { + const grantId = await grant(PERSONAL_ID, PERSONAL_ORG); + const cached = await snapshot(PERSONAL_ID, PERSONAL_ORG); + await pool.query( + `UPDATE organization_credential_grants + SET revoked_at = NOW(), revoked_by_workos_user_id = $2 WHERE id = $1`, + [grantId, PERSONAL_ID], + ); + const fresh = await snapshot(PERSONAL_ID, PERSONAL_ORG); + expect(fresh.credentialGrant).toBeNull(); + expect(sameAuthorizationIdentity(cached, fresh)).toBe(true); + expect(sameAuthorizationSnapshot(cached, fresh)).toBe(false); + }); + + it('preserves bigint epochs and rejects forwards and backwards replay', async () => { + await pool.query( + 'INSERT INTO authorization_epochs (workos_user_id, epoch) VALUES ($1, $2)', + [PERSONAL_ID, '9007199254740992'], + ); + const old = await snapshot(); + await bumpAuthorizationEpochs(pool, [PERSONAL_ID]); + const bumped = await snapshot(); + expect(old.authorizationEpoch).toBe('9007199254740992'); + expect(bumped.authorizationEpoch).toBe('9007199254740993'); + expect(sameAuthorizationSnapshot(old, bumped)).toBe(false); + expect(sameAuthorizationSnapshot(bumped, old)).toBe(false); + await pool.query('DELETE FROM authorization_epochs WHERE workos_user_id = $1', [PERSONAL_ID]); + const absent = await snapshot(); + expect(absent.authorizationEpoch).toBe('0'); + expect(sameAuthorizationSnapshot(bumped, absent)).toBe(false); + }); + + it('rejects replays with altered credential, identity, selection, or grant details', async () => { + await grant(PERSONAL_ID, PERSONAL_ORG, { until: '2999-01-01T00:00:00.654321Z' }); + const current = await snapshot(PERSONAL_ID, PERSONAL_ORG); + expect(current.credentialGrant?.effectiveUntil).toBe('2999-01-01T00:00:00.654321Z'); + expect(sameAuthorizationSnapshot(current, structuredClone(current))).toBe(true); + const identityChanges: Partial[] = [ + { authenticatedUserId: CORPORATE_ID }, + { canonicalUserId: CORPORATE_ID }, + { identityId: null }, + { authorizationEpoch: '1' }, + { credential: { ...current.credential, email: 'changed@example.test' } }, + ]; + for (const change of identityChanges) { + expect(sameAuthorizationIdentity(current, { ...current, ...change })).toBe(false); + expect(sameAuthorizationSnapshot(current, { ...current, ...change })).toBe(false); + } + expect(sameAuthorizationSnapshot(current, { ...current, selectedOrganizationId: CORPORATE_ORG })).toBe(false); + expect(sameAuthorizationSnapshot(current, { ...current, credentialGrant: null })).toBe(false); + const grantChanges: Partial>[] = [ + { id: 'replacement-grant' }, + { organizationId: CORPORATE_ORG }, + { role: 'owner' }, + { effectiveFrom: '2000-01-01T00:00:00.123457Z' }, + { effectiveUntil: '2999-01-01T00:00:00.654322Z' }, + ]; + for (const change of grantChanges) { + expect(sameAuthorizationSnapshot(current, { + ...current, credentialGrant: { ...current.credentialGrant!, ...change }, + })).toBe(false); + } + }); + + it.each([ + { direction: 'personal into corporate', primaryId: CORPORATE_ID, secondaryId: PERSONAL_ID }, + { direction: 'corporate into personal', primaryId: PERSONAL_ID, secondaryId: CORPORATE_ID }, + ])('cannot stamp old identity with a newly committed epoch: $direction', async ({ primaryId, secondaryId }) => { + const before = await snapshot(secondaryId); + const writer = await pool.connect(); + let releaseRead!: () => void; + let readCompleted!: () => void; + const readBarrier = new Promise(resolve => { readCompleted = resolve; }); + const deliveryBarrier = new Promise(resolve => { releaseRead = resolve; }); + let pendingRead: Promise | undefined; + const actualQuery = database.queryWithTimeout; + try { + await writer.query('BEGIN'); + await link(primaryId, secondaryId, writer); + // An observer while identity changed but before the transactional epoch + // write still sees the complete old state through PostgreSQL MVCC. + expect(await snapshot(secondaryId)).toEqual(before); + await bumpAuthorizationEpochs(writer, [primaryId, secondaryId]); + + // Hold delivery of the first real SQL result while the writer commits. + // A split identity/epoch loader would then issue its epoch query against + // the new commit and return old identity + new epoch. No rows are mocked. + const readSpy = vi.spyOn(database, 'queryWithTimeout').mockImplementationOnce(async (...args) => { + const result = await actualQuery(...args); + readCompleted(); + await deliveryBarrier; + return result; + }); + pendingRead = loadAuthorizationSnapshot(secondaryId, null); + await Promise.race([readBarrier, pendingRead.then(() => { + throw new Error('Snapshot returned without waiting for the database read'); + })]); + await writer.query('COMMIT'); + releaseRead(); + const overlapping = await pendingRead; + expect(overlapping).toEqual(before); + expect(readSpy).toHaveBeenCalledTimes(1); + readSpy.mockRestore(); + + const after = await snapshot(secondaryId); + expect(after.canonicalUserId).toBe(primaryId); + expect(after.identityId).not.toBe(before.identityId); + expect(after.authorizationEpoch).toBe('1'); + expect(sameAuthorizationSnapshot(before, after)).toBe(false); + expect(sameAuthorizationSnapshot(after, before)).toBe(false); + } finally { + releaseRead(); + await writer.query('ROLLBACK'); + await pendingRead?.catch(() => undefined); + vi.restoreAllMocks(); + writer.release(); + } + }); + + it('returns null for a missing exact credential, without using its former canonical person', async () => { + await link(CORPORATE_ID, PERSONAL_ID); + const cached = await snapshot(PERSONAL_ID); + await pool.query('DELETE FROM users WHERE workos_user_id = $1', [PERSONAL_ID]); + expect(await loadAuthorizationSnapshot(PERSONAL_ID, CORPORATE_ORG)).toBeNull(); + expect((await snapshot(CORPORATE_ID)).canonicalUserId).toBe(cached.canonicalUserId); + }); + + it.each(['binding', 'primary'])('fails unavailable for a missing identity %s', async missing => { + if (missing === 'binding') { + await pool.query('DELETE FROM identity_workos_users WHERE workos_user_id = $1', [PERSONAL_ID]); + } else { + await pool.query('UPDATE identity_workos_users SET is_primary = FALSE WHERE workos_user_id = $1', [PERSONAL_ID]); + } + await expect(loadAuthorizationSnapshot(PERSONAL_ID, null)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + }); + + it('fails unavailable on database errors instead of replaying a warm snapshot', async () => { + await snapshot(PERSONAL_ID, PERSONAL_ORG); + vi.spyOn(database, 'queryWithTimeout').mockRejectedValueOnce(new Error('private database connection details')); + await expect(loadAuthorizationSnapshot(PERSONAL_ID, PERSONAL_ORG)) + .rejects.toThrow(new AuthorizationSnapshotUnavailableError()); + }); + + it.each([ + { state: 'valid credential', hasUser: true }, + { state: 'absent credential', hasUser: false }, + ])('rejects a recovering replica with $state', async ({ hasUser }) => { + const primary = await snapshot(PERSONAL_ID, PERSONAL_ORG); + // The integration database is a primary. Simulate only the single query + // result from a recovering replica, including its no-user anchor row. + const readSpy = vi.spyOn(database, 'queryWithTimeout').mockResolvedValueOnce({ + rows: [{ + in_recovery: true, + authenticated_user_id: hasUser ? primary.authenticatedUserId : null, + canonical_user_id: hasUser ? primary.canonicalUserId : null, + identity_id: hasUser ? primary.identityId : null, + authorization_epoch: '0', + email: hasUser ? primary.credential.email : null, + first_name: hasUser ? primary.credential.firstName : null, + last_name: hasUser ? primary.credential.lastName : null, + grant_id: null, + grant_organization_id: null, + grant_role: null, + grant_effective_from: null, + grant_effective_until: null, + }], + rowCount: 1, + command: 'SELECT', + oid: 0, + fields: [], + }); + await expect(loadAuthorizationSnapshot(PERSONAL_ID, PERSONAL_ORG)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(readSpy).toHaveBeenCalledTimes(1); + }); + + it('expires a blocked statement, releases its transaction, and loads fresh authority after recovery', async () => { + const before = await snapshot(PERSONAL_ID, PERSONAL_ORG); + const writer = await pool.connect(); + const boundedQuery = vi.spyOn(database, 'queryWithTimeout'); + try { + await writer.query('BEGIN'); + await writer.query('LOCK TABLE identity_workos_users IN ACCESS EXCLUSIVE MODE'); + const startedAt = Date.now(); + await expect(loadAuthorizationSnapshot(PERSONAL_ID, PERSONAL_ORG)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(boundedQuery).toHaveBeenCalledTimes(1); + + await bumpAuthorizationEpochs(writer, [PERSONAL_ID]); + await writer.query('COMMIT'); + const after = await snapshot(PERSONAL_ID, PERSONAL_ORG); + expect(after.authorizationEpoch).toBe('1'); + expect(sameAuthorizationSnapshot(before, after)).toBe(false); + expect(boundedQuery).toHaveBeenCalledTimes(2); + } finally { + await writer.query('ROLLBACK'); + writer.release(); + } + }); +}); diff --git a/server/tests/unit/addie-admin-global-auth.test.ts b/server/tests/unit/addie-admin-global-auth.test.ts index 38fa4c89bee..56421ec1f66 100644 --- a/server/tests/unit/addie-admin-global-auth.test.ts +++ b/server/tests/unit/addie-admin-global-auth.test.ts @@ -51,6 +51,7 @@ vi.mock('../../src/db/org-filters.js', () => ({ vi.mock('../../src/db/client.js', async (importOriginal) => ({ ...(await importOriginal()), getPool: () => ({ query: mocks.poolQuery }), + queryWithTimeout: mocks.poolQuery, })); vi.mock('../../src/db/working-group-db.js', () => ({ @@ -104,7 +105,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], }, @@ -130,9 +131,17 @@ describe('Addie real global-admin boundary', () => { }); mocks.isAdminGroupMember.mockResolvedValue(true); mocks.poolQuery.mockImplementation((sql: string) => { - if (sql.includes('FROM users')) { + if (sql.includes('pg_catalog.pg_is_in_recovery()')) { return Promise.resolve({ - rows: [{ first_name: 'SSO', last_name: 'Admin' }], + rows: [{ + in_recovery: false, + authenticated_user_id: 'user_sso_admin', + canonical_user_id: 'user_sso_admin', + identity_id: 'identity_sso_admin', + authorization_epoch: '0', + email: 'sso-admin@example.test', email_verified: true, + first_name: 'SSO', last_name: 'Admin', grant_id: null, + }], rowCount: 1, }); } 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..717d489fdf3 100644 --- a/server/tests/unit/api-key-issuance-permissions.test.ts +++ b/server/tests/unit/api-key-issuance-permissions.test.ts @@ -1,10 +1,18 @@ 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(), fetch: vi.fn(), + info: vi.fn(), + request: null as express.Request | null, + previousSnapshot: undefined as AuthorizationSnapshot | undefined, })); vi.hoisted(() => { @@ -21,22 +29,59 @@ vi.mock('@workos-inc/node', () => ({ }, })); +vi.mock('../../src/db/client.js', () => ({ + queryWithTimeout: mocks.queryWithTimeout, isTransientConnectionError: () => false, +})); +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) => { req.user = { - id: 'user_caller', + id: req.get('x-canonical-user') ?? 'user_caller', + authWorkosUserId: req.get('x-authenticated-user'), + identityId: req.get('x-identity-id'), email: 'caller@example.test', 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, 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)] })); +} + +beforeEach(() => { + mocks.queryWithTimeout.mockReset(); + 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 +107,7 @@ function setMembership( mocks.listOrganizationMemberships.mockResolvedValue({ data: [ { + userId: 'user_caller', organizationId: 'org_target', role: { slug: role }, status, @@ -74,6 +120,7 @@ function setUnboundMembership(organizationId?: string) { mocks.listOrganizationMemberships.mockResolvedValue({ data: [ { + userId: 'user_caller', ...(organizationId ? { organizationId } : {}), role: { slug: 'owner' }, status: 'active', @@ -216,6 +263,71 @@ 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: [{ + userId: 'user_linked', organizationId: 'org_target', status: 'active', role: { slug: role }, + }] }); + } + + function expectExactAuthorizationWithoutDelete() { + expect(mocks.listOrganizationMemberships).toHaveBeenCalledExactlyOnceWith({ + userId: 'user_linked', organizationId: 'org_target', + }); + expect(mocks.queryWithTimeout).toHaveBeenCalledWith( + expect.any(String), ['user_linked', 'org_target'], 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%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(); + + vi.clearAllMocks(); + mocks.listOrganizationMemberships.mockRejectedValue(new Error('Authorization provider unavailable')); + expect(await rawDelete(`/api/me/api-keys/${keyPath}`)).toBe(503); + expectExactAuthorizationWithoutDelete(); + }); + it('refuses an ordinary member listing organization API keys before WorkOS is called', async () => { setMembership('member'); @@ -344,3 +456,354 @@ 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 + ? [{ 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 === 'create' ? 201 : operation === 'revoke' ? 204 : 200); + expect(mocks.listOrganizationMemberships).toHaveBeenLastCalledWith({ + userId: credential, organizationId: ownOrg, + }); + expect(mocks.queryWithTimeout).toHaveBeenLastCalledWith( + expect.any(String), [credential, ownOrg], expect.any(Number), + { retryTransientCheckout: false }, + ); + expect(new URL(mocks.fetch.mock.calls[0][0]).pathname) + .toContain(`/organizations/${ownOrg}/api_keys`); + 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('cannot use a path-like selected organization to target another organization', async () => { + setMembership('owner'); + const response = await request(app).get('/api/me/api-keys?org=org_target%2F..%2Forg_other'); + expect(response.status).toBe(403); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + 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)('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('Grant database unavailable')); + expect((await perform(operation, 'org_target')).status).toBe(503); + expect(mocks.fetch).not.toHaveBeenCalled(); + + setSnapshotWithoutGrant(); + expect((await perform(operation, 'org_target')).status).toBe(403); + }); + + 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(3); + 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' + ? [{ 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('authorizes only the exact credential and organization named by an explicit admin grant', async () => { + mocks.queryWithTimeout.mockImplementation(async (_sql, [userId, orgId]) => ({ + rows: [snapshotRow(userId, orgId, userId === 'user_linked' && orgId === 'org_target' ? 'admin' : undefined)], + })); + + expect((await perform('list', 'org_target') + .set('x-canonical-user', 'user_primary').set('x-authenticated-user', 'user_linked')).status).toBe(200); + mocks.fetch.mockClear(); + expect((await perform('list', 'org_target').set('x-canonical-user', 'user_primary')).status).toBe(403); + expect((await perform('list', 'org_other') + .set('x-canonical-user', 'user_primary').set('x-authenticated-user', 'user_linked')).status).toBe(403); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it.each(['epoch changed', 'grant expired', 'grant replaced', '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' } + : change === 'grant expired' ? snapshotRow('user_linked') + : change === 'grant replaced' ? { ...original, grant_id: 'grant_replacement' } : original; + mocks.queryWithTimeout.mockResolvedValue({ rows: [current] }); + mocks.listOrganizationMemberships.mockResolvedValue({ + data: [{ 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('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: [{ 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 : method === 'POST' ? 201 : 204); + expect(mocks.listOrganizationMemberships).toHaveBeenLastCalledWith({ userId: 'user_caller', organizationId: 'org_target' }); + expect(new URL(mocks.fetch.mock.lastCall![0]).pathname).toContain('/organizations/org_target/api_keys'); + } + }); + + 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.each([ + ['POST', 201, 'API key created'], + ['DELETE', 204, 'API key revoked'], + ['DELETE', 404, 'API key revoke: already absent in WorkOS'], + ] as const)('keeps the original actor for %s provider status %s across both asynchronous boundaries', async (method, providerStatus, message) => { + 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: [{ userId: 'user_linked', organizationId: 'org_target', status: 'active', role: { slug: 'owner' } }] }; + }); + mocks.fetch.mockImplementation(async () => { + providerStarted.resolve(); + await releaseProvider.promise; + return { + ok: providerStatus !== 404, + status: providerStatus, + json: async () => ({ id: 'key_scoped' }), + text: async () => 'Already absent', + }; + }); + + const pending = perform(method, { 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(method === 'POST' ? 201 : 204); + expect(mocks.listOrganizationMemberships).toHaveBeenCalledWith({ userId: 'user_linked', organizationId: 'org_target' }); + expect(mocks.queryWithTimeout).toHaveBeenCalledWith( + expect.any(String), ['user_linked', 'org_target'], expect.any(Number), + { retryTransientCheckout: false }, + ); + expect(mocks.info).toHaveBeenCalledWith({ + userId: 'user_primary', authWorkosUserId: 'user_linked', identityId: 'identity_original', + organizationId: 'org_target', ...(method === 'POST' ? { keyName: 'Scoped automation' } : { apiKeyId: 'key_scoped' }), + }, message); + }); +}); 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/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 0ad33ee6e2a..f2f2c7bf2ea 100644 --- a/server/tests/unit/authorization-epoch-session.test.ts +++ b/server/tests/unit/authorization-epoch-session.test.ts @@ -1,161 +1,649 @@ -/** - * Persisted authorization epoch enforcement (#6827). - * - * The session cache is per-instance, so evicting it cannot revoke authority - * granted before an identity-binding change — another instance still serves - * its own cached entry. These tests pin the replacement: every cache hit - * revalidates the persisted epoch fingerprint stamped when the entry was - * stored, and a moved fingerprint forces full re-validation. - */ - +/** Provider caches must never become identity, organization, or epoch authority. */ import type { NextFunction, Request, Response } from 'express'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import supertest from 'supertest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - loadSealedSession: vi.fn(), - checkPlatformBan: vi.fn(), - checkPlatformBanForApiKey: vi.fn(), - getAuthorizationFingerprint: vi.fn(), - poolQuery: vi.fn(), + authenticate: vi.fn(), loadSealedSession: vi.fn(), checkPlatformBan: vi.fn(), + loadAuthorizationSnapshot: vi.fn(), verifyWorkOSJWT: vi.fn(), poolQuery: vi.fn(), + refresh: vi.fn(), getRefreshedSession: vi.fn(), })); - vi.hoisted(() => { - process.env.WORKOS_API_KEY = process.env.WORKOS_API_KEY ?? 'sk_test'; - process.env.WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID ?? 'client_test'; - process.env.WORKOS_COOKIE_PASSWORD = - process.env.WORKOS_COOKIE_PASSWORD ?? 'placeholder-cookie-password-32-bytes-min'; + process.env.DEV_USER_EMAIL = ''; + process.env.DEV_USER_ID = ''; + process.env.WORKOS_API_KEY ??= 'sk_test'; + process.env.WORKOS_CLIENT_ID ??= 'client_test'; + process.env.WORKOS_COOKIE_PASSWORD ??= 'placeholder-cookie-password-32-bytes-min'; }); - vi.mock('@workos-inc/node', () => ({ WorkOS: vi.fn(function WorkOS() { return { - userManagement: { - loadSealedSession: mocks.loadSealedSession, - }, + userManagement: { loadSealedSession: mocks.loadSealedSession }, apiKeys: { createValidation: vi.fn() }, }; }), })); - vi.mock('../../src/db/bans-db.js', () => ({ - bansDb: { - checkPlatformBan: mocks.checkPlatformBan, - checkPlatformBanForApiKey: mocks.checkPlatformBanForApiKey, - }, + bansDb: { checkPlatformBan: mocks.checkPlatformBan, checkPlatformBanForApiKey: vi.fn() }, })); - -vi.mock('../../src/db/authorization-epoch-db.js', () => ({ - getAuthorizationFingerprint: mocks.getAuthorizationFingerprint, - bumpAuthorizationEpochs: vi.fn(), +vi.mock('../../src/db/user-authorization-snapshot-db.js', async (importOriginal) => ({ + ...await importOriginal(), + loadAuthorizationSnapshot: mocks.loadAuthorizationSnapshot, +})); +vi.mock('../../src/auth/workos-jwt.js', async (importOriginal) => ({ + ...await importOriginal(), + verifyWorkOSJWT: mocks.verifyWorkOSJWT, + looksLikeJWT: (token: string) => token.split('.').length === 3, })); - vi.mock('../../src/db/client.js', () => ({ - getPool: () => ({ query: mocks.poolQuery }), - query: mocks.poolQuery, - isDatabaseInitialized: () => true, + getPool: () => ({ query: mocks.poolQuery }), query: mocks.poolQuery, isDatabaseInitialized: () => true, })); +vi.mock('../../src/db/session-refresh-db.js', () => ({ + getRefreshedSession: mocks.getRefreshedSession, + storeRefreshedSession: vi.fn().mockResolvedValue(undefined), + cleanExpiredRefreshes: vi.fn().mockResolvedValue(0), +})); +import { AuthorizationSnapshotUnavailableError } from '../../src/db/user-authorization-snapshot-db.js'; +import { invalidateBanCache, optionalAuth, requireAuth, stopAuthTimers } from '../../src/middleware/auth.js'; +import { csrfProtection } from '../../src/middleware/csrf.js'; -import { requireAuth } from '../../src/middleware/auth.js'; - -const SESSION_USER = { - id: 'user_epoch_primary', - email: 'primary@epoch.test', - firstName: 'Epoch', - lastName: 'User', - emailVerified: true, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', +const AUTHENTICATED_ID = 'user_epoch_authenticated'; +const PROVIDER_USER = { + id: AUTHENTICATED_ID, email: 'sam@example.test', firstName: 'Sam', lastName: 'Adeyemi', + emailVerified: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }; - -function makeRequest(cookie: string): Request { +let sequence = 0; +function snapshot(overrides: Partial = {}): AuthorizationSnapshot { return { - headers: {}, - cookies: { 'wos-session': cookie }, - path: '/api/me', - originalUrl: '/api/me', - accepts: () => false, + authenticatedUserId: AUTHENTICATED_ID, canonicalUserId: AUTHENTICATED_ID, + identityId: 'identity_epoch', authorizationEpoch: '1', selectedOrganizationId: null, + credential: { email: PROVIDER_USER.email, firstName: 'Sam', lastName: 'Adeyemi', emailVerified: true }, + credentialGrant: null, ...overrides, + }; +} +function request(token: string, bearer = false): Request { + return { + headers: bearer ? { authorization: `Bearer ${token}` } : {}, + cookies: bearer ? {} : { 'wos-session': token }, query: {}, body: {}, params: {}, + path: '/api/me', originalUrl: '/api/me', accepts: () => false, } as unknown as Request; } - -function makeResponse(): Response { +function response(): Response { return { - cookie: vi.fn(), - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), + cookie: vi.fn(), status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), redirect: vi.fn().mockReturnThis(), } as unknown as Response; } +beforeEach(() => { + vi.clearAllMocks(); + mocks.loadAuthorizationSnapshot.mockReset(); + mocks.loadAuthorizationSnapshot.mockImplementation(async (_id: string, org: string | null) => + snapshot({ selectedOrganizationId: org })); + invalidateBanCache('user', AUTHENTICATED_ID); + mocks.checkPlatformBan.mockResolvedValue({ banned: false }); + mocks.authenticate.mockResolvedValue({ authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token' }); + mocks.refresh.mockReset().mockResolvedValue({ authenticated: false }); + mocks.getRefreshedSession.mockReset().mockResolvedValue(null); + mocks.loadSealedSession.mockReturnValue({ authenticate: mocks.authenticate, refresh: mocks.refresh }); + mocks.verifyWorkOSJWT.mockResolvedValue({ sub: AUTHENTICATED_ID, email: PROVIDER_USER.email, isM2M: false }); +}); -describe('persisted authorization epoch gates the session cache', () => { +const organizationSelectors: Array<[string, (req: Request, organizationId: string) => void]> = [ + ['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; }], + ['path organizationId', (req, org) => { req.params.organizationId = org; }], +]; + +describe.each([ + ['required cookie', requireAuth, false], ['optional cookie', optionalAuth, false], + ['required bearer', requireAuth, true], ['optional bearer', optionalAuth, true], +] as const)('%s provider organization binding', (_label, middleware, bearer) => { beforeEach(() => { - vi.clearAllMocks(); - mocks.checkPlatformBan.mockResolvedValue({ banned: false, ban: null }); - // Local user lookup resolves; attachIdentityId sees a singleton identity - // (primary === authenticated credential), so there is no id-swap. - mocks.poolQuery.mockResolvedValue({ - rows: [ - { - first_name: 'Epoch', - last_name: 'User', - identity_id: 'identity_epoch', - primary_workos_user_id: 'user_epoch_primary', - }, - ], - rowCount: 1, + mocks.authenticate.mockResolvedValue({ + authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token', organizationId: 'org_pinnacle', }); + mocks.verifyWorkOSJWT.mockResolvedValue({ + sub: AUTHENTICATED_ID, email: PROVIDER_USER.email, isM2M: false, orgId: 'org_pinnacle', + }); + }); + + describe.each(['cold', 'warm'] as const)('%s cache', (cacheState) => { + it.each(organizationSelectors)('rejects a mismatched %s selector and permits a matching selector', async (_location, select) => { + for (const selectedOrg of ['org_streamhaus', 'org_pinnacle']) { + const token = bearer ? `header.org${++sequence}.signature` : `sealed-org-${++sequence}`; + if (cacheState === 'warm') await middleware(request(token, bearer), response(), vi.fn()); + const req = request(token, bearer); + select(req, selectedOrg); + const res = response(); + const next = vi.fn(); + const snapshotsBefore = mocks.loadAuthorizationSnapshot.mock.calls.length; + await middleware(req, res, next); + if (selectedOrg === 'org_streamhaus') { + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledTimes(snapshotsBefore); + } else { + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + expect(req.user?.authorizationSnapshot?.selectedOrganizationId).toBe('org_pinnacle'); + } + } + }); + }); + + it.each(['', ' ', null, [], {}])('rejects malformed supplied provider organization %j', async (providerOrganization) => { mocks.authenticate.mockResolvedValue({ - authenticated: true, - user: SESSION_USER, - accessToken: 'access-token', + authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token', organizationId: providerOrganization, }); - mocks.loadSealedSession.mockReturnValue({ - authenticate: mocks.authenticate, - refresh: vi.fn(), + mocks.verifyWorkOSJWT.mockResolvedValue({ + sub: AUTHENTICATED_ID, email: PROVIDER_USER.email, isM2M: false, orgId: providerOrganization, }); - mocks.getAuthorizationFingerprint.mockResolvedValue('user_epoch_primary:1'); + const token = bearer ? `header.malformed${++sequence}.signature` : `sealed-malformed-${++sequence}`; + const req = request(token, bearer); + req.headers['x-organization-id'] = 'org_pinnacle'; + const res = response(); + const next = vi.fn(); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); }); +}); - it('serves the cached session while the fingerprint is unchanged', async () => { - const cookie = `sealed-unchanged-${Date.now()}`; - const next = vi.fn() as NextFunction; +describe('optional authentication credential presence', () => { + function expectRejected(req: Request, res: Response, next: ReturnType, status: number): void { + expect(res.status).toHaveBeenCalledWith(status); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(res.redirect).not.toHaveBeenCalled(); + } - await requireAuth(makeRequest(cookie), makeResponse(), next); - await requireAuth(makeRequest(cookie), makeResponse(), next); + it('permits anonymous access only when no credential is supplied', async () => { + const req = request('unused'); + req.cookies = {}; + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(req.user).toBeUndefined(); + expect(res.status).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); - expect(next).toHaveBeenCalledTimes(2); - // Second request came from cache — the sealed session was unsealed once. - expect(mocks.authenticate).toHaveBeenCalledTimes(1); + 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(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); + expectRejected(req, res, next, 401); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); }); - it('re-validates when an identity-binding change moved the fingerprint', async () => { - const cookie = `sealed-bumped-${Date.now()}`; - const next = vi.fn() as NextFunction; + it.each(['Bearer ', 'Bearer ', 'Basic invalid', ''])('rejects malformed authorization header %j without cookie fallback', async (header) => { + const req = request('otherwise-valid-cookie'); + req.headers.authorization = header; + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); + + it('rejects an unrecognized bearer after sealed-session validation instead of using a valid cookie', async () => { + const token = `invalid-opaque-${++sequence}`; + const req = request(token, true); + req.cookies['wos-session'] = 'otherwise-valid-cookie'; + mocks.authenticate.mockResolvedValue({ authenticated: false }); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + expect(mocks.loadSealedSession).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sessionData: token })); + }); + + it('continues to authenticate native sealed bearer sessions', async () => { + const token = `native-sealed-${++sequence}`; + const req = request(token, true); + const next = vi.fn(); + await optionalAuth(req, response(), next); + expect(next).toHaveBeenCalledOnce(); + expect(mocks.loadSealedSession).toHaveBeenCalledWith(expect.objectContaining({ sessionData: token })); + expect(req.user?.authorizationSnapshot?.authenticatedUserId).toBe(AUTHENTICATED_ID); + }); - await requireAuth(makeRequest(cookie), makeResponse(), next); - expect(mocks.authenticate).toHaveBeenCalledTimes(1); + it.each(['', null, {}])('rejects malformed supplied session cookie %j', async (cookie) => { + const req = request('unused'); + req.cookies['wos-session'] = cookie; + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); - // A binding mutation bumped the credential's epoch on another instance. - mocks.getAuthorizationFingerprint.mockResolvedValue('user_epoch_primary:2'); + it('rejects expired sessions and their dead-cache replay instead of making them anonymous', async () => { + const token = `dead-session-${++sequence}`; + mocks.authenticate.mockResolvedValue({ authenticated: false }); + for (let attempt = 0; attempt < 2; attempt += 1) { + const req = request(token); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + } + expect(mocks.authenticate).toHaveBeenCalledOnce(); + expect(mocks.refresh).toHaveBeenCalledOnce(); + }); - await requireAuth(makeRequest(cookie), makeResponse(), next); + it('rejects a provider result claiming authentication without a user', async () => { + mocks.authenticate.mockResolvedValue({ authenticated: true }); + const req = request(`missing-user-${++sequence}`); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + }); - expect(next).toHaveBeenCalledTimes(2); - expect(mocks.authenticate).toHaveBeenCalledTimes(2); + it.each([false, true])('rejects a missing local credential on cold authentication (bearer: %s)', async (bearer) => { + mocks.loadAuthorizationSnapshot.mockResolvedValue(null); + const req = request(bearer ? `header.missing${++sequence}.signature` : `sealed-missing-${++sequence}`, bearer); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); }); - it('bypasses the cache when the epoch lookup fails rather than serving unconfirmed state', async () => { - const cookie = `sealed-lookup-error-${Date.now()}`; - const next = vi.fn() as NextFunction; + it.each(['JWT verification', 'session authentication', 'session refresh'] as const)('returns 503 on transient %s failure without anonymous fallback', async (stage) => { + const transient = Object.assign(new Error('Authentication upstream timed out'), { code: 'ETIMEDOUT' }); + const bearer = stage === 'JWT verification'; + const req = request(bearer ? `header.timeout${++sequence}.signature` : `sealed-timeout-${++sequence}`, bearer); + if (stage === 'JWT verification') mocks.verifyWorkOSJWT.mockRejectedValue(transient); + if (stage === 'session authentication') mocks.authenticate.mockRejectedValue(transient); + if (stage === 'session refresh') { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + mocks.refresh.mockRejectedValue(transient); + } + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + + it('returns 503 if shared session recovery is unavailable', async () => { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + mocks.getRefreshedSession.mockRejectedValue(new Error('Session database unavailable')); + const req = request(`sealed-recovery-${++sequence}`); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); + }); + + it.each([ + ['JWKS HTTP response', Object.assign(new Error('Expected 200 OK from the JSON Web Key Set HTTP response'), { + name: 'JOSEError', code: 'ERR_JOSE_GENERIC', + })], + ['malformed JWKS response', Object.assign(new Error('Failed to parse the JSON Web Key Set HTTP response as JSON'), { + name: 'JOSEError', code: 'ERR_JOSE_GENERIC', + })], + ['provider HTTP 503', Object.assign(new Error('Service unavailable'), { status: 503 })], + ['provider HTTP 429', Object.assign(new Error('Rate limited'), { statusCode: 429 })], + ])('returns 503 for unavailable %s during JWT verification', async (_label, error) => { + mocks.verifyWorkOSJWT.mockRejectedValue(error); + const req = request(`header.unavailable${++sequence}.signature`, true); + req.cookies['wos-session'] = 'otherwise-valid-cookie'; + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); + + it.each(['initial', 'shared'] as const)('returns 503 for a retryable %s refresh result and permits a later retry', async (stage) => { + const token = `retryable-${stage}-${++sequence}`; + mocks.authenticate.mockResolvedValue({ authenticated: false }); + if (stage === 'shared') { + mocks.getRefreshedSession.mockResolvedValueOnce('shared-sealed-session'); + mocks.refresh.mockResolvedValueOnce({ authenticated: false, reason: 'invalid_grant', retryable: false }); + } + mocks.refresh.mockResolvedValue({ authenticated: false, reason: 'server_error', retryable: true }); + const req = request(token); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); - await requireAuth(makeRequest(cookie), makeResponse(), next); - expect(mocks.authenticate).toHaveBeenCalledTimes(1); + mocks.authenticate.mockResolvedValue({ authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token' }); + const retry = request(token); + const retryNext = vi.fn(); + await optionalAuth(retry, response(), retryNext); + expect(retryNext).toHaveBeenCalledOnce(); + expect(retry.user?.authorizationSnapshot?.authenticatedUserId).toBe(AUTHENTICATED_ID); + }); - mocks.getAuthorizationFingerprint.mockRejectedValueOnce(new Error('db down')); + it('returns 503 on a thrown provider HTTP 503 during session refresh', async () => { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + mocks.refresh.mockRejectedValue(Object.assign(new Error('Service unavailable'), { status: 503 })); + const req = request(`refresh-503-${++sequence}`); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); + }); - await requireAuth(makeRequest(cookie), makeResponse(), next); + it('returns 401 when sealed-session decoding rejects the presented credential', async () => { + mocks.loadSealedSession.mockImplementationOnce(() => { throw new Error('Invalid sealed session'); }); + const req = request(`invalid-sealed-${++sequence}`); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + }); +}); +afterAll(stopAuthTimers); +describe.each([ + ['required cookie', requireAuth, false], ['optional cookie', optionalAuth, false], + ['required bearer', requireAuth, true], ['optional bearer', optionalAuth, true], +] as const)('%s authorization snapshots', (_label, middleware, bearer) => { + function credential(): string { + sequence += 1; + return bearer ? `header.payload${sequence}.signature` : `sealed-snapshot-${sequence}`; + } + it('hydrates an atomic snapshot on cold start and every cached request', async () => { + const token = credential(); + const first = request(token, bearer); + const second = request(token, bearer); + const next = vi.fn() as NextFunction; + await middleware(first, response(), next); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ authorizationEpoch: '2' })); + await middleware(second, response(), next); expect(next).toHaveBeenCalledTimes(2); - expect(mocks.authenticate).toHaveBeenCalledTimes(2); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledTimes(2); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenNthCalledWith(2, AUTHENTICATED_ID, null); + expect(first.user?.authorizationSnapshot?.authorizationEpoch).toBe('1'); + expect(second.user?.authorizationSnapshot?.authorizationEpoch).toBe('2'); + expect(bearer ? mocks.verifyWorkOSJWT : mocks.authenticate).toHaveBeenCalledTimes(1); + expect(mocks.poolQuery).not.toHaveBeenCalled(); + }); + it.each([ + ['secondary becomes primary', 'user_other_primary', AUTHENTICATED_ID], + ['primary becomes secondary', AUTHENTICATED_ID, 'user_other_primary'], + ])('keeps the exact credential when %s', async (_direction, before, after) => { + const token = credential(); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ canonicalUserId: before })); + const first = request(token, bearer); + await middleware(first, response(), vi.fn()); + // A handler mutates its request object, which must never poison provider authentication. + first.user!.id = 'user_attacker_canonical'; + first.user!.authWorkosUserId = 'user_attacker_credential'; + first.user!.identityId = 'identity_attacker'; + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ + canonicalUserId: after, identityId: 'identity_changed', authorizationEpoch: '2', + })); + const second = request(token, bearer); + const next = vi.fn(); + await middleware(second, response(), next); + expect(next).toHaveBeenCalledOnce(); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenLastCalledWith(AUTHENTICATED_ID, null); + expect(second.user).toMatchObject({ id: after, identityId: 'identity_changed' }); + expect(second.user?.authWorkosUserId ?? second.user?.id).toBe(AUTHENTICATED_ID); + expect(second.user?.authorizationSnapshot?.authorizationEpoch).toBe('2'); + }); + it.each(['cold', 'warm'] as const)('returns unavailable with no fallback on a %s-cache database outage', async (cacheState) => { + const token = credential(); + if (cacheState === 'warm') await middleware(request(token, bearer), response(), vi.fn()); + mocks.loadAuthorizationSnapshot.mockRejectedValue(new AuthorizationSnapshotUnavailableError()); + const req = request(token, bearer); + const res = response(); + const next = vi.fn(); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(res.redirect).not.toHaveBeenCalled(); + expect(bearer ? mocks.verifyWorkOSJWT : mocks.authenticate).toHaveBeenCalledTimes(1); + }); + it('rejects a deleted exact credential instead of recovering its canonical user', async () => { + const token = credential(); + await middleware(request(token, bearer), response(), vi.fn()); + mocks.loadAuthorizationSnapshot.mockResolvedValue(null); + const req = request(token, bearer); + const res = response(); + const next = vi.fn(); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + }); + it('keeps organization selection request-local across cache hits and leaves no selection empty', async () => { + const token = credential(); + for (const org of ['org_pinnacle', 'org_streamhaus', null]) { + const req = request(token, bearer); + if (org) req.headers['x-organization-id'] = org; + await middleware(req, response(), vi.fn()); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenLastCalledWith(AUTHENTICATED_ID, org); + expect(req.user?.authorizationSnapshot?.selectedOrganizationId).toBe(org); + } + expect(bearer ? mocks.verifyWorkOSJWT : mocks.authenticate).toHaveBeenCalledTimes(1); + }); + it('uses current local credential email instead of a stale provider email', async () => { + const token = credential(); + await middleware(request(token, bearer), response(), vi.fn()); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ + credential: { email: 'sam-current@example.test', firstName: 'Sam', lastName: 'Adeyemi', emailVerified: true }, + })); + const req = request(token, bearer); + await middleware(req, response(), vi.fn()); + expect(req.user?.email).toBe('sam-current@example.test'); + }); + it('does not apply an old provider verification to the current unverified email', async () => { + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ + credential: { email: 'sam-unverified@example.test', firstName: 'Sam', lastName: 'Adeyemi', emailVerified: false }, + })); + const req = request(credential(), bearer); + await middleware(req, response(), vi.fn()); + expect(req.user).toMatchObject({ email: 'sam-unverified@example.test', emailVerified: false }); + }); + it('rejects conflicting explicit organization selectors before hydrating authority', async () => { + const req = request(credential(), bearer); + req.headers['x-organization-id'] = 'org_pinnacle'; + req.params.orgId = 'org_streamhaus'; + const res = response(); + const next = vi.fn(); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + it('keeps authorization provenance out of serialized and spread user objects', async () => { + const req = request(credential(), bearer); + await middleware(req, response(), vi.fn()); + expect(req.user?.authorizationSnapshot?.authenticatedUserId).toBe(AUTHENTICATED_ID); + expect(JSON.stringify(req.user)).not.toContain('authorizationSnapshot'); + expect({ ...req.user }).not.toHaveProperty('authorizationSnapshot'); + }); +}); +it('a cookie cached by optional auth is rehydrated before required auth', async () => { + const token = `shared-optional-${++sequence}`; + await optionalAuth(request(token), response(), vi.fn()); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ canonicalUserId: 'user_changed', authorizationEpoch: '7' })); + const second = request(token); + await requireAuth(second, response(), vi.fn()); + expect(mocks.authenticate).toHaveBeenCalledTimes(1); + expect(second.user).toMatchObject({ id: 'user_changed', authWorkosUserId: AUTHENTICATED_ID }); + expect(second.user?.authorizationSnapshot?.authorizationEpoch).toBe('7'); +}); + +describe.each([['required', requireAuth], ['optional', optionalAuth]] as const)('%s bearer bans', (_label, middleware) => { + it.each(['cold', 'warm'] as const)('checks the banned exact linked credential on a %s cache', async (cacheState) => { + const token = `header.banned${++sequence}.signature`; + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ canonicalUserId: 'user_unbanned_primary' })); + if (cacheState === 'warm') { + await middleware(request(token, true), response(), vi.fn()); + invalidateBanCache('user', AUTHENTICATED_ID); + } + mocks.checkPlatformBan.mockImplementation(async (userId: string) => userId === AUTHENTICATED_ID + ? { banned: true, ban: { id: 'ban_exact_credential', reason: 'Account suspended' } } + : { banned: false }); + const res = response(); + const next = vi.fn(); + await middleware(request(token, true), res, next); + expect(mocks.checkPlatformBan).toHaveBeenLastCalledWith(AUTHENTICATED_ID); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); +}); + +describe.each([['required', requireAuth], ['optional', optionalAuth]] as const)('%s concurrent bearer hydration', (_label, middleware) => { + it('keeps overlapping cached JWT requests bound to the original authenticated credential', async () => { + const token = `header.overlapping${++sequence}.signature`; + await middleware(request(token, true), response(), vi.fn()); + + let resolveFirst!: (value: AuthorizationSnapshot) => void; + let resolveSecond!: (value: AuthorizationSnapshot) => void; + let markFirstStarted!: () => void; + let markSecondStarted!: () => void; + const firstStarted = new Promise((resolve) => { markFirstStarted = resolve; }); + const secondStarted = new Promise((resolve) => { markSecondStarted = resolve; }); + const firstSnapshot = snapshot({ canonicalUserId: 'user_primary_a' }); + const secondSnapshot = snapshot({ canonicalUserId: 'user_primary_b', authorizationEpoch: '2' }); + mocks.loadAuthorizationSnapshot + .mockImplementationOnce(() => { + markFirstStarted(); + return new Promise((resolve) => { resolveFirst = resolve; }); + }) + .mockImplementationOnce(() => { + markSecondStarted(); + return new Promise((resolve) => { resolveSecond = resolve; }); + }) + .mockResolvedValue(secondSnapshot); + + const first = request(token, true); + const second = request(token, true); + const firstNext = vi.fn(); + const secondNext = vi.fn(); + const firstPending = middleware(first, response(), firstNext); + const secondPending = middleware(second, response(), secondNext); + await Promise.all([firstStarted, secondStarted]); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenNthCalledWith(2, AUTHENTICATED_ID, null); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenNthCalledWith(3, AUTHENTICATED_ID, null); + + resolveFirst(firstSnapshot); + await firstPending; + const firstUser = first.user; + expect(firstUser).toMatchObject({ id: 'user_primary_a', authWorkosUserId: AUTHENTICATED_ID }); + expect(firstUser?.authorizationSnapshot).toBe(firstSnapshot); + expect(secondNext).not.toHaveBeenCalled(); + + resolveSecond(secondSnapshot); + await secondPending; + expect(firstNext).toHaveBeenCalledOnce(); + expect(secondNext).toHaveBeenCalledOnce(); + expect(second.user).not.toBe(firstUser); + expect(second.user).toMatchObject({ id: 'user_primary_b', authWorkosUserId: AUTHENTICATED_ID }); + expect(second.user?.authorizationSnapshot).toBe(secondSnapshot); + expect(first.user).toBe(firstUser); + expect(first.user).toMatchObject({ id: 'user_primary_a', authWorkosUserId: AUTHENTICATED_ID }); + expect(first.user?.authorizationSnapshot).toBe(firstSnapshot); + + const following = request(token, true); + await middleware(following, response(), vi.fn()); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenNthCalledWith(4, AUTHENTICATED_ID, null); + expect(following.user?.authorizationSnapshot?.authenticatedUserId).toBe(AUTHENTICATED_ID); + expect(following.user?.authWorkosUserId).toBe(AUTHENTICATED_ID); + expect(mocks.verifyWorkOSJWT).toHaveBeenCalledTimes(1); + }); +}); + +describe('optional authentication HTTP route boundary', () => { + function route(parsedCookies: Record = {}) { + const app = express(); + // Match network-health-security.test.ts: authentication is under test, not + // cookie-parser. Inject its parsed output from a fixture owned by this app; + // the HTTP requests below send the corresponding Cookie header. An absent + // cookie remains {}, while a presented empty cookie retains its empty value. + app.use((req, _res, next) => { + req.cookies = { ...parsedCookies }; + next(); + }); + app.use(csrfProtection); + const handler = vi.fn((req: Request, res: Response) => { + res.json({ authenticated: Boolean(req.user) }); + }); + app.get('/optional-auth-test', optionalAuth, handler); + return { app, handler }; + } + + it('reaches the anonymous handler when neither Authorization nor Cookie is sent', async () => { + const { app, handler } = route(); + const result = await supertest(app).get('/optional-auth-test'); + expect(result.status).toBe(200); + expect(result.body).toEqual({ authenticated: false }); + expect(handler).toHaveBeenCalledOnce(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + + it.each(['', 'Basic invalid', 'Bearer header.invalid.signature'])('rejects supplied Authorization %j without using an otherwise valid cookie', async (authorization) => { + 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') + .set('Authorization', authorization) + .set('Cookie', `wos-session=${cookie}`); + expect(result.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + + it('rejects an opaque invalid bearer after validating that bearer rather than its companion cookie', async () => { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + const token = `invalid-native-route-${++sequence}`; + const { app, handler } = route({ 'wos-session': 'otherwise-valid' }); + const result = await supertest(app).get('/optional-auth-test') + .set('Authorization', `Bearer ${token}`) + .set('Cookie', 'wos-session=otherwise-valid'); + expect(result.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sessionData: token })); + }); + + it.each(['empty', 'invalid'] as const)('rejects an %s presented wos-session cookie before the handler', async (kind) => { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + const token = kind === 'empty' ? '' : `invalid-cookie-route-${++sequence}`; + const { app, handler } = route({ 'wos-session': token }); + const result = await supertest(app).get('/optional-auth-test').set('Cookie', `wos-session=${token}`); + expect(result.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + if (kind === 'empty') expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); + + it.each(['bearer', 'cookie'] as const)('returns 503 for unavailable authorization on a presented %s without running the handler', async (kind) => { + mocks.loadAuthorizationSnapshot.mockRejectedValue(new AuthorizationSnapshotUnavailableError()); + const cookie = `unavailable-route-${++sequence}`; + const { app, handler } = route(kind === 'cookie' ? { 'wos-session': cookie } : {}); + const httpRequest = supertest(app).get('/optional-auth-test'); + if (kind === 'bearer') httpRequest.set('Authorization', `Bearer header.route${++sequence}.signature`); + else httpRequest.set('Cookie', `wos-session=${cookie}`); + const result = await httpRequest; + expect(result.status).toBe(503); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledOnce(); }); }); 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..8fca9793183 --- /dev/null +++ b/server/tests/unit/caller-org-auth-errors.test.ts @@ -0,0 +1,185 @@ +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) })); +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'])('denies cookie authority without explicit organization provenance: %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(403); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.issueDomainClaim).not.toHaveBeenCalled(); + 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/db-client-checkout.test.ts b/server/tests/unit/db-client-checkout.test.ts index b813b903fb6..dc097bc9db9 100644 --- a/server/tests/unit/db-client-checkout.test.ts +++ b/server/tests/unit/db-client-checkout.test.ts @@ -311,6 +311,116 @@ describe('db client checkout and health checks', () => { await db.closeDatabase(); }); + it('fails an exhausted snapshot pool at the absolute deadline and releases a late client', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const pg = mockPg(); + let deliverClient!: (client: { query: ReturnType; release: ReturnType }) => void; + pg.poolConnect.mockReturnValue(new Promise((resolve) => { + deliverClient = resolve; + })); + + const db = await import('../../src/db/client.js'); + const snapshots = await import('../../src/db/user-authorization-snapshot-db.js'); + db.initializeDatabase({ connectionString: 'postgresql://localhost/test' }); + + const pending = snapshots.loadAuthorizationSnapshot('user_exhausted_pool', 'org_exhausted_pool'); + const rejection = expect(pending).rejects.toBeInstanceOf( + snapshots.AuthorizationSnapshotUnavailableError, + ); + await vi.advanceTimersByTimeAsync(1_999); + expect(pg.poolConnect).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await rejection; + + const lateClient = { query: vi.fn(), release: vi.fn() }; + deliverClient(lateClient); + await Promise.resolve(); + await Promise.resolve(); + expect(lateClient.query).not.toHaveBeenCalled(); + expect(lateClient.release).toHaveBeenCalledTimes(1); + expect(pg.poolConnect).toHaveBeenCalledTimes(1); + + await db.closeDatabase(); + }); + + it('retries one transient snapshot checkout with only the remaining absolute budget', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const pg = mockPg(); + const release = vi.fn(); + const query = vi.fn().mockImplementation(async (text: string) => { + if (text.includes('pg_catalog.pg_is_in_recovery')) { + return { + rows: [{ + in_recovery: false, + authenticated_user_id: 'user_retry_checkout', + canonical_user_id: 'user_retry_checkout', + identity_id: 'fd3043f7-cb4f-43c7-9b81-22ac97576150', + authorization_epoch: '11', + email: 'sam@pinnacle.example', + email_verified: true, + first_name: 'Sam', + last_name: 'Adeyemi', + grant_id: null, + grant_organization_id: null, + grant_role: null, + grant_effective_from: null, + grant_effective_until: null, + }], + }; + } + return { rows: [] }; + }); + pg.poolConnect + .mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + 700); + throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + }) + .mockResolvedValueOnce({ query, release }); + + const db = await import('../../src/db/client.js'); + const snapshots = await import('../../src/db/user-authorization-snapshot-db.js'); + db.initializeDatabase({ connectionString: 'postgresql://localhost/test' }); + + await expect(snapshots.loadAuthorizationSnapshot('user_retry_checkout', 'org_retry_checkout')) + .resolves.toMatchObject({ + authenticatedUserId: 'user_retry_checkout', + selectedOrganizationId: 'org_retry_checkout', + authorizationEpoch: '11', + }); + + expect(pg.poolConnect).toHaveBeenCalledTimes(2); + expect(query).toHaveBeenCalledWith( + "SELECT set_config('statement_timeout', $1, true)", + ['1300ms'], + ); + expect(release).toHaveBeenCalledTimes(1); + + await db.closeDatabase(); + }); + + it('does not multiply snapshot retries inside pool checkout', async () => { + const pg = mockPg(); + const unexpectedClient = { query: vi.fn(), release: vi.fn() }; + pg.poolConnect + .mockRejectedValueOnce(Object.assign(new Error('first reset'), { code: 'ECONNRESET' })) + .mockRejectedValueOnce(Object.assign(new Error('second reset'), { code: 'ECONNRESET' })) + .mockResolvedValueOnce(unexpectedClient); + + const db = await import('../../src/db/client.js'); + const snapshots = await import('../../src/db/user-authorization-snapshot-db.js'); + db.initializeDatabase({ connectionString: 'postgresql://localhost/test' }); + + await expect(snapshots.loadAuthorizationSnapshot('user_two_attempts', 'org_two_attempts')) + .rejects.toBeInstanceOf(snapshots.AuthorizationSnapshotUnavailableError); + expect(pg.poolConnect).toHaveBeenCalledTimes(2); + expect(unexpectedClient.query).not.toHaveBeenCalled(); + expect(unexpectedClient.release).not.toHaveBeenCalled(); + + await db.closeDatabase(); + }); + it('rolls back and releases the client after a timed query fails', async () => { const pg = mockPg(); const release = vi.fn(); 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 5edff42ddf3..1904d2f6053 100644 --- a/server/tests/unit/member-profile-admin-global-auth.test.ts +++ b/server/tests/unit/member-profile-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/network-health-security.test.ts b/server/tests/unit/network-health-security.test.ts index 94f35fa58c4..2e126be2de7 100644 --- a/server/tests/unit/network-health-security.test.ts +++ b/server/tests/unit/network-health-security.test.ts @@ -54,6 +54,7 @@ vi.mock('../../src/db/org-filters.js', () => ({ vi.mock('../../src/db/client.js', async (importOriginal) => ({ ...(await importOriginal()), getPool: () => ({ query: mocks.poolQuery }), + queryWithTimeout: mocks.poolQuery, })); vi.mock('../../src/addie/mcp/admin-tools.js', () => ({ @@ -112,7 +113,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], }, @@ -182,10 +183,27 @@ describe('network-health global authorization boundary', () => { mocks.checkPlatformBanForApiKey.mockResolvedValue({ banned: false }); mocks.checkPlatformBan.mockResolvedValue({ banned: false }); mocks.isWebUserAAOAdmin.mockResolvedValue(false); - mocks.poolQuery.mockImplementation((sql: string) => { - if (sql.includes('FROM users')) { + mocks.poolQuery.mockImplementation((sql: string, params: unknown[]) => { + if (sql.includes('pg_catalog.pg_is_in_recovery()')) { + const userId = params[0] as string; + const isAdmin = userId === 'user_platform_admin'; return Promise.resolve({ - rows: [{ first_name: 'Regular', last_name: 'User' }], + rows: [{ + in_recovery: false, + authenticated_user_id: userId, + canonical_user_id: userId, + identity_id: `identity_${userId}`, + authorization_epoch: '0', + email: isAdmin ? 'platform-admin@example.test' : 'user@example.test', + email_verified: true, + first_name: isAdmin ? 'Platform' : 'Regular', + last_name: isAdmin ? 'Admin' : 'User', + grant_id: null, + grant_organization_id: null, + grant_role: null, + grant_effective_from: null, + grant_effective_until: null, + }], rowCount: 1, }); } 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..9f24df7726b 100644 --- a/server/tests/unit/resolve-caller-org.test.ts +++ b/server/tests/unit/resolve-caller-org.test.ts @@ -7,7 +7,7 @@ * 1. WorkOS OIDC access token (RS256 JWT, `org_id` claim) — JWKS is picked * per-token from the `iss` claim, not from a server-wide env var. * 2. WorkOS API key (sk_* / wos_api_key_*) - * 3. Sealed session (middleware sets `req.user`) + * 3. Sealed session (middleware supplies a fresh authorization snapshot) * * Regression guard for the issue where OIDC JWTs silently fell through to * public-only, leaving `agents: []` for authenticated callers. @@ -19,12 +19,13 @@ const validateWorkOSApiKeyMock = vi.fn(); const jwtVerifyMock = vi.fn(); const decodeJwtMock = vi.fn(); const dbQueryMock = vi.fn(); - -vi.mock('../../src/middleware/auth.js', () => ({ +vi.mock('../../src/middleware/auth.js', async () => ({ + ...await import('../../src/auth/organization-selection.js'), 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 +40,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 +83,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 +108,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 +117,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 +137,26 @@ describe('resolveCallerOrgId', () => { expect(dbQueryMock).not.toHaveBeenCalled(); }); + it.each([ + ['header', { headers: { 'x-organization-id': 'org_other' } }], + ['query org', { query: { org: 'org_other' } }], + ['query org_id', { query: { org_id: 'org_other' } }], + ['query organization_id', { query: { organization_id: 'org_other' } }], + ['query organizationId', { query: { organizationId: 'org_other' } }], + ['body org_id', { body: { org_id: 'org_other' } }], + ['body organization_id', { body: { organization_id: 'org_other' } }], + ['body organizationId', { body: { organizationId: 'org_other' } }], + ['route org_id', { params: { org_id: '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,42 +167,210 @@ describe('resolveCallerOrgId', () => { expect(jwtVerifyMock).not.toHaveBeenCalled(); }); - // ── Sealed-session path (existing behavior) ───────────────────── + 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 }] }); - it('falls back to users.primary_organization_id when only req.user is set', async () => { - validateWorkOSApiKeyMock.mockResolvedValueOnce(null); - // resolvePrimaryOrganization: fast-path read returns the cached column - // alongside joins_valid, so a dangling pointer can fall through. - dbQueryMock.mockResolvedValueOnce({ rows: [{ primary_organization_id: 'org_from_session', 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); - const orgId = await resolveCallerOrgId(reqWith(undefined, { id: 'user_session' })); + await expect(resolveCallerOrgId(reqWith('Bearer sk_unavailable', { id: 'user_other' }))) + .rejects.toMatchObject({ status: 503 }); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); - expect(orgId).toBe('org_from_session'); - // Assert the fast-path SQL — joins_valid checks both organizations and - // organization_memberships so a dangling pointer falls through. - expect(dbQueryMock.mock.calls[0][0]).toMatch(/SELECT[\s\S]*primary_organization_id[\s\S]*EXISTS[\s\S]*organizations[\s\S]*EXISTS[\s\S]*organization_memberships[\s\S]*joins_valid[\s\S]*FROM users[\s\S]*workos_user_id\s*=\s*\$1/); - expect(dbQueryMock.mock.calls[0][1]).toEqual(['user_session']); + 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('returns null when session user has no primary org and no memberships', async () => { - validateWorkOSApiKeyMock.mockResolvedValueOnce(null); - // Fast-path: no row (column was NULL or user row missing). - dbQueryMock.mockResolvedValueOnce({ rows: [] }); - // Fallback: resolvePreferredOrganization finds no memberships. - dbQueryMock.mockResolvedValueOnce({ rows: [] }); + 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(); + }); - const orgId = await resolveCallerOrgId(reqWith(undefined, { id: 'user_no_org' })); + it('recognizes alternate bearer formatting for valid JWTs without cookie fallback', async () => { + decodeJwtMock.mockReturnValue({ iss: ISS }); + jwtVerifyMock.mockResolvedValue({ payload: { org_id: 'org_selected' } }); - expect(orgId).toBeNull(); + expect(await resolveCallerOrgId(reqWith('bEaReR\t eyJvalid.payload.sig', { id: 'user_other' }))).toBe('org_selected'); + expect(dbQueryMock).not.toHaveBeenCalled(); }); - it('swallows DB errors and returns null rather than throwing', async () => { - validateWorkOSApiKeyMock.mockResolvedValueOnce(null); - dbQueryMock.mockRejectedValueOnce(new Error('connection reset')); + 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(); + }); - const orgId = await resolveCallerOrgId(reqWith(undefined, { id: 'user_db_err' })); + 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(orgId).toBeNull(); + expect(await result).toEqual({ status: 503 }); + expect(validateWorkOSApiKeyMock).not.toHaveBeenCalled(); + expect(dbQueryMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['headers', 'x-organization-id'], + ['query', 'organizationId'], + ['query', 'org_id'], + ['body', 'org_id'], + ['params', 'org_id'], + ['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('does not infer a primary or sole org from a bare authenticated user', async () => { + dbQueryMock.mockResolvedValue({ rows: [{ primary_organization_id: 'org_primary', joins_valid: true }] }); + + expect(await resolveCallerOrgId(reqWith(undefined, { id: 'user_session' }))).toBeNull(); + expect(dbQueryMock).not.toHaveBeenCalled(); + expect(validateWorkOSApiKeyMock).not.toHaveBeenCalled(); + }); + + it('uses verified bearer authority independently of an attached cookie user without a snapshot', async () => { + decodeJwtMock.mockReturnValueOnce({ iss: ISS }); + jwtVerifyMock.mockResolvedValue({ payload: { org_id: 'org_bearer' } }); + + expect(await resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi', { id: 'user_session' }))).toBe('org_bearer'); + expect(jwtVerifyMock).toHaveBeenCalledTimes(1); + expect(validateWorkOSApiKeyMock).not.toHaveBeenCalled(); + expect(dbQueryMock).not.toHaveBeenCalled(); }); // ── Unauthenticated / malformed ──────────────────────────────── @@ -232,9 +426,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/resolve-user-org-authorization.test.ts b/server/tests/unit/resolve-user-org-authorization.test.ts index 8d4aa180609..75912943f00 100644 --- a/server/tests/unit/resolve-user-org-authorization.test.ts +++ b/server/tests/unit/resolve-user-org-authorization.test.ts @@ -1,159 +1,197 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { WorkOS } from "@workos-inc/node"; - -const queryMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../src/db/client.js", () => ({ query: queryMock })); - -import { - evaluateUserOrgRoleAuthorization, - resolveUserOrgAuthorization, -} from "../../src/utils/resolve-user-org-authorization.js"; - -const ORGANIZATION_ID = "org_authorization_test"; - -function workosWithMemberships(data: unknown[]): WorkOS { +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkOS } from '@workos-inc/node'; +import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; + +const snapshotMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/db/user-authorization-snapshot-db.js', async (importOriginal) => ({ + ...await importOriginal(), + loadAuthorizationSnapshot: snapshotMock, +})); +import { evaluateUserOrgRoleAuthorization, resolveUserOrgAuthorization } from '../../src/utils/resolve-user-org-authorization.js'; + +const ORGANIZATION_ID = 'org_pinnacle'; +const AUTHENTICATED_ID = 'user_authenticated'; +function snapshot(overrides: Partial = {}): AuthorizationSnapshot { + return { + authenticatedUserId: AUTHENTICATED_ID, canonicalUserId: 'user_canonical', + identityId: 'identity_linked', selectedOrganizationId: ORGANIZATION_ID, authorizationEpoch: '1', + credential: { email: 'sam@example.test', firstName: 'Sam', lastName: 'Adeyemi', emailVerified: true }, + credentialGrant: null, ...overrides, + }; +} +function grant(role: 'owner' | 'admin' | 'member' = 'member'): NonNullable { return { - userManagement: { - listOrganizationMemberships: vi.fn().mockResolvedValue({ data }), - }, + id: 'grant_pinnacle', organizationId: ORGANIZATION_ID, role, + effectiveFrom: '2026-01-01T00:00:00.000000Z', effectiveUntil: null, + }; +} +function principal(authorizationSnapshot?: AuthorizationSnapshot) { + return { id: 'user_canonical', authWorkosUserId: AUTHENTICATED_ID, authorizationSnapshot }; +} +function workosWithMemberships(data: unknown[] = []): WorkOS { + return { + userManagement: { listOrganizationMemberships: vi.fn().mockResolvedValue({ data }) }, } as unknown as WorkOS; } +function membership(organizationId = ORGANIZATION_ID, role = 'member', userId = AUTHENTICATED_ID) { + return { userId, organizationId, status: 'active', role: { slug: role } }; +} +const FORBIDDEN = { status: 'forbidden', complete: true, unavailableSources: [] }; +const SNAPSHOT_UNAVAILABLE = { status: 'unavailable', complete: false, unavailableSources: ['authorization_snapshot'] }; -describe("resolveUserOrgAuthorization", () => { +describe('resolveUserOrgAuthorization', () => { beforeEach(() => { - queryMock.mockReset(); - queryMock.mockResolvedValue({ rows: [] }); + snapshotMock.mockReset(); + snapshotMock.mockImplementation(async (id: string, org: string | null) => snapshot({ + authenticatedUserId: id, selectedOrganizationId: org, + })); }); - - it("uses the exact authenticated credential rather than the canonical identity user", async () => { - const workos = workosWithMemberships([ - { - organizationId: ORGANIZATION_ID, - status: "active", - role: { slug: "member" }, - }, - ]); - - await resolveUserOrgAuthorization( - workos, - { id: "user_canonical", authWorkosUserId: "user_authenticated" }, - ORGANIZATION_ID - ); - - expect( - workos.userManagement.listOrganizationMemberships - ).toHaveBeenCalledWith({ - userId: "user_authenticated", - organizationId: ORGANIZATION_ID, + it.each([ + ['secondary credential', 'user_canonical', AUTHENTICATED_ID], + ['primary credential', AUTHENTICATED_ID, undefined], + ])('uses exact %s authority independently of canonical attribution', async (_label, id, authWorkosUserId) => { + const workos = workosWithMemberships([membership()]); + const result = await resolveUserOrgAuthorization(workos, { id, authWorkosUserId }, ORGANIZATION_ID); + expect(result).toMatchObject({ status: 'authorized', membership: { source: 'workos' } }); + expect(workos.userManagement.listOrganizationMemberships).toHaveBeenCalledExactlyOnceWith({ + userId: AUTHENTICATED_ID, organizationId: ORGANIZATION_ID, }); - expect(queryMock).toHaveBeenCalledWith(expect.any(String), [ - "user_authenticated", - ORGANIZATION_ID, - ]); + expect(snapshotMock).toHaveBeenCalledTimes(2); + expect(snapshotMock).toHaveBeenNthCalledWith(1, AUTHENTICATED_ID, ORGANIZATION_ID); + expect(snapshotMock).toHaveBeenNthCalledWith(2, AUTHENTICATED_ID, ORGANIZATION_ID); }); - - it("returns a definitive denial only when both authority sources are available", async () => { - const result = await resolveUserOrgAuthorization( - workosWithMemberships([]), - { id: "user_without_access" }, - ORGANIZATION_ID - ); - - expect(result).toEqual({ - status: "forbidden", - complete: true, - unavailableSources: [], - }); + it('returns a definitive denial only when both authority sources are available', async () => { + expect(await resolveUserOrgAuthorization(workosWithMemberships(), principal(), ORGANIZATION_ID)).toEqual(FORBIDDEN); }); - - it("distinguishes an authority-source outage from a denial", async () => { - queryMock.mockRejectedValue(new Error("database unavailable")); - - const result = await resolveUserOrgAuthorization( - workosWithMemberships([]), - { id: "user_unknown" }, - ORGANIZATION_ID - ); - - expect(result).toEqual({ - status: "unavailable", - complete: false, - unavailableSources: ["credential_grant"], - }); - expect(evaluateUserOrgRoleAuthorization(result)).toEqual({ - status: "unavailable", - unavailableSources: ["credential_grant"], - }); + it.each(['', ' '])('never chooses an implicit organization for missing selection %j', async (org) => { + const workos = workosWithMemberships([membership()]); + expect(await resolveUserOrgAuthorization(workos, principal(), org)).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(snapshotMock).not.toHaveBeenCalled(); }); - - it("uses an active exact-credential grant and preserves partial role uncertainty", async () => { - queryMock.mockResolvedValue({ - rows: [{ workos_organization_id: ORGANIZATION_ID, role: "member" }], + it.each([ + ['another linked credential', membership(ORGANIZATION_ID, 'owner', 'user_canonical')], + ['another organization', membership('org_streamhaus', 'owner')], + ])('does not inherit membership from %s', async (_label, otherMembership) => { + const workos = workosWithMemberships([otherMembership]); + expect(await resolveUserOrgAuthorization(workos, principal(), ORGANIZATION_ID)).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).toHaveBeenCalledWith({ + userId: AUTHENTICATED_ID, organizationId: ORGANIZATION_ID, }); - const workos = { - userManagement: { - listOrganizationMemberships: vi - .fn() - .mockRejectedValue(new Error("WorkOS unavailable")), - }, - } as unknown as WorkOS; - + }); + it('rejects replaying a selected organization snapshot against another organization', async () => { + const workos = workosWithMemberships([membership('org_streamhaus')]); + const result = await resolveUserOrgAuthorization(workos, principal(snapshot()), 'org_streamhaus'); + expect(result).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + }); + it('binds an unselected authenticated snapshot to an explicit organization using fresh authority', async () => { const result = await resolveUserOrgAuthorization( - workos, - { id: "user_granted" }, - ORGANIZATION_ID + workosWithMemberships([membership('org_streamhaus')]), + principal(snapshot({ selectedOrganizationId: null })), 'org_streamhaus', ); - + expect(result).toMatchObject({ status: 'authorized', membership: { organizationId: 'org_streamhaus', source: 'workos' } }); + }); + it('distinguishes snapshot outage from denial without falling back to valid provider membership', async () => { + snapshotMock.mockRejectedValue(new Error('database unavailable')); + const workos = workosWithMemberships([membership(ORGANIZATION_ID, 'owner')]); + const result = await resolveUserOrgAuthorization(workos, principal(), ORGANIZATION_ID); + expect(result).toEqual(SNAPSHOT_UNAVAILABLE); + expect(evaluateUserOrgRoleAuthorization(result)).toEqual({ status: 'unavailable', unavailableSources: ['authorization_snapshot'] }); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + }); + it('fails unavailable if the post-provider primary revalidation fails', async () => { + snapshotMock.mockResolvedValueOnce(snapshot({ credentialGrant: grant('owner') })) + .mockRejectedValueOnce(new Error('database unavailable')); + const result = await resolveUserOrgAuthorization(workosWithMemberships([membership()]), principal(), ORGANIZATION_ID); + expect(result).toEqual(SNAPSHOT_UNAVAILABLE); + }); + it('uses an active exact-credential grant and preserves partial role uncertainty', async () => { + snapshotMock.mockResolvedValue(snapshot({ credentialGrant: grant() })); + const workos = workosWithMemberships(); + vi.mocked(workos.userManagement.listOrganizationMemberships).mockRejectedValue(new Error('WorkOS unavailable')); + const result = await resolveUserOrgAuthorization(workos, principal(), ORGANIZATION_ID); expect(result).toMatchObject({ - status: "authorized", - complete: false, - unavailableSources: ["workos"], - membership: { role: "member", source: "credential_grant" }, - }); - expect(evaluateUserOrgRoleAuthorization(result, "member")).toMatchObject({ - status: "authorized", - membership: { role: "member" }, + status: 'authorized', complete: false, unavailableSources: ['workos'], + membership: { role: 'member', source: 'credential_grant' }, }); - expect(evaluateUserOrgRoleAuthorization(result, "admin")).toEqual({ - status: "unavailable", - unavailableSources: ["workos"], + expect(evaluateUserOrgRoleAuthorization(result, 'member')).toMatchObject({ status: 'authorized' }); + expect(evaluateUserOrgRoleAuthorization(result, 'admin')).toEqual({ status: 'unavailable', unavailableSources: ['workos'] }); + }); + it('returns unavailable without an independent grant when WorkOS cannot answer', async () => { + expect(await resolveUserOrgAuthorization(null, principal(), ORGANIZATION_ID)).toEqual({ + status: 'unavailable', complete: false, unavailableSources: ['workos'], }); }); - - it("definitively denies a complete exact membership below the required role", () => { + it('definitively denies a complete exact membership below the required role', () => { expect(evaluateUserOrgRoleAuthorization({ - status: "authorized", - membership: { - organizationId: ORGANIZATION_ID, - role: "member", - source: "workos", - }, - complete: true, - unavailableSources: [], - }, "admin")).toEqual({ status: "forbidden" }); + status: 'authorized', complete: true, unavailableSources: [], + membership: { organizationId: ORGANIZATION_ID, role: 'member', source: 'workos' }, + }, 'admin')).toEqual({ status: 'forbidden' }); }); - - it("chooses the highest role while retaining its authority source", async () => { - queryMock.mockResolvedValue({ - rows: [{ workos_organization_id: ORGANIZATION_ID, role: "owner" }], - }); - const result = await resolveUserOrgAuthorization( - workosWithMemberships([ - { - organizationId: ORGANIZATION_ID, - status: "active", - role: { slug: "admin" }, - }, - ]), - { id: "user_with_two_sources" }, - ORGANIZATION_ID - ); - - expect(result).toMatchObject({ - status: "authorized", - complete: true, - membership: { role: "owner", source: "credential_grant" }, + it('chooses the highest role while retaining its authority source', async () => { + snapshotMock.mockResolvedValue(snapshot({ credentialGrant: grant('owner') })); + const result = await resolveUserOrgAuthorization(workosWithMemberships([membership(ORGANIZATION_ID, 'admin')]), principal(), ORGANIZATION_ID); + expect(result).toMatchObject({ status: 'authorized', complete: true, membership: { role: 'owner', source: 'credential_grant' } }); + }); + it.each([ + ['epoch advanced', { authorizationEpoch: '2' }], + ['epoch moved backwards', { authorizationEpoch: '0' }], + ['canonical identity changed', { canonicalUserId: 'user_new_primary' }], + ['binding changed without a matching epoch', { identityId: 'identity_new' }], + ['credential changed', { authenticatedUserId: 'user_other' }], + ] as const)('rejects replayed request state when %s', async (_case, change) => { + const workos = workosWithMemberships([membership()]); + snapshotMock.mockResolvedValue(snapshot(change)); + expect(await resolveUserOrgAuthorization(workos, principal(snapshot()), ORGANIZATION_ID)).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + }); + it.each([ + ['revoked', null], ['replaced', { ...grant('owner'), id: 'grant_replacement' }], + ])('rejects a %s grant in prior request state even when the epoch is unchanged', async (_label, currentGrant) => { + snapshotMock.mockResolvedValue(snapshot({ credentialGrant: currentGrant })); + const workos = workosWithMemberships([membership(ORGANIZATION_ID, 'owner')]); + const result = await resolveUserOrgAuthorization(workos, principal(snapshot({ credentialGrant: grant('owner') })), ORGANIZATION_ID); + expect(result).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + }); + it.each([ + ['identity binding', { canonicalUserId: 'user_new_primary', identityId: 'identity_new', authorizationEpoch: '2' }], + ['epoch alone', { authorizationEpoch: '2' }], + ['grant revoked', { credentialGrant: null }], + ['grant role', { credentialGrant: grant('member') }], + ['grant replaced', { credentialGrant: { ...grant('owner'), id: 'grant_replacement' } }], + ['grant end date', { credentialGrant: { ...grant('owner'), effectiveUntil: '2026-09-13T00:00:00.000000Z' } }], + ['selected organization', { selectedOrganizationId: 'org_streamhaus' }], + ] as const)('rejects a %s change while a provider decision is in flight', async (_case, change) => { + const before = snapshot({ credentialGrant: grant('owner') }); + let current = before; + snapshotMock.mockImplementation(async () => current); + let finishProvider!: (value: { data: ReturnType[] }) => void; + let providerStarted!: () => void; + const started = new Promise((resolve) => { providerStarted = resolve; }); + const workos = workosWithMemberships(); + vi.mocked(workos.userManagement.listOrganizationMemberships).mockImplementation(() => { + providerStarted(); + return new Promise((resolve) => { finishProvider = resolve as typeof finishProvider; }); }); + const pending = resolveUserOrgAuthorization(workos, principal(before), ORGANIZATION_ID); + await started; + current = snapshot({ ...before, ...change }); + finishProvider({ data: [membership(ORGANIZATION_ID, 'owner')] }); + expect(await pending).toEqual(FORBIDDEN); + expect(snapshotMock).toHaveBeenCalledTimes(2); + }); + it('rejects a credential deleted during provider authorization', async () => { + snapshotMock.mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(null); + expect(await resolveUserOrgAuthorization(workosWithMemberships([membership()]), principal(), ORGANIZATION_ID)).toEqual(FORBIDDEN); + }); + it('checks persisted epoch each turn and rejects replayed prior context', async () => { + const before = snapshot(); + const workos = workosWithMemberships([membership()]); + expect(await resolveUserOrgAuthorization(workos, principal(before), ORGANIZATION_ID)).toMatchObject({ status: 'authorized' }); + snapshotMock.mockResolvedValue(snapshot({ authorizationEpoch: '2' })); + expect(await resolveUserOrgAuthorization(workos, principal(before), ORGANIZATION_ID)).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).toHaveBeenCalledTimes(1); }); }); diff --git a/server/tests/unit/sealed-organization-boundary.test.ts b/server/tests/unit/sealed-organization-boundary.test.ts new file mode 100644 index 00000000000..9a938680b20 --- /dev/null +++ b/server/tests/unit/sealed-organization-boundary.test.ts @@ -0,0 +1,402 @@ +/** Mounted consumers with the real snapshot and organization resolvers. */ +import express from 'express'; +import request from 'supertest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkOSUser } from '../../src/types.js'; + +const mocks = vi.hoisted(() => ({ + principal: undefined as WorkOSUser | undefined, + boundedQuery: vi.fn(), + memberships: vi.fn(), + validateApiKey: vi.fn(), + listProposals: vi.fn(), + getProposal: vi.fn(), + getClient: vi.fn(), + isAdmin: vi.fn(), + isModerator: vi.fn(), +})); + +vi.mock('../../src/middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + req.user = mocks.principal; + next(); + }, + validateWorkOSApiKey: mocks.validateApiKey, +})); +vi.mock('../../src/db/client.js', async importOriginal => ({ + ...await importOriginal(), + queryWithTimeout: mocks.boundedQuery, + getClient: mocks.getClient, +})); +vi.mock('../../src/auth/workos-client.js', () => ({ + getAuthorizationEnforcementWorkos: () => ({ userManagement: { listOrganizationMemberships: mocks.memberships } }), +})); +vi.mock('../../src/db/community-mirror-db.js', () => ({ + CommunityMirrorDatabase: class { + listProposals = mocks.listProposals; + getProposalById = mocks.getProposal; + }, +})); +vi.mock('../../src/db/publisher-db.js', () => ({ PublisherDatabase: class {} })); +vi.mock('../../src/addie/admin-status-lookup.js', () => ({ isWebUserAAOAdmin: mocks.isAdmin })); +vi.mock('../../src/services/brand-logo-auth.js', () => ({ isRegistryModerator: mocks.isModerator })); +vi.mock('../../src/notifications/registry.js', () => ({})); +vi.mock('../../src/middleware/rate-limit.js', () => { + const pass = (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(); + return { registryReadRateLimiter: pass, brandCreationRateLimiter: pass }; +}); + +import { buildConformanceTokenRouter } from '../../src/conformance/token-route.js'; +import { createCommunityMirrorRouter } from '../../src/routes/community-mirrors.js'; +import { verifyConformanceToken } from '../../src/conformance/token.js'; +import { loadAuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; +import { resolveCallerOrgId } from '../../src/routes/helpers/resolve-caller-org.js'; + +const ORG = 'org_pinnacle'; +const OTHER_ORG = 'org_streamhaus'; +const PROPOSAL_ID = '00000000-0000-4000-8000-000000000001'; +const originalSecret = process.env.CONFORMANCE_JWT_SECRET; +let row: Record; + +function app() { + const instance = express(); + instance.use(express.json()); + instance.use('/api/conformance', buildConformanceTokenRouter()); + return instance; +} + +function mirrorApp(attachedOrganizationId?: string) { + const instance = express(); + instance.use(express.json()); + instance.use('/api/registry', createCommunityMirrorRouter({ + requireAuth: (req, _res, next) => { + req.user = mocks.principal; + if (attachedOrganizationId) Object.assign(req, { apiKey: { organizationId: attachedOrganizationId } }); + next(); + }, + })); + return instance; +} + +async function authenticate(authenticatedUserId = 'user_primary', canonicalUserId = 'user_linked', organizationId: string | null = ORG) { + row.authenticated_user_id = authenticatedUserId; + row.canonical_user_id = canonicalUserId; + const snapshot = await loadAuthorizationSnapshot(authenticatedUserId, organizationId); + mocks.principal = { + id: canonicalUserId, + authWorkosUserId: authenticatedUserId, + email: 'sam@pinnacle.example', + }; + Object.defineProperty(mocks.principal, 'authorizationSnapshot', { value: snapshot }); + mocks.boundedQuery.mockClear(); +} + +function post() { + return request(app()).post('/api/conformance/token').set('Cookie', 'wos-session=sealed-session-fixture'); +} + +beforeEach(() => { + vi.resetAllMocks(); + process.env.CONFORMANCE_JWT_SECRET = 'sealed-organization-test-secret'; + mocks.principal = undefined; + row = { + in_recovery: false, + authenticated_user_id: 'user_primary', canonical_user_id: 'user_linked', identity_id: 'identity_linked', + authorization_epoch: '7', email: 'sam@pinnacle.example', email_verified: true, + first_name: 'Sam', last_name: 'Adeyemi', + grant_id: null, grant_organization_id: null, grant_role: null, + grant_effective_from: null, grant_effective_until: null, + }; + mocks.boundedQuery.mockImplementation(async () => ({ rows: [{ ...row }], rowCount: 1 })); + mocks.memberships.mockImplementation(async ({ userId, organizationId }) => ({ + data: [{ id: 'membership_pinnacle', userId, organizationId, status: 'active', role: { slug: 'member' } }], + })); + mocks.validateApiKey.mockResolvedValue(null); + mocks.listProposals.mockResolvedValue({ proposals: [{ id: PROPOSAL_ID }], total: 1 }); + mocks.getProposal.mockImplementation(async () => ({ + id: PROPOSAL_ID, proposed_by_user_id: mocks.principal?.id, + proposed_by_organization_id: ORG, adagents_json: { private_proposal: true }, + })); + // A canonical admin/moderator result must never rescue denied exact authority. + mocks.isAdmin.mockResolvedValue(true); + mocks.isModerator.mockResolvedValue(true); +}); + +afterAll(() => { + if (originalSecret === undefined) delete process.env.CONFORMANCE_JWT_SECRET; + else process.env.CONFORMANCE_JWT_SECRET = originalSecret; +}); + +describe.each([ + ['user_primary', 'user_linked'], + ['user_linked', 'user_primary'], +])('explicit Bearer with authenticated %s attributed to %s', (authenticated, canonical) => { + it.each([ + 'Bearer', + 'Bearer malformed opaque token', + 'Bearer sealed-session-fixture', + 'bEaReR sealed-session-fixture', + 'bEaReR\t sealed-session-fixture', + ])('rejects %s before using valid cookie authority in mounted consumers', async authorization => { + await authenticate(authenticated, canonical); + const mirror = mirrorApp(); + const responses = [ + await post().set('Authorization', authorization).set('X-Organization-Id', ORG), + await request(mirror).get('/api/registry/mirror-proposals') + .set('Cookie', 'wos-session=sealed-session-fixture') + .set('Authorization', authorization).set('X-Organization-Id', ORG), + await request(mirror).get(`/api/registry/mirror-proposals/${PROPOSAL_ID}`) + .set('Cookie', 'wos-session=sealed-session-fixture') + .set('Authorization', authorization).set('X-Organization-Id', ORG), + ]; + for (const response of responses) { + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'invalid_bearer_token' }); + } + expect(mocks.boundedQuery).not.toHaveBeenCalled(); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.listProposals).not.toHaveBeenCalled(); + expect(mocks.getProposal).not.toHaveBeenCalled(); + expect(mocks.getClient).not.toHaveBeenCalled(); + expect(mocks.isAdmin).not.toHaveBeenCalled(); + expect(mocks.isModerator).not.toHaveBeenCalled(); + }); +}); + +describe.each([ + ['user_primary', 'user_linked'], + ['user_linked', 'user_primary'], +])('community mirror proposals with authenticated %s attributed to %s', (authenticated, canonical) => { + it.each(['no selection', 'no membership', 'canonical membership', 'conflict', 'stale epoch', 'unavailable'])( + 'denies list and detail for %s without canonical-user fallback', async denied => { + await authenticate(authenticated, canonical, denied === 'no selection' ? null : ORG); + if (denied === 'no membership') mocks.memberships.mockResolvedValue({ data: [] }); + if (denied === 'canonical membership') mocks.memberships.mockResolvedValue({ + data: [{ userId: canonical, organizationId: ORG, status: 'active', role: { slug: 'owner' } }], + }); + if (denied === 'stale epoch') row.authorization_epoch = '8'; + if (denied === 'unavailable') mocks.boundedQuery.mockRejectedValue(new Error('database unavailable')); + for (const path of ['/mirror-proposals', `/mirror-proposals/${PROPOSAL_ID}`]) { + const pending = request(mirrorApp()).get(`/api/registry${path}`); + if (denied === 'conflict') pending.set('X-Organization-Id', OTHER_ORG); + const response = await pending; + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Community mirror proposal not found' }); + } + expect(mocks.listProposals).not.toHaveBeenCalled(); + expect(mocks.getProposal).not.toHaveBeenCalled(); + expect(mocks.isAdmin).not.toHaveBeenCalled(); + expect(mocks.isModerator).not.toHaveBeenCalled(); + }, + ); + + it('scopes authorized list/detail to the selected org and denies other-org or unscoped rows', async () => { + await authenticate(authenticated, canonical); + const instance = mirrorApp(); + expect((await request(instance).get('/api/registry/mirror-proposals')).status).toBe(200); + expect(mocks.listProposals).toHaveBeenCalledWith(expect.objectContaining({ proposedByOrganizationId: ORG })); + expect(mocks.listProposals.mock.calls[0][0].proposedByUserId).toBeUndefined(); + expect((await request(instance).get(`/api/registry/mirror-proposals/${PROPOSAL_ID}`)).status).toBe(200); + for (const organizationId of [OTHER_ORG, null]) { + mocks.getProposal.mockResolvedValue({ + id: PROPOSAL_ID, proposed_by_user_id: canonical, proposed_by_organization_id: organizationId, + }); + expect((await request(instance).get(`/api/registry/mirror-proposals/${PROPOSAL_ID}`)).status).toBe(404); + } + expect(mocks.memberships.mock.calls.every(([input]) => input.userId === authenticated)).toBe(true); + }); + + it('rejects list/detail replay after revocation before reading proposals', async () => { + await authenticate(authenticated, canonical); + const instance = mirrorApp(); + for (const path of ['/mirror-proposals', `/mirror-proposals/${PROPOSAL_ID}`]) { + expect((await request(instance).get(`/api/registry${path}`)).status).toBe(200); + } + row.authorization_epoch = '8'; + mocks.listProposals.mockClear(); + mocks.getProposal.mockClear(); + for (const path of ['/mirror-proposals', `/mirror-proposals/${PROPOSAL_ID}`]) { + expect((await request(instance).get(`/api/registry${path}`)).status).toBe(404); + } + expect(mocks.listProposals).not.toHaveBeenCalled(); + expect(mocks.getProposal).not.toHaveBeenCalled(); + }); + + it.each(['no selection', 'stale epoch', 'conflict'])('does not rescue %s with an attached API-key organization', async denied => { + await authenticate(authenticated, canonical, denied === 'no selection' ? null : ORG); + if (denied === 'stale epoch') row.authorization_epoch = '8'; + for (const path of ['/mirror-proposals', `/mirror-proposals/${PROPOSAL_ID}`]) { + const pending = request(mirrorApp(ORG)).get(`/api/registry${path}`); + if (denied === 'conflict') pending.set('X-Organization-Id', OTHER_ORG); + expect((await pending).status).toBe(404); + } + expect(mocks.listProposals).not.toHaveBeenCalled(); + expect(mocks.getProposal).not.toHaveBeenCalled(); + }); + + it('denies human manager fallback for queue, approve, reject, publish and retire', async () => { + await authenticate(authenticated, canonical, null); + const instance = mirrorApp(); + expect((await request(instance).get('/api/registry/mirror-proposals?review_queue=true')).status).toBe(403); + for (const action of ['approve', 'reject']) { + expect((await request(instance).post(`/api/registry/mirror-proposals/${PROPOSAL_ID}/${action}`).send({})).status).toBe(403); + } + expect((await request(instance).put('/api/registry/mirrors/example').send({})).status).toBe(403); + expect((await request(instance).delete('/api/registry/mirrors/example')).status).toBe(403); + expect(mocks.getClient).not.toHaveBeenCalled(); + expect(mocks.isAdmin).not.toHaveBeenCalled(); + expect(mocks.isModerator).not.toHaveBeenCalled(); + }); +}); + +describe('sealed-session organization boundary on POST /api/conformance/token', () => { + it.each([ + ['user_primary', 'user_linked'], + ['user_linked', 'user_primary'], + ])('uses the exact authenticated %s credential when person attribution is %s', async (authenticated, canonical) => { + await authenticate(authenticated, canonical); + const response = await post(); + expect(response.status).toBe(200); + expect(verifyConformanceToken(response.body.token).sub).toBe(ORG); + expect(mocks.memberships).toHaveBeenCalledExactlyOnceWith({ userId: authenticated, organizationId: ORG }); + expect(mocks.boundedQuery.mock.calls.every(([, parameters]) => parameters[0] === authenticated)).toBe(true); + }); + + it.each([ + ['user_primary', 'user_linked'], + ['user_linked', 'user_primary'], + ])('does not copy %s authority into authenticated %s', async (canonical, authenticated) => { + await authenticate(authenticated, canonical); + mocks.memberships.mockResolvedValue({ + data: [{ userId: canonical, organizationId: ORG, status: 'active', role: { slug: 'owner' } }], + }); + const response = await post(); + expect(response.status).toBe(403); + expect(response.body.token).toBeUndefined(); + expect(mocks.memberships).toHaveBeenCalledExactlyOnceWith({ userId: authenticated, organizationId: ORG }); + }); + + it('requires explicit organization context even when the credential has a sole membership', async () => { + await authenticate('user_primary', 'user_linked', null); + const response = await post(); + expect(response.status).toBe(403); + expect(response.body.token).toBeUndefined(); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('accepts an explicit request selection when authentication had no selected organization', async () => { + await authenticate('user_primary', 'user_linked', null); + const response = await post().send({ organization_id: ORG }); + expect(response.status).toBe(200); + expect(verifyConformanceToken(response.body.token).sub).toBe(ORG); + }); + + it.each(['absent', 'unavailable'])('preserves an exact credential grant when WorkOS membership is %s', async state => { + Object.assign(row, { + grant_id: 'grant_exact', grant_organization_id: ORG, grant_role: 'member', + grant_effective_from: '2026-01-01T00:00:00.000000Z', + }); + await authenticate('user_linked', 'user_primary'); + if (state === 'absent') mocks.memberships.mockResolvedValue({ data: [] }); + else mocks.memberships.mockRejectedValue(new Error('provider unavailable')); + const response = await post(); + expect(response.status).toBe(200); + expect(verifyConformanceToken(response.body.token).sub).toBe(ORG); + expect(mocks.boundedQuery.mock.calls.every(([, parameters]) => parameters[0] === 'user_linked')).toBe(true); + }); + + it.each(['id', 'authWorkosUserId'] as const)('rejects a snapshot transplanted to a different principal %s', async field => { + await authenticate(); + mocks.principal![field] = 'user_other'; + const response = await post(); + expect(response.status).toBe(403); + expect(response.body.token).toBeUndefined(); + expect(mocks.boundedQuery).not.toHaveBeenCalled(); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('checks path selectors populated after authentication middleware', async () => { + await authenticate(); + const instance = express(); + // Authentication commonly runs before Express has matched a tenant route. + instance.use((req, _res, next) => { req.user = mocks.principal; next(); }); + instance.get('/organizations/:orgId/check', async (req, res) => { + const organizationId = await resolveCallerOrgId(req); + res.status(organizationId ? 200 : 403).json({ organizationId }); + }); + expect((await request(instance).get(`/organizations/${ORG}/check`)).status).toBe(200); + mocks.memberships.mockClear(); + const conflict = await request(instance).get(`/organizations/${OTHER_ORG}/check`); + expect(conflict.status).toBe(403); + expect(conflict.body.organizationId).toBeNull(); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('rejects a canonical user object without authenticated snapshot provenance', async () => { + mocks.principal = { id: 'user_linked', email: 'sam@pinnacle.example' }; + const response = await post().send({ organization_id: ORG }); + expect(response.status).toBe(403); + expect(response.body.token).toBeUndefined(); + expect(mocks.memberships).not.toHaveBeenCalled(); + expect(mocks.boundedQuery).not.toHaveBeenCalled(); + }); + + it.each(['header', 'query', 'query organizationId', 'query org_id', 'body', 'org_id alias'])('rejects a conflicting %s selector before provider authorization', async location => { + await authenticate(); + const pending = post(); + if (location === 'header') pending.set('X-Organization-Id', OTHER_ORG); + if (location === 'query') pending.query({ organization_id: OTHER_ORG }); + if (location === 'query organizationId') pending.query({ organizationId: OTHER_ORG }); + if (location === 'query org_id') pending.query({ org_id: OTHER_ORG }); + if (location === 'body') pending.send({ organizationId: OTHER_ORG }); + if (location === 'org_id alias') pending.send({ org_id: OTHER_ORG }); + const response = await pending; + expect(response.status).toBe(403); + expect(response.body.token).toBeUndefined(); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('accepts matching selectors across header, query, and body', async () => { + await authenticate(); + const response = await post().set('X-Organization-Id', ORG).query({ org: ORG }).send({ organization_id: ORG }); + expect(response.status).toBe(200); + expect(verifyConformanceToken(response.body.token).sub).toBe(ORG); + }); + + it.each(['epoch', 'identity', 'credential grant'])('rejects replay of a request snapshot after its %s changes', async changed => { + if (changed === 'credential grant') { + Object.assign(row, { grant_id: 'grant_original', grant_organization_id: ORG, grant_role: 'member', grant_effective_from: '2026-01-01T00:00:00.000000Z' }); + } + await authenticate(); + expect((await post()).status).toBe(200); + if (changed === 'epoch') row.authorization_epoch = '8'; + if (changed === 'identity') row.canonical_user_id = 'user_rebound'; + if (changed === 'credential grant') row.grant_id = 'grant_replacement'; + mocks.memberships.mockClear(); + const response = await post(); + expect(response.status).toBe(403); + expect(response.body.token).toBeUndefined(); + expect(mocks.memberships).not.toHaveBeenCalled(); + }); + + it('rejects a concurrent epoch change while WorkOS membership is in flight', async () => { + await authenticate(); + mocks.memberships.mockImplementation(async ({ userId, organizationId }) => { + row.authorization_epoch = '8'; + return { data: [{ userId, organizationId, status: 'active', role: { slug: 'member' } }] }; + }); + const response = await post(); + expect(response.status).toBe(403); + expect(response.body.token).toBeUndefined(); + }); + + it.each(['database', 'provider'])('fails closed without issuing a token during a %s outage', async source => { + await authenticate(); + if (source === 'database') mocks.boundedQuery.mockRejectedValue(new Error('private database details')); + else mocks.memberships.mockRejectedValue(new Error('private provider details')); + const response = await post(); + expect(response.status).toBe(403); + expect(response.body.token).toBeUndefined(); + expect(response.text).not.toContain('private'); + }); +}); diff --git a/server/tests/unit/user-authorization-snapshot-timeout.test.ts b/server/tests/unit/user-authorization-snapshot-timeout.test.ts new file mode 100644 index 00000000000..aa295be55f0 --- /dev/null +++ b/server/tests/unit/user-authorization-snapshot-timeout.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { queryWithTimeout } from '../../src/db/client.js'; +import { + AuthorizationSnapshotUnavailableError, + loadAuthorizationSnapshot, +} from '../../src/db/user-authorization-snapshot-db.js'; + +vi.mock('../../src/db/client.js', async importOriginal => ({ + ...await importOriginal(), + queryWithTimeout: vi.fn(), +})); + +const boundedQuery = vi.mocked(queryWithTimeout); +const USER_ID = 'user_snapshot_timeout'; +const ORGANIZATION_ID = 'org_snapshot_timeout'; + +function queryResult(overrides: Record = {}) { + return { + rows: [{ + in_recovery: false, + authenticated_user_id: USER_ID, + canonical_user_id: USER_ID, + identity_id: 'fd3043f7-cb4f-43c7-9b81-22ac97576150', + authorization_epoch: '7', + email: 'sam@pinnacle.example', + email_verified: true, + first_name: 'Sam', + last_name: 'Adeyemi', + grant_id: null, + grant_organization_id: null, + grant_role: null, + grant_effective_from: null, + grant_effective_until: null, + ...overrides, + }], + rowCount: 1, + command: 'SELECT', + oid: 0, + fields: [], + }; +} + +describe('authorization snapshot query deadline and connection retry', () => { + beforeEach(() => { + boundedQuery.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries one transient connection failure with only the remaining absolute budget', async () => { + boundedQuery.mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + 700); + throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + }).mockResolvedValueOnce(queryResult()); + + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)).resolves.toMatchObject({ + authenticatedUserId: USER_ID, + selectedOrganizationId: ORGANIZATION_ID, + authorizationEpoch: '7', + }); + expect(boundedQuery).toHaveBeenCalledTimes(2); + const [first, second] = boundedQuery.mock.calls; + expect(first[2]).toBe(2_000); + expect(first[3]).toEqual({ retryTransientCheckout: false }); + expect(second).toEqual([first[0], first[1], 1_300, { retryTransientCheckout: false }]); + expect(first[1]).toEqual([USER_ID, ORGANIZATION_ID]); + }); + + it('stops after two connection failures and permits a clean request after recovery', async () => { + boundedQuery.mockRejectedValue(Object.assign(new Error('private connection details'), { code: 'EPIPE' })); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toThrow(new AuthorizationSnapshotUnavailableError()); + expect(boundedQuery).toHaveBeenCalledTimes(2); + + boundedQuery.mockResolvedValueOnce(queryResult()); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)).resolves.toMatchObject({ + authenticatedUserId: USER_ID, authorizationEpoch: '7', + }); + expect(boundedQuery).toHaveBeenCalledTimes(3); + }); + + it('does not start a retry after the first attempt exhausts the deadline', async () => { + boundedQuery.mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + 2_000); + throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + }); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }); + + it('rejects a result delivered after the absolute deadline without retrying', async () => { + boundedQuery.mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + 2_001); + return queryResult(); + }); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }); + + it.each([ + { code: '57014', message: 'canceling statement due to statement timeout' }, + { code: '55P03', message: 'canceling statement due to lock timeout' }, + { code: '42P01', message: 'relation does not exist' }, + ])('does not retry a non-connection database error ($code)', async ({ code, message }) => { + boundedQuery.mockRejectedValue(Object.assign(new Error(message), { code })); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toThrow(new AuthorizationSnapshotUnavailableError()); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }); + + it.each([ + { state: 'replica', row: { in_recovery: true } }, + { state: 'missing primary identity', row: { canonical_user_id: null } }, + ])('does not retry an unavailable $state snapshot', async ({ row }) => { + boundedQuery.mockResolvedValue(queryResult(row)); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }); +}); 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 7c1623375bc..9034f526f9c 100644 --- a/server/tests/unit/working-group-admin-global-auth.test.ts +++ b/server/tests/unit/working-group-admin-global-auth.test.ts @@ -56,6 +56,7 @@ vi.mock('../../src/db/org-filters.js', () => ({ vi.mock('../../src/db/client.js', async (importOriginal) => ({ ...(await importOriginal()), getPool: () => ({ query: mocks.poolQuery }), + queryWithTimeout: mocks.poolQuery, })); vi.mock('../../src/addie/mcp/admin-tools.js', async () => { @@ -108,7 +109,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], }, @@ -136,9 +137,17 @@ describe('working-group real global-admin boundary', () => { ); mocks.isMember.mockResolvedValue(true); mocks.poolQuery.mockImplementation((sql: string) => { - if (sql.includes('FROM users')) { + if (sql.includes('pg_catalog.pg_is_in_recovery()')) { return Promise.resolve({ - rows: [{ first_name: 'SSO', last_name: 'Admin' }], + rows: [{ + in_recovery: false, + authenticated_user_id: 'user_sso_admin', + canonical_user_id: 'user_sso_admin', + identity_id: 'identity_sso_admin', + authorization_epoch: '0', + email: 'sso-admin@example.test', email_verified: true, + first_name: 'SSO', last_name: 'Admin', grant_id: null, + }], rowCount: 1, }); } 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 () => {