From 27bfd178458b214ae72b5f81e72fd832ab0efd55 Mon Sep 17 00:00:00 2001 From: rafalzawadzki Date: Tue, 24 Mar 2026 16:57:16 +0100 Subject: [PATCH 1/5] Add OAuth 2.1 support for MCP server Wraps the Cloudflare Worker with @cloudflare/workers-oauth-provider to enable OAuth 2.1 authentication (DCR, PKCE, token management) alongside the existing API key auth. Users connecting via Claude Desktop or Claude.ai are redirected to the Supadata dashboard for login, which returns a signed JWT containing the user's API key. Legacy API key headers (x-api-key, x-api-token, supadata-api-key) continue to work unchanged. - Add auth-handler.ts (Hono router for /authorize + /callback) - Wrap worker.ts with OAuthProvider, serve PRM metadata - Add KV namespace binding for OAuth token storage - Add routes for OAuth endpoints (.well-known/*, /authorize, /token, etc.) - Add @cloudflare/workers-oauth-provider, hono, jose dependencies Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 21 +++++- package.json | 6 +- src/auth-handler.ts | 119 ++++++++++++++++++++++++++++++ src/worker.ts | 173 ++++++++++++++++++++++++++++---------------- tsconfig.json | 3 +- wrangler.toml | 15 +++- 6 files changed, 268 insertions(+), 69 deletions(-) create mode 100644 src/auth-handler.ts diff --git a/package-lock.json b/package-lock.json index 21fb6b9..4d2e125 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,27 @@ { "name": "@supadata/mcp", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@supadata/mcp", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.1.0", "@modelcontextprotocol/sdk": "^1.25.3", "@supadata/js": "^1.4.0", "dotenv": "^16.4.7", + "hono": "^4.7.0", + "jose": "^6.0.0", "zod": "^3.25.76" }, "bin": { "mcp": "dist/index.js" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", "@jest/globals": "^29.7.0", "@types/express": "^5.0.1", "@types/jest": "^29.5.14", @@ -568,6 +572,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@cloudflare/workers-oauth-provider": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-0.1.0.tgz", + "integrity": "sha512-wok0RqyZlxkaKJCmGGXKyWkN0znRs9SFfTOJ1/TJxnv6N/lfXbCYr5hCbsgmOdvVi8JsKKYWj/qkqOLZybgAyQ==", + "license": "MIT" + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260317.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260317.1.tgz", + "integrity": "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", diff --git a/package.json b/package.json index 39f3c68..b29ba6b 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,16 @@ }, "license": "MIT", "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.1.0", "@modelcontextprotocol/sdk": "^1.25.3", "@supadata/js": "^1.4.0", "dotenv": "^16.4.7", + "hono": "^4.7.0", + "jose": "^6.0.0", "zod": "^3.25.76" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", "@jest/globals": "^29.7.0", "@types/express": "^5.0.1", "@types/jest": "^29.5.14", @@ -58,4 +62,4 @@ "url": "https://github.com/supadata-ai/mcp/issues" }, "homepage": "https://github.com/supadata-ai/mcp#readme" -} \ No newline at end of file +} diff --git a/src/auth-handler.ts b/src/auth-handler.ts new file mode 100644 index 0000000..06cedf8 --- /dev/null +++ b/src/auth-handler.ts @@ -0,0 +1,119 @@ +import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider'; +import { Hono } from 'hono'; +import { jwtVerify } from 'jose'; + +type Env = { + OAUTH_PROVIDER: OAuthHelpers; + OAUTH_KV: KVNamespace; + DASHBOARD_URL: string; + MCP_JWT_SECRET: string; + COOKIE_ENCRYPTION_KEY: string; +}; + +const app = new Hono<{ Bindings: Env }>(); + +// Store OAuth request info in KV, keyed by a random state token +async function createState( + oauthReqInfo: AuthRequest, + kv: KVNamespace +): Promise { + const stateToken = crypto.randomUUID(); + await kv.put( + `oauth_state:${stateToken}`, + JSON.stringify(oauthReqInfo), + { expirationTtl: 600 } // 10 minutes + ); + return stateToken; +} + +// GET /authorize — redirect to dashboard for login +app.get('/authorize', async (c) => { + const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); + if (!oauthReqInfo.clientId) { + return c.text('Invalid request: missing client_id', 400); + } + + const stateToken = await createState(oauthReqInfo, c.env.OAUTH_KV); + + const callbackUrl = new URL('/callback', c.req.url).href; + const dashboardUrl = new URL('/oauth/mcp', c.env.DASHBOARD_URL); + dashboardUrl.searchParams.set('state', stateToken); + dashboardUrl.searchParams.set('callback', callbackUrl); + + return c.redirect(dashboardUrl.toString()); +}); + +// GET /callback — validate JWT from dashboard and complete authorization +app.get('/callback', async (c) => { + const token = c.req.query('token'); + const stateToken = c.req.query('state'); + const error = c.req.query('error'); + + // Handle deny + if (error) { + return c.text(`Authorization denied: ${error}`, 403); + } + + if (!token || !stateToken) { + return c.text('Missing token or state parameter', 400); + } + + // Retrieve and validate the OAuth request info from KV + const storedState = await c.env.OAUTH_KV.get(`oauth_state:${stateToken}`); + if (!storedState) { + return c.text('Invalid or expired state', 400); + } + + // Delete state to prevent replay + await c.env.OAUTH_KV.delete(`oauth_state:${stateToken}`); + + let oauthReqInfo: AuthRequest; + try { + oauthReqInfo = JSON.parse(storedState); + } catch { + return c.text('Corrupted state data', 400); + } + + // Validate the JWT from the dashboard + let payload; + try { + const secret = new TextEncoder().encode(c.env.MCP_JWT_SECRET); + const result = await jwtVerify(token, secret); + payload = result.payload; + } catch (err: any) { + console.error('JWT verification failed:', err.message); + return c.text('Invalid or expired token', 401); + } + + const userId = payload.sub; + const apiKey = payload.api_key as string; + const name = payload.name as string; + const email = payload.email as string; + + if (!userId || !apiKey) { + return c.text('Invalid token claims', 400); + } + + // Complete the OAuth authorization — the library issues its own tokens + const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ + request: oauthReqInfo, + userId, + scope: oauthReqInfo.scope || ['mcp'], + metadata: { + label: name || email || userId + }, + props: { + apiKey, + userId, + name, + email + } + }); + + return c.redirect(redirectTo); +}); + +// Wrap Hono app as an ExportedHandler for OAuthProvider compatibility +export const authHandler = { + fetch: (request: Request, env: any, ctx: any) => app.fetch(request, env, ctx), +}; diff --git a/src/worker.ts b/src/worker.ts index 1f8c3d6..e886a06 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,91 +1,136 @@ -import { createMcpServer } from './mcp.js'; +import OAuthProvider from '@cloudflare/workers-oauth-provider'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -export default { - async fetch(request: Request, env: { SUPADATA_API_KEY: string }, _ctx: any): Promise { - // Health check — no server/transport needed - const url = new URL(request.url); - if (url.pathname === '/' && request.method === 'GET') { - return new Response('Supadata MCP Worker is running.', { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }); - } +import { createMcpServer } from './mcp.js'; +import { authHandler } from './auth-handler.js'; - // In stateless mode, only POST is supported for JSON-RPC requests. - // GET (SSE streams) and DELETE (session termination) don't work - // because server.close() in the finally block would terminate them. - if (request.method !== 'POST') { - return new Response( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Method not allowed. Use POST for JSON-RPC requests.', - }, - id: null, - }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, +// Handle MCP requests with a direct API key (legacy / backward-compatible) +async function handleMcpWithApiKey(request: Request, apiKey: string): Promise { + if (request.method !== 'POST') { + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed. Use POST for JSON-RPC requests.', }, - ); - } + id: null, + }), + { + status: 405, + headers: { 'Content-Type': 'application/json', Allow: 'POST' }, + }, + ); + } - let server: Server | null = null; + let server: Server | null = null; - try { - let apiKey = request.headers.get('x-api-key'); + try { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); - if (!apiKey) { - apiKey = request.headers.get('x-api-token'); - } + const result = createMcpServer({ supadataApiKey: apiKey }); + server = result.server; - if (!apiKey) { - apiKey = request.headers.get('supadata-api-key'); - } + await server.connect(transport); - if (!apiKey) { - apiKey = env.SUPADATA_API_KEY; - } + const response = await transport.handleRequest(request); + return response ?? new Response('Not Found', { status: 404 }); + } catch (err: any) { + console.error('Worker Error:', err); + return new Response( + JSON.stringify({ error: `Server Internal Error: ${err?.message}` }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ); + } finally { + if (server) { + await server.close(); + } + } +} - if (!apiKey) { - console.warn('No API key provided via headers (x-api-key) or environment (SUPADATA_API_KEY).'); - } else { - console.log(`Received API Key (length: ${apiKey.length})`); - } +// OAuth-authenticated MCP handler +// The OAuthProvider injects authenticated `props` (including apiKey) via the execution context +const mcpHandler = { + async fetch(request: Request, env: any, ctx: any): Promise { + const props = ctx?.props || {}; + const apiKey = props.apiKey as string; - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }); + if (!apiKey) { + return new Response( + JSON.stringify({ error: 'No API key in OAuth context' }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return handleMcpWithApiKey(request, apiKey); + }, +}; - const result = createMcpServer({ - supadataApiKey: apiKey || '', +export default { + async fetch(request: Request, env: any, ctx: any): Promise { + const url = new URL(request.url); + + // Health check + if (url.pathname === '/' && request.method === 'GET') { + return new Response('Supadata MCP Worker is running.', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, }); - server = result.server; + } - await server.connect(transport); + // Legacy API key support: if x-api-key header is present, bypass OAuth entirely + const apiKey = request.headers.get('x-api-key') + || request.headers.get('x-api-token') + || request.headers.get('supadata-api-key'); - const response = await transport.handleRequest(request); - return response ?? new Response('Not Found', { status: 404 }); + if (apiKey && url.pathname === '/mcp') { + console.log(`Received API Key via header (length: ${apiKey.length})`); + return handleMcpWithApiKey(request, apiKey); + } - } catch (err: any) { - console.error('Worker Error:', err); + // Also support SUPADATA_API_KEY env var for direct POST to /mcp (backward compat) + if (!apiKey && env.SUPADATA_API_KEY && url.pathname === '/mcp' && request.method === 'POST') { + console.log(`Using env SUPADATA_API_KEY (length: ${env.SUPADATA_API_KEY.length})`); + return handleMcpWithApiKey(request, env.SUPADATA_API_KEY); + } + // Serve OAuth Protected Resource Metadata (RFC 9728) + if (url.pathname === '/.well-known/oauth-protected-resource') { return new Response( JSON.stringify({ - error: `Server Internal Error: ${err?.message}`, + resource: 'https://api.supadata.ai', + authorization_servers: ['https://api.supadata.ai'], + scopes_supported: ['mcp'], + bearer_methods_supported: ['header'], }), - { status: 500, headers: { 'Content-Type': 'application/json' } }, + { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }, ); - } finally { - if (server) { - await server.close(); - } } + + // OAuth flow for everything else + const provider = new OAuthProvider({ + apiRoute: '/mcp', + apiHandler: mcpHandler, + defaultHandler: authHandler, + authorizeEndpoint: '/authorize', + tokenEndpoint: '/token', + clientRegistrationEndpoint: '/register', + scopesSupported: ['mcp'], + accessTokenTTL: 3600, // 1 hour + refreshTokenTTL: 2592000, // 30 days + }); + + return provider.fetch(request, env, ctx); }, }; diff --git a/tsconfig.json b/tsconfig.json index 18ef969..7c97e21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "types": ["@cloudflare/workers-types"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"] diff --git a/wrangler.toml b/wrangler.toml index dfdd60a..4cdefc1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,7 +1,20 @@ name = "supadata-mcp" main = "src/worker.ts" compatibility_date = "2024-12-01" -routes = [{ pattern = "api.supadata.ai/mcp", zone_name = "supadata.ai" }] +routes = [ + { pattern = "api.supadata.ai/mcp", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/.well-known/oauth-authorization-server", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/.well-known/oauth-protected-resource", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/authorize", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/token", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/register", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/callback", zone_name = "supadata.ai" }, +] [observability] enabled = true + +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "7fdecd45b4e74de2aeaf8d53cb8309af" +preview_id = "c37fddf9dc494ab99956edf0f9296cfd" From 7ecab7882ef01409eff703e8aff4e82c60433168 Mon Sep 17 00:00:00 2001 From: rafalzawadzki Date: Tue, 24 Mar 2026 21:52:32 +0100 Subject: [PATCH 2/5] Add resource_metadata to WWW-Authenticate header on 401 MCP clients (Claude Desktop, Claude.ai) need the resource_metadata URL in the WWW-Authenticate header to discover the OAuth server (RFC 9728). The library's default 401 response doesn't include it, so we use the onError callback to add it. Co-Authored-By: Claude Opus 4.6 --- src/worker.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/worker.ts b/src/worker.ts index e886a06..03493fd 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -129,6 +129,18 @@ export default { scopesSupported: ['mcp'], accessTokenTTL: 3600, // 1 hour refreshTokenTTL: 2592000, // 30 days + onError({ code, description, status, headers }) { + // Add resource_metadata to WWW-Authenticate header on 401 (RFC 9728) + if (status === 401) { + const newHeaders = { ...headers }; + newHeaders['WWW-Authenticate'] = + `Bearer resource_metadata="https://api.supadata.ai/.well-known/oauth-protected-resource"`; + return new Response(JSON.stringify({ error: code, error_description: description }), { + status, + headers: { ...newHeaders, 'Content-Type': 'application/json' }, + }); + } + }, }); return provider.fetch(request, env, ctx); From 535d93900456fd3b697f95a720fddfdfceaea7be Mon Sep 17 00:00:00 2001 From: rafalzawadzki Date: Tue, 24 Mar 2026 22:01:38 +0100 Subject: [PATCH 3/5] Move OAuth endpoints under /oauth/ prefix to avoid route conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /authorize path was being intercepted by a conflicting route on the api.supadata.ai zone (likely the API gateway). Moving all OAuth endpoints under /oauth/ avoids any conflicts: - /authorize → /oauth/authorize - /token → /oauth/token - /register → /oauth/register - /callback → /oauth/callback Also simplified wrangler routes to use a single /oauth/* wildcard. Co-Authored-By: Claude Opus 4.6 --- src/auth-handler.ts | 10 +++++----- src/worker.ts | 6 +++--- wrangler.toml | 5 +---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/auth-handler.ts b/src/auth-handler.ts index 06cedf8..5b2eebf 100644 --- a/src/auth-handler.ts +++ b/src/auth-handler.ts @@ -26,8 +26,8 @@ async function createState( return stateToken; } -// GET /authorize — redirect to dashboard for login -app.get('/authorize', async (c) => { +// GET /oauth/authorize — redirect to dashboard for login +app.get('/oauth/authorize', async (c) => { const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); if (!oauthReqInfo.clientId) { return c.text('Invalid request: missing client_id', 400); @@ -35,7 +35,7 @@ app.get('/authorize', async (c) => { const stateToken = await createState(oauthReqInfo, c.env.OAUTH_KV); - const callbackUrl = new URL('/callback', c.req.url).href; + const callbackUrl = new URL('/oauth/callback', c.req.url).href; const dashboardUrl = new URL('/oauth/mcp', c.env.DASHBOARD_URL); dashboardUrl.searchParams.set('state', stateToken); dashboardUrl.searchParams.set('callback', callbackUrl); @@ -43,8 +43,8 @@ app.get('/authorize', async (c) => { return c.redirect(dashboardUrl.toString()); }); -// GET /callback — validate JWT from dashboard and complete authorization -app.get('/callback', async (c) => { +// GET /oauth/callback — validate JWT from dashboard and complete authorization +app.get('/oauth/callback', async (c) => { const token = c.req.query('token'); const stateToken = c.req.query('state'); const error = c.req.query('error'); diff --git a/src/worker.ts b/src/worker.ts index 03493fd..19f0f3d 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -123,9 +123,9 @@ export default { apiRoute: '/mcp', apiHandler: mcpHandler, defaultHandler: authHandler, - authorizeEndpoint: '/authorize', - tokenEndpoint: '/token', - clientRegistrationEndpoint: '/register', + authorizeEndpoint: '/oauth/authorize', + tokenEndpoint: '/oauth/token', + clientRegistrationEndpoint: '/oauth/register', scopesSupported: ['mcp'], accessTokenTTL: 3600, // 1 hour refreshTokenTTL: 2592000, // 30 days diff --git a/wrangler.toml b/wrangler.toml index 4cdefc1..3abcec7 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -5,10 +5,7 @@ routes = [ { pattern = "api.supadata.ai/mcp", zone_name = "supadata.ai" }, { pattern = "api.supadata.ai/.well-known/oauth-authorization-server", zone_name = "supadata.ai" }, { pattern = "api.supadata.ai/.well-known/oauth-protected-resource", zone_name = "supadata.ai" }, - { pattern = "api.supadata.ai/authorize", zone_name = "supadata.ai" }, - { pattern = "api.supadata.ai/token", zone_name = "supadata.ai" }, - { pattern = "api.supadata.ai/register", zone_name = "supadata.ai" }, - { pattern = "api.supadata.ai/callback", zone_name = "supadata.ai" }, + { pattern = "api.supadata.ai/oauth/*", zone_name = "supadata.ai" }, ] [observability] From ba421d0cdfc1af62816d6d278dc48af85a3fe71b Mon Sep 17 00:00:00 2001 From: rafalzawadzki Date: Tue, 24 Mar 2026 22:11:34 +0100 Subject: [PATCH 4/5] Fix token audience mismatch with trailing slash normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude sends resource=https://api.supadata.ai/ (with trailing slash) in the authorize request. This gets stored as the token audience. When validating the token on /mcp, the library computes the resource server as protocol://host (no trailing slash) and does strict equality — which fails. Strip trailing slashes from the resource parameter before storing the grant. Co-Authored-By: Claude Opus 4.6 --- src/auth-handler.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/auth-handler.ts b/src/auth-handler.ts index 5b2eebf..2e35160 100644 --- a/src/auth-handler.ts +++ b/src/auth-handler.ts @@ -94,6 +94,18 @@ app.get('/oauth/callback', async (c) => { return c.text('Invalid token claims', 400); } + // Normalize resource parameter: strip trailing slash to match the library's + // audience validation which computes resourceServer as protocol://host (no slash). + // Claude sends resource=https://api.supadata.ai/ but the library checks against + // https://api.supadata.ai — strict equality fails without this normalization. + if (oauthReqInfo.resource) { + if (typeof oauthReqInfo.resource === 'string') { + oauthReqInfo.resource = oauthReqInfo.resource.replace(/\/+$/, ''); + } else if (Array.isArray(oauthReqInfo.resource)) { + oauthReqInfo.resource = oauthReqInfo.resource.map((r: string) => r.replace(/\/+$/, '')); + } + } + // Complete the OAuth authorization — the library issues its own tokens const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ request: oauthReqInfo, From 0717d88dce17104957bbca64fd40ab753d4fea14 Mon Sep 17 00:00:00 2001 From: rafalzawadzki Date: Tue, 24 Mar 2026 22:50:57 +0100 Subject: [PATCH 5/5] Fix OAuth token audience mismatch by stripping resource parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root cause from worker logs: 1. Claude sends resource=https://api.supadata.ai/ (trailing slash) in both the authorize request and token exchange POST body 2. The library stores this as the token audience 3. When validating tokens on /mcp, the library computes the resource server as protocol://host (no trailing slash) and does strict equality 4. "https://api.supadata.ai" !== "https://api.supadata.ai/" → 401 Fix: strip the resource parameter from two places: - auth-handler.ts: delete resource from the grant before completeAuthorization - worker.ts: strip resource from the token exchange POST body before it reaches the OAuthProvider This prevents any audience from being stored in tokens, skipping the audience validation entirely on MCP requests. Co-Authored-By: Claude Opus 4.6 --- src/auth-handler.ts | 24 +++++++++++++----------- src/worker.ts | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/auth-handler.ts b/src/auth-handler.ts index 2e35160..7227923 100644 --- a/src/auth-handler.ts +++ b/src/auth-handler.ts @@ -28,7 +28,9 @@ async function createState( // GET /oauth/authorize — redirect to dashboard for login app.get('/oauth/authorize', async (c) => { + console.log('[Auth] /oauth/authorize hit'); const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw); + console.log('[Auth] Parsed auth request:', JSON.stringify({ clientId: oauthReqInfo.clientId, redirectUri: oauthReqInfo.redirectUri, scope: oauthReqInfo.scope, resource: oauthReqInfo.resource })); if (!oauthReqInfo.clientId) { return c.text('Invalid request: missing client_id', 400); } @@ -40,11 +42,13 @@ app.get('/oauth/authorize', async (c) => { dashboardUrl.searchParams.set('state', stateToken); dashboardUrl.searchParams.set('callback', callbackUrl); + console.log('[Auth] Redirecting to dashboard:', dashboardUrl.toString()); return c.redirect(dashboardUrl.toString()); }); // GET /oauth/callback — validate JWT from dashboard and complete authorization app.get('/oauth/callback', async (c) => { + console.log('[Auth] /oauth/callback hit'); const token = c.req.query('token'); const stateToken = c.req.query('state'); const error = c.req.query('error'); @@ -94,17 +98,14 @@ app.get('/oauth/callback', async (c) => { return c.text('Invalid token claims', 400); } - // Normalize resource parameter: strip trailing slash to match the library's - // audience validation which computes resourceServer as protocol://host (no slash). - // Claude sends resource=https://api.supadata.ai/ but the library checks against - // https://api.supadata.ai — strict equality fails without this normalization. - if (oauthReqInfo.resource) { - if (typeof oauthReqInfo.resource === 'string') { - oauthReqInfo.resource = oauthReqInfo.resource.replace(/\/+$/, ''); - } else if (Array.isArray(oauthReqInfo.resource)) { - oauthReqInfo.resource = oauthReqInfo.resource.map((r: string) => r.replace(/\/+$/, '')); - } - } + // Remove resource parameter to avoid audience validation issues. + // Claude sends resource=https://api.supadata.ai/ (trailing slash) but the library + // validates tokens against protocol://host (no slash) using strict equality. + // This mismatch causes "Token audience does not match resource server" on every + // MCP request. Removing resource skips audience validation entirely. + delete (oauthReqInfo as any).resource; + + console.log('[Auth] JWT valid, userId:', userId, 'resource:', oauthReqInfo.resource); // Complete the OAuth authorization — the library issues its own tokens const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ @@ -122,6 +123,7 @@ app.get('/oauth/callback', async (c) => { } }); + console.log('[Auth] completeAuthorization redirectTo:', redirectTo); return c.redirect(redirectTo); }); diff --git a/src/worker.ts b/src/worker.ts index 19f0f3d..3ecd319 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -118,6 +118,26 @@ export default { ); } + // Log all requests for debugging + console.log(`[OAuth] ${request.method} ${url.pathname} (auth: ${request.headers.has('authorization') ? 'Bearer' : 'none'})`); + + // Strip `resource` parameter from token exchange requests. + // Claude sends resource=https://api.supadata.ai/ (trailing slash) which gets stored + // as the token audience. The library then validates tokens against protocol://host + // (no trailing slash) using strict equality, causing every MCP request to fail with + // "Token audience does not match resource server". Removing `resource` from both + // the grant (auth-handler) and the token exchange body prevents audience from being set. + let processedRequest = request; + if (url.pathname === '/oauth/token' && request.method === 'POST') { + const body = await request.text(); + const params = new URLSearchParams(body); + if (params.has('resource')) { + console.log(`[OAuth] Stripping resource param from token exchange: ${params.get('resource')}`); + params.delete('resource'); + processedRequest = new Request(request, { body: params.toString() }); + } + } + // OAuth flow for everything else const provider = new OAuthProvider({ apiRoute: '/mcp', @@ -130,6 +150,7 @@ export default { accessTokenTTL: 3600, // 1 hour refreshTokenTTL: 2592000, // 30 days onError({ code, description, status, headers }) { + console.log(`[OAuth Error] status=${status} code=${code} desc=${description}`); // Add resource_metadata to WWW-Authenticate header on 401 (RFC 9728) if (status === 401) { const newHeaders = { ...headers }; @@ -143,6 +164,8 @@ export default { }, }); - return provider.fetch(request, env, ctx); + const response = await provider.fetch(processedRequest, env, ctx); + console.log(`[OAuth Response] ${url.pathname} -> ${response.status}`); + return response; }, };