From a3735c1a49addd539b1aa511d933638b7ad30b09 Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Tue, 17 Mar 2026 12:06:16 -0600 Subject: [PATCH 1/2] Support OAuth --- src/config.ts | 14 +++ src/index.ts | 139 ++++++++++++++++++++++++------ src/lib/account-resolver.ts | 88 +++++++++++++++++++ test/integration/server.spec.ts | 6 ++ test/lib/account-resolver.spec.ts | 136 +++++++++++++++++++++++++++++ test/lib/api-client.spec.ts | 6 ++ test/resources/resources.spec.ts | 6 ++ test/tools/download.spec.ts | 6 ++ test/tools/export.spec.ts | 6 ++ test/tools/function.spec.ts | 6 ++ test/tools/smartscraper.spec.ts | 6 ++ 11 files changed, 391 insertions(+), 28 deletions(-) create mode 100644 src/lib/account-resolver.ts create mode 100644 test/lib/account-resolver.spec.ts diff --git a/src/config.ts b/src/config.ts index abff470..21c3305 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,13 @@ export interface McpConfig { analyticsEnabled: boolean; sqsQueueUrl?: string; sqsRegion: string; + // OAuth (Supabase) + oauthEnabled: boolean; + supabaseUrl: string; + supabaseOAuthClientId: string; + supabaseOAuthClientSecret: string; + supabaseServiceRoleKey: string; + mcpBaseUrl: string; } export function getConfig(): McpConfig { @@ -38,5 +45,12 @@ export function getConfig(): McpConfig { analyticsEnabled: process.env.ANALYTICS_ENABLED === 'true', sqsQueueUrl: process.env.SQS_QUEUE_URL, sqsRegion: process.env.SQS_REGION ?? 'us-west-2', + // OAuth (Supabase) + oauthEnabled: process.env.OAUTH_ENABLED === 'true', + supabaseUrl: process.env.SUPABASE_URL ?? '', + supabaseOAuthClientId: process.env.SUPABASE_OAUTH_CLIENT_ID ?? '', + supabaseOAuthClientSecret: process.env.SUPABASE_OAUTH_CLIENT_SECRET ?? '', + supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? '', + mcpBaseUrl: process.env.MCP_BASE_URL ?? 'https://mcp.browserless.io', }; } diff --git a/src/index.ts b/src/index.ts index b163d83..939b1e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import { FastMCP } from 'fastmcp'; +import type { IncomingMessage } from 'node:http'; +import { FastMCP, OAuthProvider } from 'fastmcp'; import { getConfig } from './config.js'; import type { BrowserlessSession } from './config.js'; import { registerPowerScraperTool } from './tools/smartscraper.js'; @@ -10,45 +11,127 @@ import { registerStatusResource } from './resources/status.js'; import { registerScrapeUrlPrompt } from './prompts/scrape-url.js'; import { registerExtractContentPrompt } from './prompts/extract-content.js'; import { AmplitudeHelper } from './lib/amplitude.js'; +import { resolveApiKey } from './lib/account-resolver.js'; const config = getConfig(); + +// Supabase OAuth tokens have a very short TTL (60s), which causes FastMCP's +// token-swap mode to issue equally short-lived JWTs and trigger constant refresh +// cycles. We intercept Supabase token responses to extend the TTL to 1 hour. +// This is safe because we only decode the JWT payload (for accountId) — we never +// use it as a bearer token against Supabase APIs. +const OAUTH_TOKEN_TTL_OVERRIDE = 3600; // 1 hour +const originalFetch = globalThis.fetch; +globalThis.fetch = async (...args: Parameters) => { + const response = await originalFetch(...args); + const url = + typeof args[0] === 'string' + ? args[0] + : args[0] instanceof URL + ? args[0].toString() + : (args[0] as Request).url; + if (response.ok && url.includes('/oauth/token')) { + const body = (await response.json()) as Record; + if ( + typeof body.expires_in === 'number' && + body.expires_in < OAUTH_TOKEN_TTL_OVERRIDE + ) { + body.expires_in = OAUTH_TOKEN_TTL_OVERRIDE; + } + return new Response(JSON.stringify(body), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + return response; +}; const amplitude = new AmplitudeHelper( config.analyticsEnabled, config.sqsQueueUrl, config.sqsRegion, ); -const server = new FastMCP({ - name: 'browserless-mcp', - version: '0.1.0', - authenticate: - config.transport === 'httpStream' - ? async (request) => { - const params = new URLSearchParams(request.url?.split('?')[1] ?? ''); - - // Token: Authorization header > ?token= query param - const authHeader = request.headers.authorization; - const headerToken = authHeader?.startsWith('Bearer ') - ? authHeader.slice(7) - : authHeader; - const token = headerToken || params.get('token') || undefined; - - if (!token) { - throw new Error( - 'No Browserless API token provided. ' + - 'Pass it as Authorization: Bearer header or ?token= query parameter.', +// OAuth proxy via Supabase (for Claude.ai and other OAuth-capable MCP clients). +// OAuthProvider acts as an OAuth 2.1 proxy implementing the MCP authorization spec: +// - Protected Resource Metadata (RFC 9728) +// - Auth Server Metadata (RFC 8414) +// - Dynamic Client Registration (RFC 7591) +// - Authorization code flow with PKCE proxied to Supabase +const oauthProvider = + config.oauthEnabled && config.transport === 'httpStream' + ? new OAuthProvider({ + baseUrl: config.mcpBaseUrl, + clientId: config.supabaseOAuthClientId, + clientSecret: config.supabaseOAuthClientSecret, + authorizationEndpoint: `${config.supabaseUrl}/auth/v1/oauth/authorize`, + tokenEndpoint: `${config.supabaseUrl}/auth/v1/oauth/token`, + scopes: ['email'], + consentRequired: true, + }) + : undefined; + +// Hybrid authenticate: plain API key first, then ?token=, then OAuth fallback. +// 1. Authorization header with plain API key (non-JWT) → direct token session +// 2. ?token= query param → direct token session +// 3. Authorization header with JWT → OAuth flow: validate via OAuthProvider, +// resolve Browserless API key from Supabase PostgREST, return as session token +const hybridAuthenticate = + config.transport === 'httpStream' + ? async (request: IncomingMessage) => { + const params = new URLSearchParams(request.url?.split('?')[1] ?? ''); + const authHeader = request.headers.authorization as string | undefined; + const headerToken = authHeader?.startsWith('Bearer ') + ? authHeader.slice(7) + : authHeader; + + const apiUrl = + (request.headers['x-browserless-api-url'] as string) ?? + params.get('browserlessUrl') ?? + config.browserlessApiUrl; + + // JWTs have 3 dot-separated base64url segments; plain API keys do not. + const isJwt = headerToken + ? headerToken.split('.').length === 3 + : false; + + // 1. Authorization header with plain API key + if (headerToken && !isJwt) { + return { token: headerToken, apiUrl } as BrowserlessSession; + } + + // 2. ?token= query param + const directToken = params.get('token') || undefined; + if (directToken) { + return { token: directToken, apiUrl } as BrowserlessSession; + } + + // 3. Authorization header with JWT → OAuth flow + if (oauthProvider && isJwt) { + const oauthSession = await oauthProvider.authenticate(request); + if (oauthSession?.accessToken) { + const { apiKey } = await resolveApiKey( + config.supabaseUrl, + config.supabaseServiceRoleKey, + oauthSession.accessToken, ); + return { token: apiKey, apiUrl } as BrowserlessSession; } + } - // API URL: x-browserless-api-url header > ?browserlessUrl= query param > default - const apiUrl = - (request.headers['x-browserless-api-url'] as string) ?? - params.get('browserlessUrl') ?? - config.browserlessApiUrl; + throw new Error( + 'No Browserless API token provided. ' + + 'Pass it as Authorization: Bearer header, ' + + '?token= query parameter, or authenticate via OAuth.', + ); + } + : undefined; - return { token, apiUrl }; - } - : undefined, +const server = new FastMCP({ + name: 'browserless-mcp', + version: '0.1.0', + ...(oauthProvider ? { auth: oauthProvider } : {}), + authenticate: hybridAuthenticate, }); registerPowerScraperTool(server, config, amplitude); diff --git a/src/lib/account-resolver.ts b/src/lib/account-resolver.ts new file mode 100644 index 0000000..af3445a --- /dev/null +++ b/src/lib/account-resolver.ts @@ -0,0 +1,88 @@ +import { ResponseCache } from './cache.js'; + +interface ResolvedAccount { + apiKey: string; + email: string; +} + +interface SupabaseJwtPayload { + sub?: string; + email?: string; + app_metadata?: { + accountId?: string; + }; +} + +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const cache = new ResponseCache(CACHE_TTL_MS); + +function decodeJwtPayload(jwt: string): SupabaseJwtPayload { + const parts = jwt.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT format'); + } + const payload = Buffer.from(parts[1], 'base64url').toString('utf-8'); + return JSON.parse(payload) as SupabaseJwtPayload; +} + +/** + * Resolves a Browserless API key from a Supabase access token (JWT) + * by extracting app_metadata.accountId and querying Supabase PostgREST. + */ +export async function resolveApiKey( + supabaseUrl: string, + serviceRoleKey: string, + accessToken: string, +): Promise { + const payload = decodeJwtPayload(accessToken); + const accountId = payload.app_metadata?.accountId; + + if (!accountId) { + throw new Error( + 'Supabase JWT does not contain app_metadata.accountId. ' + + 'The user may not have a Browserless account.', + ); + } + + const cached = cache.get(`account:${accountId}`); + if (cached) { + return cached; + } + + const url = `${supabaseUrl}/rest/v1/accounts?account_id=eq.${encodeURIComponent(accountId)}&select=api_key,email`; + const response = await fetch(url, { + headers: { + apikey: serviceRoleKey, + Authorization: `Bearer ${serviceRoleKey}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Supabase REST API returned ${response.status}: ${response.statusText}`, + ); + } + + const rows = (await response.json()) as Array<{ + api_key?: string; + email?: string; + }>; + const account = rows[0]; + + if (!account?.api_key || !account?.email) { + throw new Error('Account not found or missing api_key/email.'); + } + + const resolved: ResolvedAccount = { + apiKey: account.api_key, + email: account.email, + }; + + cache.set(`account:${accountId}`, resolved); + return resolved; +} + +export function clearResolverCache(): void { + cache.clear(); +} diff --git a/test/integration/server.spec.ts b/test/integration/server.spec.ts index eb3b0b8..b9aea2f 100644 --- a/test/integration/server.spec.ts +++ b/test/integration/server.spec.ts @@ -18,6 +18,12 @@ const mockConfig: McpConfig = { cacheTtlMs: 0, analyticsEnabled: false, sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', }; describe('MCP Server Integration', () => { diff --git a/test/lib/account-resolver.spec.ts b/test/lib/account-resolver.spec.ts new file mode 100644 index 0000000..2751516 --- /dev/null +++ b/test/lib/account-resolver.spec.ts @@ -0,0 +1,136 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { resolveApiKey, clearResolverCache } from '../../src/lib/account-resolver.js'; + +function buildFakeJwt(payload: Record): string { + const header = Buffer.from( + JSON.stringify({ alg: 'HS256', typ: 'JWT' }), + ).toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${header}.${body}.fake-sig`; +} + +const SUPABASE_URL = 'https://test.supabase.co'; +const SERVICE_ROLE_KEY = 'test-service-role-key'; + +describe('account-resolver', () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + clearResolverCache(); + fetchStub = sinon.stub(globalThis, 'fetch'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('resolves API key from valid Supabase JWT via PostgREST', async () => { + const jwt = buildFakeJwt({ + sub: 'user-uuid', + email: 'user@example.com', + app_metadata: { accountId: 'acc-123' }, + }); + + fetchStub.resolves( + new Response( + JSON.stringify([{ api_key: 'resolved-key', email: 'user@example.com' }]), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await resolveApiKey(SUPABASE_URL, SERVICE_ROLE_KEY, jwt); + + expect(result.apiKey).to.equal('resolved-key'); + expect(result.email).to.equal('user@example.com'); + + // Verify PostgREST call + const [url, opts] = fetchStub.firstCall.args; + expect(url).to.include('/rest/v1/accounts'); + expect(url).to.include('account_id=eq.acc-123'); + expect(opts.headers.apikey).to.equal(SERVICE_ROLE_KEY); + expect(opts.headers.Authorization).to.equal(`Bearer ${SERVICE_ROLE_KEY}`); + }); + + it('returns cached result on second call', async () => { + const jwt = buildFakeJwt({ + sub: 'user-uuid', + app_metadata: { accountId: 'acc-456' }, + }); + + fetchStub.resolves( + new Response( + JSON.stringify([{ api_key: 'cached-key', email: 'cached@example.com' }]), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await resolveApiKey(SUPABASE_URL, SERVICE_ROLE_KEY, jwt); + const result = await resolveApiKey(SUPABASE_URL, SERVICE_ROLE_KEY, jwt); + + expect(result.apiKey).to.equal('cached-key'); + expect(fetchStub.callCount).to.equal(1); + }); + + it('throws when JWT has no app_metadata.accountId', async () => { + const jwt = buildFakeJwt({ sub: 'user-uuid', email: 'user@example.com' }); + + try { + await resolveApiKey(SUPABASE_URL, SERVICE_ROLE_KEY, jwt); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('app_metadata.accountId'); + } + }); + + it('throws when PostgREST returns empty array', async () => { + const jwt = buildFakeJwt({ + sub: 'user-uuid', + app_metadata: { accountId: 'acc-missing' }, + }); + + fetchStub.resolves( + new Response(JSON.stringify([]), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + try { + await resolveApiKey(SUPABASE_URL, SERVICE_ROLE_KEY, jwt); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Account not found'); + } + }); + + it('throws on PostgREST HTTP error', async () => { + const jwt = buildFakeJwt({ + sub: 'user-uuid', + app_metadata: { accountId: 'acc-err' }, + }); + + fetchStub.resolves( + new Response('Internal Server Error', { + status: 500, + statusText: 'Internal Server Error', + }), + ); + + try { + await resolveApiKey(SUPABASE_URL, SERVICE_ROLE_KEY, jwt); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('500'); + } + }); + + it('throws on invalid JWT format', async () => { + try { + await resolveApiKey(SUPABASE_URL, SERVICE_ROLE_KEY, 'not-a-jwt'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Invalid JWT format'); + } + }); +}); diff --git a/test/lib/api-client.spec.ts b/test/lib/api-client.spec.ts index a711108..9984c16 100644 --- a/test/lib/api-client.spec.ts +++ b/test/lib/api-client.spec.ts @@ -13,6 +13,12 @@ const mockConfig: McpConfig = { cacheTtlMs: 60000, analyticsEnabled: false, sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', }; const mockSuccessResponse = { diff --git a/test/resources/resources.spec.ts b/test/resources/resources.spec.ts index d7a43c6..474eb7a 100644 --- a/test/resources/resources.spec.ts +++ b/test/resources/resources.spec.ts @@ -15,6 +15,12 @@ const mockConfig: McpConfig = { cacheTtlMs: 0, analyticsEnabled: false, sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', }; describe('Resources', () => { diff --git a/test/tools/download.spec.ts b/test/tools/download.spec.ts index 0c53f85..74f72c7 100644 --- a/test/tools/download.spec.ts +++ b/test/tools/download.spec.ts @@ -15,6 +15,12 @@ const mockConfig: McpConfig = { cacheTtlMs: 0, analyticsEnabled: false, sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', }; const mockContext = { diff --git a/test/tools/export.spec.ts b/test/tools/export.spec.ts index 67f2a08..2f19183 100644 --- a/test/tools/export.spec.ts +++ b/test/tools/export.spec.ts @@ -15,6 +15,12 @@ const mockConfig: McpConfig = { cacheTtlMs: 0, analyticsEnabled: false, sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', }; const mockContext = { diff --git a/test/tools/function.spec.ts b/test/tools/function.spec.ts index b8da1f0..87528d5 100644 --- a/test/tools/function.spec.ts +++ b/test/tools/function.spec.ts @@ -15,6 +15,12 @@ const mockConfig: McpConfig = { cacheTtlMs: 0, analyticsEnabled: false, sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', }; const mockContext = { diff --git a/test/tools/smartscraper.spec.ts b/test/tools/smartscraper.spec.ts index ae87260..8dba4a8 100644 --- a/test/tools/smartscraper.spec.ts +++ b/test/tools/smartscraper.spec.ts @@ -15,6 +15,12 @@ const mockConfig: McpConfig = { cacheTtlMs: 0, analyticsEnabled: false, sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', }; const makeSuccessResponse = (overrides = {}) => ({ From f0b2fadfe20d25954fd5e5731d9641394bcadfe8 Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Tue, 17 Mar 2026 14:20:13 -0600 Subject: [PATCH 2/2] support supabase JWT directly --- src/index.ts | 53 ++++++++++++++++++++++++++++++++------------------- tsconfig.json | 4 ++-- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index 939b1e9..03924f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import type { IncomingMessage } from 'node:http'; import { FastMCP, OAuthProvider } from 'fastmcp'; +import { OAuthProxy } from 'fastmcp/auth'; import { getConfig } from './config.js'; import type { BrowserlessSession } from './config.js'; import { registerPowerScraperTool } from './tools/smartscraper.js'; @@ -52,15 +53,30 @@ const amplitude = new AmplitudeHelper( config.sqsRegion, ); -// OAuth proxy via Supabase (for Claude.ai and other OAuth-capable MCP clients). -// OAuthProvider acts as an OAuth 2.1 proxy implementing the MCP authorization spec: -// - Protected Resource Metadata (RFC 9728) -// - Auth Server Metadata (RFC 8414) -// - Dynamic Client Registration (RFC 7591) -// - Authorization code flow with PKCE proxied to Supabase +// Passthrough OAuth provider: disables FastMCP's token-swap mode so the MCP client +// receives the raw Supabase JWT directly. This eliminates server-side token storage, +// meaning server restarts don't invalidate client sessions. +class PassthroughOAuthProvider extends OAuthProvider { + protected createProxy(): OAuthProxy { + return new OAuthProxy({ + allowedRedirectUriPatterns: ['http://localhost:*', 'https://*'], + baseUrl: this.config.baseUrl, + consentRequired: false, + enableTokenSwap: false, + scopes: this.config.scopes ?? [], + upstreamAuthorizationEndpoint: this.genericConfig.authorizationEndpoint, + upstreamClientId: this.config.clientId, + upstreamClientSecret: this.config.clientSecret, + upstreamTokenEndpoint: this.genericConfig.tokenEndpoint, + upstreamTokenEndpointAuthMethod: + this.genericConfig.tokenEndpointAuthMethod ?? 'client_secret_basic', + }); + } +} + const oauthProvider = config.oauthEnabled && config.transport === 'httpStream' - ? new OAuthProvider({ + ? new PassthroughOAuthProvider({ baseUrl: config.mcpBaseUrl, clientId: config.supabaseOAuthClientId, clientSecret: config.supabaseOAuthClientSecret, @@ -71,10 +87,10 @@ const oauthProvider = }) : undefined; -// Hybrid authenticate: plain API key first, then ?token=, then OAuth fallback. +// Hybrid authenticate: plain API key first, then ?token=, then OAuth/JWT fallback. // 1. Authorization header with plain API key (non-JWT) → direct token session // 2. ?token= query param → direct token session -// 3. Authorization header with JWT → OAuth flow: validate via OAuthProvider, +// 3. Authorization header with JWT (Supabase token from OAuth) → decode payload, // resolve Browserless API key from Supabase PostgREST, return as session token const hybridAuthenticate = config.transport === 'httpStream' @@ -106,17 +122,14 @@ const hybridAuthenticate = return { token: directToken, apiUrl } as BrowserlessSession; } - // 3. Authorization header with JWT → OAuth flow - if (oauthProvider && isJwt) { - const oauthSession = await oauthProvider.authenticate(request); - if (oauthSession?.accessToken) { - const { apiKey } = await resolveApiKey( - config.supabaseUrl, - config.supabaseServiceRoleKey, - oauthSession.accessToken, - ); - return { token: apiKey, apiUrl } as BrowserlessSession; - } + // 3. Authorization header with JWT → decode Supabase token directly + if (isJwt && headerToken) { + const { apiKey } = await resolveApiKey( + config.supabaseUrl, + config.supabaseServiceRoleKey, + headerToken, + ); + return { token: apiKey, apiUrl } as BrowserlessSession; } throw new Error( diff --git a/tsconfig.json b/tsconfig.json index 954b1e6..c21063d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,8 +3,8 @@ "rootDir": ".", "outDir": "build", "baseUrl": ".", - "module": "es2022", - "moduleResolution": "node", + "module": "Node16", + "moduleResolution": "Node16", "target": "es2022", "lib": ["es2022"], "strict": true,