Skip to content
Draft
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
2 changes: 1 addition & 1 deletion server/src/auth/organization-principal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { WorkOSUser } from "../types.js";

export type OrgAuthorizationPrincipal = Pick<
WorkOSUser,
"id" | "authWorkosUserId"
"id" | "authWorkosUserId" | "authorizationSnapshot"
>;

/**
Expand Down
24 changes: 24 additions & 0 deletions server/src/auth/organization-selection.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<Request, 'headers' | 'query' | 'body' | 'params'>>,
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;
}
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;
}
67 changes: 64 additions & 3 deletions server/src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,65 @@ export function withDatabaseDeadline<T>(
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<PoolClient> {
const remainingMs = deadlineMs - Date.now();
if (remainingMs <= 0) throw databaseDeadlineExceededError();

const checkout = getPool().connect();
let timeout: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
const deadline = new Promise<never>((_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<PoolClient> {
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
Expand All @@ -159,18 +216,22 @@ export async function queryWithTimeout<T extends QueryResultRow = any>(
text: string,
params: any[] | undefined,
timeoutMs: number,
options: { retryTransientCheckout?: boolean } = {},
): Promise<QueryResult<T>> {
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;
Expand Down
Loading
Loading