From 5f599ca2e8db702fe04a63cd67051cbf8113a413 Mon Sep 17 00:00:00 2001 From: Maximo Guk <62088388+Maximo-Guk@users.noreply.github.com> Date: Thu, 17 Apr 2025 18:22:55 -0500 Subject: [PATCH] Don't use DefaultScopes for /authorize endpoint, instead have each app define it's own scopes --- apps/sandbox-container/server/index.ts | 13 +- apps/workers-bindings/src/index.ts | 13 +- apps/workers-observability/src/index.ts | 13 +- packages/mcp-common/src/cloudflare-auth.ts | 16 +- .../src/cloudflare-oauth-handler.ts | 199 +++++++++--------- 5 files changed, 141 insertions(+), 113 deletions(-) diff --git a/apps/sandbox-container/server/index.ts b/apps/sandbox-container/server/index.ts index 4c9dee80..649edcb1 100644 --- a/apps/sandbox-container/server/index.ts +++ b/apps/sandbox-container/server/index.ts @@ -2,7 +2,7 @@ import OAuthProvider from '@cloudflare/workers-oauth-provider' import { env } from 'cloudflare:workers' import { - CloudflareAuthHandler, + createAuthHandlers, handleTokenExchangeCallback, } from '@repo/mcp-common/src/cloudflare-oauth-handler' @@ -29,12 +29,21 @@ export type Props = { accounts: AccountSchema['result'] } +const ContainerScopes = { + 'account:read': 'See your account info such as account details, analytics, and memberships.', + 'user:read': 'See your user info such as name, email address, and account memberships.', + 'workers:write': + 'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.', + 'workers_observability:read': 'See observability logs for your account', + offline_access: 'Grants refresh tokens for long-lived access.', +} as const + export default new OAuthProvider({ apiRoute: '/sse', // @ts-ignore apiHandler: ContainerMcpAgent.mount('/sse', { binding: 'CONTAINER_MCP_AGENT' }), // @ts-ignore - defaultHandler: CloudflareAuthHandler, + defaultHandler: createAuthHandlers({ scopes: ContainerScopes }), authorizeEndpoint: '/oauth/authorize', tokenEndpoint: '/token', tokenExchangeCallback: (options) => diff --git a/apps/workers-bindings/src/index.ts b/apps/workers-bindings/src/index.ts index c6cc527b..e8380d21 100644 --- a/apps/workers-bindings/src/index.ts +++ b/apps/workers-bindings/src/index.ts @@ -4,7 +4,7 @@ import { McpAgent } from 'agents/mcp' import { env } from 'cloudflare:workers' import { - CloudflareAuthHandler, + createAuthHandlers, handleTokenExchangeCallback, } from '@repo/mcp-common/src/cloudflare-oauth-handler' import { registerAccountTools } from '@repo/mcp-common/src/tools/account' @@ -60,13 +60,22 @@ export class WorkersBindingsMCP extends McpAgent diff --git a/apps/workers-observability/src/index.ts b/apps/workers-observability/src/index.ts index 7b7d68e1..e3166e4d 100644 --- a/apps/workers-observability/src/index.ts +++ b/apps/workers-observability/src/index.ts @@ -4,7 +4,7 @@ import { McpAgent } from 'agents/mcp' import { env } from 'cloudflare:workers' import { - CloudflareAuthHandler, + createAuthHandlers, handleTokenExchangeCallback, } from '@repo/mcp-common/src/cloudflare-oauth-handler' import { registerAccountTools } from '@repo/mcp-common/src/tools/account' @@ -66,12 +66,21 @@ export class MyMCP extends McpAgent { } } +const ObservabilityScopes = { + 'account:read': 'See your account info such as account details, analytics, and memberships.', + 'user:read': 'See your user info such as name, email address, and account memberships.', + 'workers:write': + 'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.', + 'workers_observability:read': 'See observability logs for your account', + offline_access: 'Grants refresh tokens for long-lived access.', +} as const + export default new OAuthProvider({ apiRoute: '/sse', // @ts-ignore apiHandler: MyMCP.mount('/sse'), // @ts-ignore - defaultHandler: CloudflareAuthHandler, + defaultHandler: createAuthHandlers({ scopes: ObservabilityScopes }), authorizeEndpoint: '/oauth/authorize', tokenEndpoint: '/token', tokenExchangeCallback: (options) => diff --git a/packages/mcp-common/src/cloudflare-auth.ts b/packages/mcp-common/src/cloudflare-auth.ts index b2966fd5..ec45e829 100644 --- a/packages/mcp-common/src/cloudflare-auth.ts +++ b/packages/mcp-common/src/cloudflare-auth.ts @@ -7,15 +7,6 @@ import type { AuthRequest } from '@cloudflare/workers-oauth-provider' // Constants const PKCE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' const RECOMMENDED_CODE_VERIFIER_LENGTH = 96 -export const DefaultScopes = { - 'account:read': 'See your account info such as account details, analytics, and memberships.', - 'user:read': 'See your user info such as name, email address, and account memberships.', - 'workers:write': - 'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.', - 'workers_observability:read': 'See observability logs for your account', - offline_access: 'Grants refresh tokens for long-lived access.', -} as const - function base64urlEncode(value: string): string { let base64 = btoa(value) base64 = base64.replace(/\+/g, '-') @@ -52,11 +43,13 @@ function generateAuthUrl({ redirect_uri, state, code_challenge, + scopes, }: { client_id: string redirect_uri: string code_challenge: string state: string + scopes: Record }) { const params = new URLSearchParams({ response_type: 'code', @@ -65,7 +58,7 @@ function generateAuthUrl({ state, code_challenge, code_challenge_method: 'S256', - scope: Object.keys(DefaultScopes).join(' '), + scope: Object.keys(scopes).join(' '), }) const upstream = new URL(`https://dash.cloudflare.com/oauth2/auth?${params.toString()}`) @@ -86,10 +79,12 @@ export async function getAuthorizationURL({ client_id, redirect_uri, state, + scopes, }: { client_id: string redirect_uri: string state: AuthRequest + scopes: Record }): Promise<{ authUrl: string; codeVerifier: string }> { const { codeChallenge, codeVerifier } = await generatePKCECodes() @@ -99,6 +94,7 @@ export async function getAuthorizationURL({ redirect_uri, state: btoa(JSON.stringify({ ...state, codeVerifier })), code_challenge: codeChallenge, + scopes, }), codeVerifier: codeVerifier, } diff --git a/packages/mcp-common/src/cloudflare-oauth-handler.ts b/packages/mcp-common/src/cloudflare-oauth-handler.ts index 555acef5..80328c0f 100644 --- a/packages/mcp-common/src/cloudflare-oauth-handler.ts +++ b/packages/mcp-common/src/cloudflare-oauth-handler.ts @@ -2,12 +2,7 @@ import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { z } from 'zod' -import { - DefaultScopes, - getAuthorizationURL, - getAuthToken, - refreshAuthToken, -} from './cloudflare-auth' +import { getAuthorizationURL, getAuthToken, refreshAuthToken } from './cloudflare-auth' import { McpError } from './mcp-error' import type { @@ -140,102 +135,112 @@ export async function handleTokenExchangeCallback( } } -const app = new Hono() - /** - * OAuth Authorization Endpoint + * Creates a Hono app with OAuth routes for a specific Cloudflare worker * - * This route initiates the Cloudflare OAuth flow when a user wants to log in. - * It creates a random state parameter to prevent CSRF attacks and stores the - * original OAuth request information in KV storage for later retrieval. - * Then it redirects the user to Cloudflare's authorization page with the appropriate - * parameters so the user can authenticate and grant permissions. + * @param scopes optional subset of scopes to request when handling authorization requests + * @returns a Hono app with configured OAuth routes */ -app.get(`/oauth/authorize`, async (c) => { - try { - const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw) - oauthReqInfo.scope = Object.keys(DefaultScopes) - if (!oauthReqInfo.clientId) { - return c.text('Invalid request', 400) - } - - const res = await getAuthorizationURL({ - client_id: c.env.CLOUDFLARE_CLIENT_ID, - redirect_uri: new URL('/oauth/callback', c.req.url).href, - state: oauthReqInfo, +export function createAuthHandlers({ scopes }: { scopes: Record }) { + { + const app = new Hono() + + /** + * OAuth Authorization Endpoint + * + * This route initiates the Cloudflare OAuth flow when a user wants to log in. + * It creates a random state parameter to prevent CSRF attacks and stores the + * original OAuth request information in KV storage for later retrieval. + * Then it redirects the user to Cloudflare's authorization page with the appropriate + * parameters so the user can authenticate and grant permissions. + */ + app.get(`/oauth/authorize`, async (c) => { + try { + const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw) + oauthReqInfo.scope = Object.keys(scopes) + if (!oauthReqInfo.clientId) { + return c.text('Invalid request', 400) + } + const res = await getAuthorizationURL({ + client_id: c.env.CLOUDFLARE_CLIENT_ID, + redirect_uri: new URL('/oauth/callback', c.req.url).href, + state: oauthReqInfo, + scopes, + }) + + return Response.redirect(res.authUrl, 302) + } catch (e) { + if (e instanceof McpError) { + return c.text(e.message, { status: e.code }) + } + console.error(e) + return c.text('Internal Error', 500) + } }) - return Response.redirect(res.authUrl, 302) - } catch (e) { - if (e instanceof McpError) { - return c.text(e.message, { status: e.code }) - } - console.error(e) - return c.text('Internal Error', 500) - } -}) - -/** - * OAuth Callback Endpoint - * - * This route handles the callback from Cloudflare after user authentication. - * It exchanges the temporary code for an access token, then stores some - * user metadata & the auth token as part of the 'props' on the token passed - * down to the client. It ends by redirecting the client back to _its_ callback URL - */ -app.get(`/oauth/callback`, zValidator('query', AuthQuery), async (c) => { - try { - const { state, code } = c.req.valid('query') - const oauthReqInfo = AuthRequestSchemaWithExtraParams.parse(JSON.parse(atob(state))) - // Get the oathReqInfo out of KV - if (!oauthReqInfo.clientId) { - throw new McpError('Invalid State', 400) - } - - const [{ accessToken, refreshToken, user, accounts }] = await Promise.all([ - getTokenAndUser(c, code, oauthReqInfo.codeVerifier), - c.env.OAUTH_PROVIDER.createClient({ - clientId: oauthReqInfo.clientId, - tokenEndpointAuthMethod: 'none', - }), - ]) - - // TODO: Implement auth restriction in staging - // if ( - // !user.email.endsWith("@cloudflare.com") && - // !(c.env.PERMITTED_USERS ?? []).includes(user.email) - // ) { - // throw new McpError( - // `This user ${user.email} is not allowed to access this restricted MCP server`, - // 401, - // ); - // } - - // Return back to the MCP client a new token - const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ - request: oauthReqInfo, - userId: user.id, - metadata: { - label: user.email, - }, - scope: oauthReqInfo.scope, - // This will be available on this.props inside MyMCP - props: { - user, - accounts, - accessToken, - refreshToken, - }, + /** + * OAuth Callback Endpoint + * + * This route handles the callback from Cloudflare after user authentication. + * It exchanges the temporary code for an access token, then stores some + * user metadata & the auth token as part of the 'props' on the token passed + * down to the client. It ends by redirecting the client back to _its_ callback URL + */ + app.get(`/oauth/callback`, zValidator('query', AuthQuery), async (c) => { + try { + const { state, code } = c.req.valid('query') + const oauthReqInfo = AuthRequestSchemaWithExtraParams.parse(JSON.parse(atob(state))) + // Get the oathReqInfo out of KV + if (!oauthReqInfo.clientId) { + throw new McpError('Invalid State', 400) + } + + const [{ accessToken, refreshToken, user, accounts }] = await Promise.all([ + getTokenAndUser(c, code, oauthReqInfo.codeVerifier), + c.env.OAUTH_PROVIDER.createClient({ + clientId: oauthReqInfo.clientId, + tokenEndpointAuthMethod: 'none', + }), + ]) + + // TODO: Implement auth restriction in staging + // if ( + // !user.email.endsWith("@cloudflare.com") && + // !(c.env.PERMITTED_USERS ?? []).includes(user.email) + // ) { + // throw new McpError( + // `This user ${user.email} is not allowed to access this restricted MCP server`, + // 401, + // ); + // } + + // Return back to the MCP client a new token + const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ + request: oauthReqInfo, + userId: user.id, + metadata: { + label: user.email, + }, + scope: oauthReqInfo.scope, + // This will be available on this.props inside MyMCP + props: { + user, + accounts, + accessToken, + refreshToken, + }, + }) + + return Response.redirect(redirectTo, 302) + } catch (e) { + console.error(e) + if (e instanceof McpError) { + return c.text(e.message, { status: e.code }) + } + return c.text('Internal Error', 500) + } }) - return Response.redirect(redirectTo, 302) - } catch (e) { - console.error(e) - if (e instanceof McpError) { - return c.text(e.message, { status: e.code }) - } - return c.text('Internal Error', 500) + return app } -}) - -export const CloudflareAuthHandler = app +}