Skip to content

Have each app define it's own scopes #57

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 18, 2025
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
13 changes: 11 additions & 2 deletions apps/sandbox-container/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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) =>
Expand Down
13 changes: 11 additions & 2 deletions apps/workers-bindings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -60,13 +60,22 @@ export class WorkersBindingsMCP extends McpAgent<Env, WorkersBindingsMCPState, P
}
}

const BindingsScopes = {
'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 the OAuth handler as the default
export default new OAuthProvider({
apiRoute: '/sse',
// @ts-ignore
apiHandler: WorkersBindingsMCP.mount('/sse'),
// @ts-ignore
defaultHandler: CloudflareAuthHandler,
defaultHandler: createAuthHandlers({ scopes: BindingsScopes }),
authorizeEndpoint: '/oauth/authorize',
tokenEndpoint: '/token',
tokenExchangeCallback: (options) =>
Expand Down
13 changes: 11 additions & 2 deletions apps/workers-observability/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -66,12 +66,21 @@ export class MyMCP extends McpAgent<Env, State, Props> {
}
}

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) =>
Expand Down
16 changes: 6 additions & 10 deletions packages/mcp-common/src/cloudflare-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '-')
Expand Down Expand Up @@ -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<string, string>
}) {
const params = new URLSearchParams({
response_type: 'code',
Expand All @@ -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()}`)
Expand All @@ -86,10 +79,12 @@ export async function getAuthorizationURL({
client_id,
redirect_uri,
state,
scopes,
}: {
client_id: string
redirect_uri: string
state: AuthRequest
scopes: Record<string, string>
}): Promise<{ authUrl: string; codeVerifier: string }> {
const { codeChallenge, codeVerifier } = await generatePKCECodes()

Expand All @@ -99,6 +94,7 @@ export async function getAuthorizationURL({
redirect_uri,
state: btoa(JSON.stringify({ ...state, codeVerifier })),
code_challenge: codeChallenge,
scopes,
}),
codeVerifier: codeVerifier,
}
Expand Down
199 changes: 102 additions & 97 deletions packages/mcp-common/src/cloudflare-oauth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -140,102 +135,112 @@ export async function handleTokenExchangeCallback(
}
}

const app = new Hono<AuthContext>()

/**
* 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<string, string> }) {
{
const app = new Hono<AuthContext>()

/**
* 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
}
Loading