Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions server/public/dashboard-api-keys.html
Original file line number Diff line number Diff line change
Expand Up @@ -566,16 +566,23 @@ <h3>${escapeHtml(key.name)}</h3>
}

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. <a href="/dashboard">Go to dashboard</a>';
DashboardNav.init({ currentOrgName: 'Select organization' });
document.getElementById('loading').textContent = 'The selected organization is unavailable. Choose an organization from the dashboard.';
return;
}

Expand Down
47 changes: 44 additions & 3 deletions server/src/auth/workos-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,15 +127,21 @@ export function looksLikeJWT(token: string): boolean {
export async function verifyWorkOSJWT(token: string): Promise<VerifiedWorkOSToken> {
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',
);
}

Expand Down
12 changes: 9 additions & 3 deletions server/src/conformance/token-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
217 changes: 217 additions & 0 deletions server/src/db/api-key-management-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import type { PoolClient, QueryConfig, QueryResultRow } from 'pg';
import { getPool, queryWithTimeout } from './client.js';
import {
AuthorizationSnapshotUnavailableError,
sameAuthorizationIdentity,
type AuthorizationSnapshot,
} from './user-authorization-snapshot-db.js';

const AUTHORIZATION_TIMEOUT_MS = 2_000;
const FENCE_TIMEOUT_MS = 15_000;

// pg supports per-query read deadlines; @types/pg declares this option only on
// ClientConfig. Preserve the runtime option without weakening query result types.
type BoundedQueryConfig = QueryConfig & { query_timeout: number };

// Secret management never reads grants, including during authentication. Keep
// this projection separate from the grant-aware snapshot used by other routes.
const SNAPSHOT_SQL = `
SELECT pg_catalog.pg_is_in_recovery() AS in_recovery,
credential.workos_user_id AS authenticated_user_id,
primary_binding.workos_user_id AS canonical_user_id, binding.identity_id,
COALESCE(primary_binding.primary_count, 0)::text AS primary_count,
EXISTS (
SELECT 1 FROM registry_audit_log audit
WHERE audit.workos_user_id = $1
AND audit.action IN (
'identity_credential_deleted', 'identity_primary_deletion_quarantined'
)
) AS terminal_marker,
COALESCE(epoch.epoch, 0)::text AS authorization_epoch,
credential.email, credential.email_verified, credential.first_name, credential.last_name
FROM (VALUES (1)) AS anchor(value)
LEFT JOIN users credential ON credential.workos_user_id = $1
LEFT JOIN identity_workos_users binding ON binding.workos_user_id = credential.workos_user_id
LEFT JOIN LATERAL (
SELECT COUNT(*) AS primary_count, MIN(candidate.workos_user_id) AS workos_user_id
FROM identity_workos_users candidate
WHERE candidate.identity_id = binding.identity_id AND candidate.is_primary
) primary_binding ON TRUE
LEFT JOIN authorization_epochs epoch ON epoch.workos_user_id = credential.workos_user_id`;

interface SnapshotRow {
in_recovery: boolean;
terminal_marker: boolean;
primary_count: string;
authenticated_user_id: string | null;
canonical_user_id: string | null;
identity_id: string | null;
authorization_epoch: string;
email: string | null;
email_verified: boolean;
first_name: string | null;
last_name: string | null;
}

function snapshotFromRow(row: SnapshotRow | undefined, organizationId: string | null): AuthorizationSnapshot | null {
if (!row || row.in_recovery) throw new AuthorizationSnapshotUnavailableError();
// Grant-free management must retain the primary snapshot's terminal
// lifecycle boundary, including stale provider recreation and bad routing.
if (row.terminal_marker || !row.authenticated_user_id || !row.identity_id
|| row.primary_count !== '1' || !row.canonical_user_id) return null;
return Object.freeze({
authenticatedUserId: row.authenticated_user_id,
canonicalUserId: row.canonical_user_id,
identityId: row.identity_id,
selectedOrganizationId: organizationId,
authorizationEpoch: row.authorization_epoch,
credential: Object.freeze({
email: row.email, emailVerified: row.email_verified,
firstName: row.first_name, lastName: row.last_name,
}),
credentialGrant: null,
});
}

export async function loadApiKeyManagementSnapshot(
authenticatedUserId: string,
organizationId: string | null,
): Promise<AuthorizationSnapshot | null> {
try {
const result = await queryWithTimeout<SnapshotRow>(SNAPSHOT_SQL, [authenticatedUserId], AUTHORIZATION_TIMEOUT_MS, {
retryTransientCheckout: false,
});
return snapshotFromRow(result.rows[0], organizationId);
} catch {
throw new AuthorizationSnapshotUnavailableError();
}
}

export class ApiKeyManagementStateChangedError extends Error {}

function assertSameState(previous: AuthorizationSnapshot, current: AuthorizationSnapshot | null): void {
if (!current || !sameAuthorizationIdentity(previous, current)
|| previous.selectedOrganizationId !== current.selectedOrganizationId) {
throw new ApiKeyManagementStateChangedError();
}
}

