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
56 changes: 56 additions & 0 deletions apps/api/src/openapi-docs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,60 @@ describe('OpenAPI document', () => {
);
});
});

describe('MCP OAuth security', () => {
it('declares an oauth2 authorization-code scheme pointed at the Comp AI auth server', () => {
const scheme = document.components?.securitySchemes?.oauth2 as
| {
type?: string;
flows?: {
authorizationCode?: {
authorizationUrl?: string;
tokenUrl?: string;
scopes?: Record<string, string>;
};
};
}
| undefined;

expect(scheme?.type).toBe('oauth2');
expect(scheme?.flows?.authorizationCode?.authorizationUrl).toBe(
`${PUBLIC_SERVER_URL}/api/auth/mcp/authorize`,
);
expect(scheme?.flows?.authorizationCode?.tokenUrl).toBe(
`${PUBLIC_SERVER_URL}/api/auth/mcp/token`,
);
});

it('offers oauth2 alongside the API key on every authenticated operation', () => {
const operations = Object.values(document.paths).flatMap((methods) =>
Object.values(methods as Record<string, { security?: unknown }>),
);

const hasReq = (security: unknown, scheme: string): boolean =>
Array.isArray(security) &&
security.some((req) => req && typeof req === 'object' && scheme in req);

const apiKeyOps = operations.filter((op) =>
hasReq(op?.security, 'apikey'),
);

// Sanity: the spec really does gate operations behind the API key.
expect(apiKeyOps.length).toBeGreaterThan(0);

// Every API-key operation must also accept oauth2 (OR semantics) so MCP
// callers authenticate per-user instead of via a shared key.
const missingOAuth = apiKeyOps.filter(
(op) => !hasReq(op?.security, 'oauth2'),
);
expect(missingOAuth).toHaveLength(0);

// And oauth2 is never offered on an endpoint that isn't API-key gated.
const oauthWithoutApiKey = operations.filter(
(op) =>
hasReq(op?.security, 'oauth2') && !hasReq(op?.security, 'apikey'),
);
expect(oauthWithoutApiKey).toHaveLength(0);
});
});
});
73 changes: 73 additions & 0 deletions apps/api/src/openapi/public-docs-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ export const PUBLIC_OPENAPI_DESCRIPTION =

export const PUBLIC_SERVER_URL = 'https://api.trycomp.ai';

/**
* Name of the OAuth2 security scheme advertised in the public spec. MCP hosts
* (e.g. Speakeasy Gram) only surface "Sign in with Comp AI" + forward the
* caller's bearer token to the API when the spec declares an oauth2 scheme;
* with only the API key, every MCP user would hit the API as one shared
* identity, bypassing per-user RBAC.
*/
export const MCP_OAUTH_SECURITY_SCHEME = 'oauth2';

function getVisibilityForOperation(
operation: OpenApiOperation,
metadata?: PublicOperationMetadata,
Expand Down Expand Up @@ -274,6 +283,67 @@ function applyMcpToolNames(
}
}

/**
* Declare the OAuth2 (authorization code) security scheme and offer it on every
* operation that already accepts the API key. The scheme points at the
* better-auth MCP authorization server; the per-operation `security` entries use
* OR semantics, so an API key OR a Comp AI OAuth token satisfies the request.
* This is what lets MCP hosts forward each user's bearer token to the API so the
* existing per-user/per-org RBAC applies, rather than a single shared key.
*/
function applyMcpOAuthSecurity(document: OpenAPIObject): void {
document.components ??= {};
document.components.securitySchemes ??= {};
document.components.securitySchemes[MCP_OAUTH_SECURITY_SCHEME] = {
type: 'oauth2',
description:
'OAuth 2.1 authorization code flow. Sign in with your Comp AI account — tokens are issued by the Comp AI authorization server and scoped to your organization, role, and permissions.',
flows: {
authorizationCode: {
authorizationUrl: `${PUBLIC_SERVER_URL}/api/auth/mcp/authorize`,
tokenUrl: `${PUBLIC_SERVER_URL}/api/auth/mcp/token`,
refreshUrl: `${PUBLIC_SERVER_URL}/api/auth/mcp/token`,
scopes: {
openid: 'OpenID Connect authentication',
profile: 'Basic profile information',
email: 'Email address',
offline_access: 'Maintain access via refresh tokens',
},
},
},
};

for (const methods of Object.values(document.paths)) {
for (const operation of Object.values(
methods as Record<string, OpenApiOperation>,
)) {
if (!operation || typeof operation !== 'object') {
continue;
}

const security = operation.security;
if (!Array.isArray(security)) {
continue;
}

const requirements = security as Array<Record<string, string[]>>;
const hasApiKey = requirements.some(
(req) => req && typeof req === 'object' && 'apikey' in req,
);
const hasOAuth = requirements.some(
(req) =>
req && typeof req === 'object' && MCP_OAUTH_SECURITY_SCHEME in req,
);

// Mirror OAuth onto API-key operations only — endpoints that are
// intentionally public (empty security) must stay unauthenticated.
if (hasApiKey && !hasOAuth) {
requirements.push({ [MCP_OAUTH_SECURITY_SCHEME]: [] });
}
}
}
}

export function applyPublicOpenApiMetadata(document: OpenAPIObject): void {
document.info.title = PUBLIC_OPENAPI_TITLE;
document.info.description = PUBLIC_OPENAPI_DESCRIPTION;
Expand Down Expand Up @@ -328,4 +398,7 @@ export function applyPublicOpenApiMetadata(document: OpenAPIObject): void {
addTagMetadata(document);
removeUnusedSchemas(document);
sanitizePublicSchemas(document);

// Add OAuth last so its security scheme isn't touched by schema pruning.
applyMcpOAuthSecurity(document);
}
Loading
Loading