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
67 changes: 65 additions & 2 deletions src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { GatewayEnv, AuthResult, Tier, RiskLevel } from './types.js';
import { extractBearerToken, validateBearerToken, buildWwwAuthenticate } from './auth.js';
import { OAUTH_PROVIDER_CONFIG } from './oauth-config.js';
import { resolveRoute, getToolRiskLevel, ROUTE_TABLE, type BackendRoute } from './route-table.js';
import { toBackendToolName, buildAggregatedCatalog, validateToolArguments } from './tool-registry.js';
import { type AuditArtifact, generateTraceId, summarizeInput, emitAudit, queueAuditEvent } from './audit.js';
Expand Down Expand Up @@ -977,6 +978,53 @@ export async function handleMcpRequest(
return jsonResponse({ error: 'Method not allowed', code: 'METHOD_NOT_ALLOWED' }, 405);
}

// #29 legacy-scope fallback. Extracts the bearer token from the incoming
// request and calls the OAuth provider's `unwrapToken` helper to read the
// denormalized top-level `grant.scope` off the stored token record. This
// rescues grants minted before the C-1a remediation (commit 256ba06,
// 2026-04-10), which wrote valid scopes at the grant top level but empty
// `props.scopes`. Returns an empty array on any failure — the caller
// treats that as "no scopes" and downstream scope enforcement will reject
// the tool call, which is the correct behavior for the unrescuable cohort
// (grants minted between C-1a and #32 that have empty scopes at every
// level). Any failure mode falls through to the same empty-scopes outcome
// the caller already handled before this fallback existed.
async function resolveLegacyGrantScopes(
request: Request,
env: GatewayEnv,
): Promise<string[]> {
const bearer = extractBearerToken(request);
if (!bearer) return [];
try {
// Dynamic import: the @cloudflare/workers-oauth-provider library
// transitively imports `cloudflare:*` protocol modules that the node
// ESM loader used by vitest unit tests cannot resolve. Eagerly
// importing `getOAuthApi` at the top of gateway.ts breaks every
// unit test that transitively imports gateway.ts, even tests that
// never exercise the fallback. Lazy-loading here confines the
// workerd-specific dependency to runtime on the fallback path,
// which under vitest is mocked via vi.mock.
const { getOAuthApi } = await import('@cloudflare/workers-oauth-provider');
const helpers = getOAuthApi(OAUTH_PROVIDER_CONFIG, env);
const token = await helpers.unwrapToken(bearer);
if (!token) return [];
const grantScope = token.grant?.scope ?? [];
if (grantScope.length > 0) {
// Rescue hit — log so we can measure the legacy cohort shrinking
// over time and know when to retire the fallback + backfill script.
console.warn(
`[gateway] legacy-grant scope fallback rescued grant=${token.grantId} scopes=${grantScope.join(',')}`,
);
}
return grantScope;
} catch (err) {
console.error(
`[gateway] resolveLegacyGrantScopes failed: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
}
}

// ─── Resolve auth: OAuth props (from OAuthProvider) or Bearer token (API keys/JWTs) ──
async function resolveAuth(
request: Request,
Expand All @@ -985,6 +1033,20 @@ async function resolveAuth(
): Promise<AuthResult> {
// If OAuthProvider already validated the token, use its props
if (oauthProps?.userId) {
// #29 legacy-scope fallback: grants minted before the C-1a remediation
// (256ba06, 2026-04-10) carry empty `props.scopes` because the old
// hardcoded path wrote `['generate','read']` at the grant top level but
// never populated props.scopes. Post-#32 grants populate both. Any
// authenticated caller hitting this path with empty props.scopes is
// either a legacy-cohort user or a client that minted a grant between
// C-1a and #32. We try to rescue the first cohort by reading the
// denormalized `grant.scope` off the token record; the second cohort
// has nothing to fall back to and must reauth.
let effectiveScopes = oauthProps.scopes ?? [];
if (effectiveScopes.length === 0) {
effectiveScopes = await resolveLegacyGrantScopes(request, env);
}

// Resolve tenant info from AUTH_SERVICE for proper tier
try {
const tenant = await env.AUTH_SERVICE.provisionTenant({
Expand All @@ -1000,8 +1062,9 @@ async function resolveAuth(
// was issued with. Previously hardcoded to ['generate', 'read'],
// which silently granted full access to any OAuth-authed caller
// regardless of what their token claimed. Now respects the scopes
// passed via completeAuthorization() props.
scopes: oauthProps.scopes ?? [],
// passed via completeAuthorization() props, with a #29 fallback to
// the top-level grant.scope for legacy grants minted pre-C-1a.
scopes: effectiveScopes,
};
} catch (err) {
// Tenant resolution failed — cannot proceed without a tenantId.
Expand Down
14 changes: 5 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import OAuthProvider from '@cloudflare/workers-oauth-provider';
import type { GatewayEnv } from './types.js';
import { handleMcpRequest } from './gateway.js';
import oauthHandler from './oauth-handler.js';
import { OAUTH_PROVIDER_CONFIG } from './oauth-config.js';

const CORS_HEADERS: Record<string, string> = {
'Access-Control-Allow-Origin': '*',
Expand All @@ -26,7 +26,10 @@ function addCorsHeaders(response: Response): Response {
}

const oauthProvider = new OAuthProvider<GatewayEnv>({
apiRoute: '/mcp',
...OAUTH_PROVIDER_CONFIG,
// Override the config's stub apiHandler with the real one that
// routes authenticated requests into handleMcpRequest. See
// src/oauth-config.ts for why the base config carries a stub.
apiHandler: {
fetch: async (request: Request, env: GatewayEnv, ctx: ExecutionContext) => {
// OAuthProvider validates the token and sets ctx.props with the
Expand All @@ -39,13 +42,6 @@ const oauthProvider = new OAuthProvider<GatewayEnv>({
return addCorsHeaders(response);
},
},
defaultHandler: oauthHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
scopesSupported: ['generate', 'read'],
accessTokenTTL: 3600,
refreshTokenTTL: 2592000,
});

export default {
Expand Down
26 changes: 26 additions & 0 deletions src/oauth-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// ─── Shared OAuthProvider configuration ──────────────────────
// Extracted into its own module so gateway.ts can call getOAuthApi()
// for the #29 legacy-scope fallback in resolveAuth without creating a
// circular import with index.ts (which imports handleMcpRequest from
// gateway.ts). The real apiHandler.fetch is attached in index.ts at
// provider-instantiation time — the stub below exists only to satisfy
// the OAuthProviderOptions shape for read-side helpers calls like
// unwrapToken, which never traverse apiHandler.

import type { OAuthProviderOptions } from '@cloudflare/workers-oauth-provider';
import type { GatewayEnv } from './types.js';
import oauthHandler from './oauth-handler.js';

export const OAUTH_PROVIDER_CONFIG: OAuthProviderOptions<GatewayEnv> = {
apiRoute: '/mcp',
apiHandler: {
fetch: async () => new Response('api handler not attached', { status: 500 }),
},
defaultHandler: oauthHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
scopesSupported: ['generate', 'read'],
accessTokenTTL: 3600,
refreshTokenTTL: 2592000,
};
Loading
Loading