Skip to main content
Glama

mcp-server-cloudflare

Official
by cloudflare
cloudflare-oauth-handler.ts13.3 kB
import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { z } from 'zod' import { AuthUser } from '../../mcp-observability/src' import { generatePKCECodes, getAuthorizationURL, getAuthToken, refreshAuthToken, } from './cloudflare-auth' import { McpError } from './mcp-error' import { useSentry } from './sentry' import { V4Schema } from './v4-api' import { bindStateToSession, clientIdAlreadyApproved, createOAuthState, generateCSRFProtection, OAuthError, parseRedirectApproval, renderApprovalDialog, validateOAuthState, } from './workers-oauth-utils' import type { AuthRequest, OAuthHelpers, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, } from '@cloudflare/workers-oauth-provider' import type { Context } from 'hono' import type { MetricsTracker } from '../../mcp-observability/src' import type { BaseHonoContext } from './sentry' type AuthContext = { Bindings: { OAUTH_PROVIDER: OAuthHelpers OAUTH_KV: KVNamespace MCP_COOKIE_ENCRYPTION_KEY: string CLOUDFLARE_CLIENT_ID: string CLOUDFLARE_CLIENT_SECRET: string MCP_SERVER_NAME?: string MCP_SERVER_DESCRIPTION?: string } } & BaseHonoContext const AuthQuery = z.object({ code: z.string().describe('OAuth code from CF dash'), state: z.string().describe('Value of the OAuth state'), scope: z.string().describe('OAuth scopes granted'), }) type UserSchema = z.infer<typeof UserSchema> const UserSchema = z.object({ id: z.string(), email: z.string(), }) const AccountSchema = z.object({ name: z.string(), id: z.string(), }) type AccountsSchema = z.infer<typeof AccountsSchema> const AccountsSchema = z.array(AccountSchema) const AccountAuthProps = z.object({ type: z.literal('account_token'), accessToken: z.string(), account: AccountSchema, }) const UserAuthProps = z.object({ type: z.literal('user_token'), accessToken: z.string(), user: UserSchema, accounts: AccountsSchema, refreshToken: z.string().optional(), }) export type AuthProps = z.infer<typeof AuthProps> const AuthProps = z.discriminatedUnion('type', [AccountAuthProps, UserAuthProps]) export async function getUserAndAccounts( accessToken: string, devModeHeaders?: HeadersInit ): Promise<{ user: UserSchema | null; accounts: AccountsSchema }> { const headers = devModeHeaders ? devModeHeaders : { Authorization: `Bearer ${accessToken}`, } // Fetch the user & accounts info from Cloudflare const [userResponse, accountsResponse] = await Promise.all([ fetch('https://api.cloudflare.com/client/v4/user', { headers, }), fetch('https://api.cloudflare.com/client/v4/accounts', { headers, }), ]) const { result: user } = V4Schema(UserSchema).parse(await userResponse.json()) const { result: accounts } = V4Schema(AccountsSchema).parse(await accountsResponse.json()) if (!user || !userResponse.ok) { // If accounts is present, then assume that we have an account scoped token if (accounts !== null) { return { user: null, accounts } } console.log(user) throw new McpError('Failed to fetch user', 500, { reportToSentry: true }) } if (!accounts || !accountsResponse.ok) { console.log(accounts) throw new McpError('Failed to fetch accounts', 500, { reportToSentry: true }) } return { user, accounts } } /** * Exchanges an OAuth authorization code for access and refresh tokens, then fetches user and account details. * * @param c - Hono context containing OAuth environment variables (client ID/secret) * @param code - OAuth authorization code received from the authorization server * @param code_verifier - PKCE code verifier used to validate the authorization request * @returns Promise resolving to an object containing access token, refresh token, user profile, and accounts */ async function getTokenAndUserDetails( c: Context<AuthContext>, code: string, code_verifier: string ): Promise<{ accessToken: string refreshToken: string user: UserSchema accounts: AccountsSchema }> { // Exchange the code for an access token const { access_token: accessToken, refresh_token: refreshToken } = await getAuthToken({ client_id: c.env.CLOUDFLARE_CLIENT_ID, client_secret: c.env.CLOUDFLARE_CLIENT_SECRET, redirect_uri: new URL('/oauth/callback', c.req.url).href, code, code_verifier, }) const { user, accounts } = await getUserAndAccounts(accessToken) // User cannot be null for OAuth flow if (user === null) { throw new McpError('Failed to fetch user', 500, { reportToSentry: true }) } return { accessToken, refreshToken, user, accounts } } export async function handleTokenExchangeCallback( options: TokenExchangeCallbackOptions, clientId: string, clientSecret: string ): Promise<TokenExchangeCallbackResult | undefined> { // options.props contains the current props if (options.grantType === 'refresh_token') { const props = AuthProps.parse(options.props) if (props.type === 'account_token') { // Refreshing an account_token should not be possible, as we only do this for user tokens throw new McpError('Internal Server Error', 500) } if (!props.refreshToken) { throw new McpError('Missing refreshToken', 500) } // handle token refreshes const { access_token: accessToken, refresh_token: refreshToken, expires_in, } = await refreshAuthToken({ client_id: clientId, client_secret: clientSecret, refresh_token: props.refreshToken, }) return { newProps: { ...options.props, accessToken, refreshToken, } satisfies AuthProps, accessTokenTTL: expires_in, } } } /** * Helper function to redirect to Cloudflare OAuth * * Note: We pass the stateToken as a simple string in the URL. * The existing getAuthorizationURL function will wrap it with the oauthReqInfo * before base64-encoding. * On callback, we extract the stateToken, look up the original oauthReqInfo in KV. */ async function redirectToCloudflare( c: Context<AuthContext>, oauthReqInfo: AuthRequest, stateToken: string, codeChallenge: string, scopes: Record<string, string>, additionalHeaders: Record<string, string> = {} ): Promise<Response> { // Create a modified oauthReqInfo that includes our stateToken const stateWithToken: AuthRequest = { ...oauthReqInfo, state: stateToken, // embed our KV state token } const { authUrl } = await getAuthorizationURL({ client_id: c.env.CLOUDFLARE_CLIENT_ID, redirect_uri: new URL('/oauth/callback', c.req.url).href, state: stateWithToken, scopes, codeChallenge, }) return new Response(null, { status: 302, headers: { ...additionalHeaders, Location: authUrl, }, }) } /** * Creates a Hono app with OAuth routes for a specific Cloudflare worker * * @param scopes optional subset of scopes to request when handling authorization requests * @param metrics MetricsTracker which is used to track auth metrics * @returns a Hono app with configured OAuth routes */ export function createAuthHandlers({ scopes, metrics, }: { scopes: Record<string, string> metrics: MetricsTracker }) { const app = new Hono<AuthContext>() app.use(useSentry) /** * GET /oauth/authorize - Show consent dialog or redirect if approved */ 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 new OAuthError('invalid_request', 'Missing client_id parameter', 400).toResponse() } // Check if client was previously approved (skip consent if so) if ( await clientIdAlreadyApproved( c.req.raw, oauthReqInfo.clientId, c.env.MCP_COOKIE_ENCRYPTION_KEY ) ) { // Client already approved - create state and redirect immediately const { codeChallenge, codeVerifier } = await generatePKCECodes() const stateToken = await createOAuthState(oauthReqInfo, c.env.OAUTH_KV, codeVerifier) const { setCookie: sessionCookie } = await bindStateToSession(stateToken) return redirectToCloudflare(c, oauthReqInfo, stateToken, codeChallenge, scopes, { 'Set-Cookie': sessionCookie, }) } // Client not approved - show consent dialog const { token: csrfToken, setCookie: csrfCookie } = generateCSRFProtection() // Render approval dialog const response = renderApprovalDialog(c.req.raw, { client: await c.env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId), server: { name: c.env.MCP_SERVER_NAME || 'Cloudflare MCP Server', logo: 'https://images.mcp.cloudflare.com/mcp.svg', description: c.env.MCP_SERVER_DESCRIPTION || 'This server uses Cloudflare for authentication.', }, state: { oauthReqInfo, }, csrfToken, setCookie: csrfCookie, }) return response } catch (e) { c.var.sentry?.recordError(e) let message: string | undefined if (e instanceof Error) { message = `${e.name}: ${e.message}` } else if (typeof e === 'string') { message = e } else { message = 'Unknown error' } metrics.logEvent( new AuthUser({ errorMessage: `Authorize Error: ${message}`, }) ) if (e instanceof OAuthError) { return e.toResponse() } if (e instanceof McpError) { return c.text(e.message, { status: e.code }) } console.error(e) return c.text('Internal Error', 500) } }) /** * POST /oauth/authorize - Handle consent form submission */ app.post(`/oauth/authorize`, async (c) => { try { // Validates CSRF token, extracts state, and generates approved client cookie const { state, headers } = await parseRedirectApproval( c.req.raw, c.env.MCP_COOKIE_ENCRYPTION_KEY ) if (!state.oauthReqInfo) { return new OAuthError( 'invalid_request', 'Missing OAuth request info in state', 400 ).toResponse() } const oauthReqInfo = state.oauthReqInfo as AuthRequest // Create OAuth state in KV and bind to session const { codeChallenge, codeVerifier } = await generatePKCECodes() const stateToken = await createOAuthState(oauthReqInfo, c.env.OAUTH_KV, codeVerifier) const { setCookie: sessionCookie } = await bindStateToSession(stateToken) // Build redirect response const redirectResponse = await redirectToCloudflare( c, oauthReqInfo, stateToken, codeChallenge, scopes ) // Add both cookies: approved client cookie (if present) and session binding cookie // Note: We must use append() for multiple Set-Cookie headers, not combine with commas if (headers['Set-Cookie']) { redirectResponse.headers.append('Set-Cookie', headers['Set-Cookie']) } redirectResponse.headers.append('Set-Cookie', sessionCookie) return redirectResponse } catch (e) { c.var.sentry?.recordError(e) let message: string | undefined if (e instanceof Error) { message = `${e.name}: ${e.message}` } else if (typeof e === 'string') { message = e } else { message = 'Unknown error' } metrics.logEvent( new AuthUser({ errorMessage: `Authorize POST Error: ${message}`, }) ) if (e instanceof OAuthError) { return e.toResponse() } console.error(e) return c.text('Internal Error', 500) } }) /** * GET /oauth/callback - Handle OAuth callback from Cloudflare */ app.get(`/oauth/callback`, zValidator('query', AuthQuery), async (c) => { try { const { code } = c.req.valid('query') // Validate state using dual validation (KV + session cookie) const { oauthReqInfo, codeVerifier, clearCookie } = await validateOAuthState( c.req.raw, c.env.OAUTH_KV ) if (!oauthReqInfo.clientId) { return new OAuthError('invalid_request', 'Invalid OAuth request info', 400).toResponse() } // Exchange code for tokens and get user details const [{ accessToken, refreshToken, user, accounts }] = await Promise.all([ getTokenAndUserDetails(c, code, codeVerifier), // use codeVerifier from KV c.env.OAUTH_PROVIDER.createClient({ clientId: oauthReqInfo.clientId, tokenEndpointAuthMethod: 'none', }), ]) // Complete authorization and issue token to MCP client const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ request: oauthReqInfo, userId: user.id, metadata: { label: user.email, }, scope: oauthReqInfo.scope, props: { type: 'user_token', user, accounts, accessToken, refreshToken, } satisfies AuthProps, }) metrics.logEvent( new AuthUser({ userId: user.id, }) ) // Redirect back to MCP client with cleared session cookie return new Response(null, { status: 302, headers: { Location: redirectTo, 'Set-Cookie': clearCookie, }, }) } catch (e) { c.var.sentry?.recordError(e) let message: string | undefined if (e instanceof Error) { console.error(e) message = `${e.name}: ${e.message}` } else if (typeof e === 'string') { message = e } else { message = 'Unknown error' } metrics.logEvent( new AuthUser({ errorMessage: `Callback Error: ${message}`, }) ) if (e instanceof OAuthError) { return e.toResponse() } if (e instanceof McpError) { return c.text(e.message, { status: e.code }) } return c.text('Internal Error', 500) } }) return app }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cloudflare/mcp-server-cloudflare'

If you have feedback or need assistance with the MCP directory API, please join our Discord server