async function checkoutBefore(deadline: number): Promise<PoolClient> {
const checkout = getPool().connect();
let timer: ReturnType<typeof setTimeout> | undefined;
let expired = false;
try {
return await Promise.race([
checkout.then((client) => {
if (expired) client.release();
return client;
}),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
expired = true;
reject(new AuthorizationSnapshotUnavailableError());
}, Math.max(1, deadline - Date.now()));
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}

/**
* Serialize with real identity/epoch row writers, not a route-local mutex.
* No rows are inserted or updated, and the callback is never retried. A users
* FOR UPDATE lock also blocks a first epoch INSERT through its foreign key.
*
* WorkOS membership changes do not participate in this database transaction.
* The caller must recheck direct membership inside it immediately before the
* effect; this fence does not claim atomicity with out-of-band WorkOS changes.
*/
export async function withApiKeyManagementFence<T>(
previous: AuthorizationSnapshot,
work: (assertCurrent: () => Promise<void>, assertLive: () => void) => Promise<T>,
): Promise<T> {
let client: PoolClient;
const deadline = Date.now() + FENCE_TIMEOUT_MS;
try {
client = await checkoutBefore(Date.now() + AUTHORIZATION_TIMEOUT_MS);
} catch {
throw new AuthorizationSnapshotUnavailableError();
}
let destroyed = false;
const onConnectionError = () => { destroyed = true; };
client.on('error', onConnectionError);
const query = async <T extends QueryResultRow = QueryResultRow>(text: string, values: unknown[] = []) => {
const remaining = deadline - Date.now();
if (destroyed || remaining <= 0) throw new AuthorizationSnapshotUnavailableError();
try {
// Driver timeout also bounds BEGIN/configuration and network loss. A timed
// out query is never returned to the pool with an uncertain transaction.
const config: BoundedQueryConfig = { text, values, query_timeout: Math.min(AUTHORIZATION_TIMEOUT_MS, remaining) };
return await client.query<T>(config);
} catch (error) {
destroyed = true;
throw error;
}
};
try {
try {
await query('BEGIN');
await query("SELECT set_config('statement_timeout', '2000ms', true), set_config('lock_timeout', '2000ms', true)");
await query("SELECT set_config('idle_in_transaction_session_timeout', '15000ms', true)");
const credential = await query(
'SELECT workos_user_id FROM users WHERE workos_user_id = $1 FOR UPDATE',
[previous.authenticatedUserId],
);
if (credential.rows.length === 0) throw new ApiKeyManagementStateChangedError();
// Lock the identity against new primary bindings and its current primary
// against demotion/deletion. Attribution is a replay guard, never authority.
// Take the shared identity lock before either binding so simultaneous
// requests through primary and linked credentials cannot lock each other.
await query('SELECT id FROM identities WHERE id = $1 FOR UPDATE', [previous.identityId]);
await query(
'SELECT workos_user_id FROM identity_workos_users WHERE workos_user_id = $1 FOR UPDATE',
[previous.authenticatedUserId],
);
await query(
'SELECT workos_user_id FROM identity_workos_users WHERE identity_id = $1 AND is_primary FOR UPDATE',
[previous.identityId],
);
await query(
'SELECT epoch FROM authorization_epochs WHERE workos_user_id = $1 FOR UPDATE',
[previous.authenticatedUserId],
);
} catch (error) {
if (error instanceof ApiKeyManagementStateChangedError) throw error;
throw new AuthorizationSnapshotUnavailableError();
}
const assertLive = () => {
if (destroyed || Date.now() >= deadline) throw new AuthorizationSnapshotUnavailableError();
};
const assertCurrent = async () => {
let current: AuthorizationSnapshot | null;
try {
if (Date.now() + 5_000 >= deadline) throw new AuthorizationSnapshotUnavailableError();
const result = await query<SnapshotRow>(SNAPSHOT_SQL, [previous.authenticatedUserId]);
if (destroyed || Date.now() + 5_000 >= deadline) throw new AuthorizationSnapshotUnavailableError();
current = snapshotFromRow(result.rows[0], previous.selectedOrganizationId);
} catch {
throw new AuthorizationSnapshotUnavailableError();
}
assertSameState(previous, current);
};
await assertCurrent();
return await work(assertCurrent, assertLive);
} finally {
// The transaction contains locks only. Cleanup failure cannot turn a known
// provider success into a claimed failed mutation, nor cause a second call.
try {
const cleanup: BoundedQueryConfig = { text: 'ROLLBACK', query_timeout: 500 };
if (!destroyed) await client.query(cleanup);
} catch {
destroyed = true;
}
client.removeListener('error', onConnectionError);
client.release(destroyed);
}
}
20 changes: 20 additions & 0 deletions server/src/middleware/api-key-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading