Skip to content

Commit b391764

Browse files
authored
Don't use DefaultScopes for /authorize endpoint, instead have each app define it's own scopes (#57)
1 parent 2aa9c02 commit b391764

File tree

5 files changed

+141
-113
lines changed

5 files changed

+141
-113
lines changed

apps/sandbox-container/server/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import OAuthProvider from '@cloudflare/workers-oauth-provider'
22
import { env } from 'cloudflare:workers'
33

44
import {
5-
CloudflareAuthHandler,
5+
createAuthHandlers,
66
handleTokenExchangeCallback,
77
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
88

@@ -29,12 +29,21 @@ export type Props = {
2929
accounts: AccountSchema['result']
3030
}
3131

32+
const ContainerScopes = {
33+
'account:read': 'See your account info such as account details, analytics, and memberships.',
34+
'user:read': 'See your user info such as name, email address, and account memberships.',
35+
'workers:write':
36+
'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.',
37+
'workers_observability:read': 'See observability logs for your account',
38+
offline_access: 'Grants refresh tokens for long-lived access.',
39+
} as const
40+
3241
export default new OAuthProvider({
3342
apiRoute: '/sse',
3443
// @ts-ignore
3544
apiHandler: ContainerMcpAgent.mount('/sse', { binding: 'CONTAINER_MCP_AGENT' }),
3645
// @ts-ignore
37-
defaultHandler: CloudflareAuthHandler,
46+
defaultHandler: createAuthHandlers({ scopes: ContainerScopes }),
3847
authorizeEndpoint: '/oauth/authorize',
3948
tokenEndpoint: '/token',
4049
tokenExchangeCallback: (options) =>

apps/workers-bindings/src/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { McpAgent } from 'agents/mcp'
44
import { env } from 'cloudflare:workers'
55

66
import {
7-
CloudflareAuthHandler,
7+
createAuthHandlers,
88
handleTokenExchangeCallback,
99
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
1010
import { registerAccountTools } from '@repo/mcp-common/src/tools/account'
@@ -62,13 +62,22 @@ export class WorkersBindingsMCP extends McpAgent<Env, WorkersBindingsMCPState, P
6262
}
6363
}
6464

65+
const BindingsScopes = {
66+
'account:read': 'See your account info such as account details, analytics, and memberships.',
67+
'user:read': 'See your user info such as name, email address, and account memberships.',
68+
'workers:write':
69+
'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.',
70+
'workers_observability:read': 'See observability logs for your account',
71+
offline_access: 'Grants refresh tokens for long-lived access.',
72+
} as const
73+
6574
// Export the OAuth handler as the default
6675
export default new OAuthProvider({
6776
apiRoute: '/sse',
6877
// @ts-ignore
6978
apiHandler: WorkersBindingsMCP.mount('/sse'),
7079
// @ts-ignore
71-
defaultHandler: CloudflareAuthHandler,
80+
defaultHandler: createAuthHandlers({ scopes: BindingsScopes }),
7281
authorizeEndpoint: '/oauth/authorize',
7382
tokenEndpoint: '/token',
7483
tokenExchangeCallback: (options) =>

apps/workers-observability/src/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { McpAgent } from 'agents/mcp'
44
import { env } from 'cloudflare:workers'
55

66
import {
7-
CloudflareAuthHandler,
7+
createAuthHandlers,
88
handleTokenExchangeCallback,
99
} from '@repo/mcp-common/src/cloudflare-oauth-handler'
1010
import { registerAccountTools } from '@repo/mcp-common/src/tools/account'
@@ -66,12 +66,21 @@ export class MyMCP extends McpAgent<Env, State, Props> {
6666
}
6767
}
6868

69+
const ObservabilityScopes = {
70+
'account:read': 'See your account info such as account details, analytics, and memberships.',
71+
'user:read': 'See your user info such as name, email address, and account memberships.',
72+
'workers:write':
73+
'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.',
74+
'workers_observability:read': 'See observability logs for your account',
75+
offline_access: 'Grants refresh tokens for long-lived access.',
76+
} as const
77+
6978
export default new OAuthProvider({
7079
apiRoute: '/sse',
7180
// @ts-ignore
7281
apiHandler: MyMCP.mount('/sse'),
7382
// @ts-ignore
74-
defaultHandler: CloudflareAuthHandler,
83+
defaultHandler: createAuthHandlers({ scopes: ObservabilityScopes }),
7584
authorizeEndpoint: '/oauth/authorize',
7685
tokenEndpoint: '/token',
7786
tokenExchangeCallback: (options) =>

packages/mcp-common/src/cloudflare-auth.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,6 @@ import type { AuthRequest } from '@cloudflare/workers-oauth-provider'
77
// Constants
88
const PKCE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
99
const RECOMMENDED_CODE_VERIFIER_LENGTH = 96
10-
export const DefaultScopes = {
11-
'account:read': 'See your account info such as account details, analytics, and memberships.',
12-
'user:read': 'See your user info such as name, email address, and account memberships.',
13-
'workers:write':
14-
'See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.',
15-
'workers_observability:read': 'See observability logs for your account',
16-
offline_access: 'Grants refresh tokens for long-lived access.',
17-
} as const
18-
1910
function base64urlEncode(value: string): string {
2011
let base64 = btoa(value)
2112
base64 = base64.replace(/\+/g, '-')
@@ -52,11 +43,13 @@ function generateAuthUrl({
5243
redirect_uri,
5344
state,
5445
code_challenge,
46+
scopes,
5547
}: {
5648
client_id: string
5749
redirect_uri: string
5850
code_challenge: string
5951
state: string
52+
scopes: Record<string, string>
6053
}) {
6154
const params = new URLSearchParams({
6255
response_type: 'code',
@@ -65,7 +58,7 @@ function generateAuthUrl({
6558
state,
6659
code_challenge,
6760
code_challenge_method: 'S256',
68-
scope: Object.keys(DefaultScopes).join(' '),
61+
scope: Object.keys(scopes).join(' '),
6962
})
7063

7164
const upstream = new URL(`https://dash.cloudflare.com/oauth2/auth?${params.toString()}`)
@@ -86,10 +79,12 @@ export async function getAuthorizationURL({
8679
client_id,
8780
redirect_uri,
8881
state,
82+
scopes,
8983
}: {
9084
client_id: string
9185
redirect_uri: string
9286
state: AuthRequest
87+
scopes: Record<string, string>
9388
}): Promise<{ authUrl: string; codeVerifier: string }> {
9489
const { codeChallenge, codeVerifier } = await generatePKCECodes()
9590

@@ -99,6 +94,7 @@ export async function getAuthorizationURL({
9994
redirect_uri,
10095
state: btoa(JSON.stringify({ ...state, codeVerifier })),
10196
code_challenge: codeChallenge,
97+
scopes,
10298
}),
10399
codeVerifier: codeVerifier,
104100
}

packages/mcp-common/src/cloudflare-oauth-handler.ts

Lines changed: 102 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ import { zValidator } from '@hono/zod-validator'
22
import { Hono } from 'hono'
33
import { z } from 'zod'
44

5-
import {
6-
DefaultScopes,
7-
getAuthorizationURL,
8-
getAuthToken,
9-
refreshAuthToken,
10-
} from './cloudflare-auth'
5+
import { getAuthorizationURL, getAuthToken, refreshAuthToken } from './cloudflare-auth'
116
import { McpError } from './mcp-error'
127

138
import type {
@@ -140,102 +135,112 @@ export async function handleTokenExchangeCallback(
140135
}
141136
}
142137

143-
const app = new Hono<AuthContext>()
144-
145138
/**
146-
* OAuth Authorization Endpoint
139+
* Creates a Hono app with OAuth routes for a specific Cloudflare worker
147140
*
148-
* This route initiates the Cloudflare OAuth flow when a user wants to log in.
149-
* It creates a random state parameter to prevent CSRF attacks and stores the
150-
* original OAuth request information in KV storage for later retrieval.
151-
* Then it redirects the user to Cloudflare's authorization page with the appropriate
152-
* parameters so the user can authenticate and grant permissions.
141+
* @param scopes optional subset of scopes to request when handling authorization requests
142+
* @returns a Hono app with configured OAuth routes
153143
*/
154-
app.get(`/oauth/authorize`, async (c) => {
155-
try {
156-
const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw)
157-
oauthReqInfo.scope = Object.keys(DefaultScopes)
158-
if (!oauthReqInfo.clientId) {
159-
return c.text('Invalid request', 400)
160-
}
161-
162-
const res = await getAuthorizationURL({
163-
client_id: c.env.CLOUDFLARE_CLIENT_ID,
164-
redirect_uri: new URL('/oauth/callback', c.req.url).href,
165-
state: oauthReqInfo,
144+
export function createAuthHandlers({ scopes }: { scopes: Record<string, string> }) {
145+
{
146+
const app = new Hono<AuthContext>()
147+
148+
/**
149+
* OAuth Authorization Endpoint
150+
*
151+
* This route initiates the Cloudflare OAuth flow when a user wants to log in.
152+
* It creates a random state parameter to prevent CSRF attacks and stores the
153+
* original OAuth request information in KV storage for later retrieval.
154+
* Then it redirects the user to Cloudflare's authorization page with the appropriate
155+
* parameters so the user can authenticate and grant permissions.
156+
*/
157+
app.get(`/oauth/authorize`, async (c) => {
158+
try {
159+
const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw)
160+
oauthReqInfo.scope = Object.keys(scopes)
161+
if (!oauthReqInfo.clientId) {
162+
return c.text('Invalid request', 400)
163+
}
164+
const res = await getAuthorizationURL({
165+
client_id: c.env.CLOUDFLARE_CLIENT_ID,
166+
redirect_uri: new URL('/oauth/callback', c.req.url).href,
167+
state: oauthReqInfo,
168+
scopes,
169+
})
170+
171+
return Response.redirect(res.authUrl, 302)
172+
} catch (e) {
173+
if (e instanceof McpError) {
174+
return c.text(e.message, { status: e.code })
175+
}
176+
console.error(e)
177+
return c.text('Internal Error', 500)
178+
}
166179
})
167180

168-
return Response.redirect(res.authUrl, 302)
169-
} catch (e) {
170-
if (e instanceof McpError) {
171-
return c.text(e.message, { status: e.code })
172-
}
173-
console.error(e)
174-
return c.text('Internal Error', 500)
175-
}
176-
})
177-
178-
/**
179-
* OAuth Callback Endpoint
180-
*
181-
* This route handles the callback from Cloudflare after user authentication.
182-
* It exchanges the temporary code for an access token, then stores some
183-
* user metadata & the auth token as part of the 'props' on the token passed
184-
* down to the client. It ends by redirecting the client back to _its_ callback URL
185-
*/
186-
app.get(`/oauth/callback`, zValidator('query', AuthQuery), async (c) => {
187-
try {
188-
const { state, code } = c.req.valid('query')
189-
const oauthReqInfo = AuthRequestSchemaWithExtraParams.parse(JSON.parse(atob(state)))
190-
// Get the oathReqInfo out of KV
191-
if (!oauthReqInfo.clientId) {
192-
throw new McpError('Invalid State', 400)
193-
}
194-
195-
const [{ accessToken, refreshToken, user, accounts }] = await Promise.all([
196-
getTokenAndUser(c, code, oauthReqInfo.codeVerifier),
197-
c.env.OAUTH_PROVIDER.createClient({
198-
clientId: oauthReqInfo.clientId,
199-
tokenEndpointAuthMethod: 'none',
200-
}),
201-
])
202-
203-
// TODO: Implement auth restriction in staging
204-
// if (
205-
// !user.email.endsWith("@cloudflare.com") &&
206-
// !(c.env.PERMITTED_USERS ?? []).includes(user.email)
207-
// ) {
208-
// throw new McpError(
209-
// `This user ${user.email} is not allowed to access this restricted MCP server`,
210-
// 401,
211-
// );
212-
// }
213-
214-
// Return back to the MCP client a new token
215-
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
216-
request: oauthReqInfo,
217-
userId: user.id,
218-
metadata: {
219-
label: user.email,
220-
},
221-
scope: oauthReqInfo.scope,
222-
// This will be available on this.props inside MyMCP
223-
props: {
224-
user,
225-
accounts,
226-
accessToken,
227-
refreshToken,
228-
},
181+
/**
182+
* OAuth Callback Endpoint
183+
*
184+
* This route handles the callback from Cloudflare after user authentication.
185+
* It exchanges the temporary code for an access token, then stores some
186+
* user metadata & the auth token as part of the 'props' on the token passed
187+
* down to the client. It ends by redirecting the client back to _its_ callback URL
188+
*/
189+
app.get(`/oauth/callback`, zValidator('query', AuthQuery), async (c) => {
190+
try {
191+
const { state, code } = c.req.valid('query')
192+
const oauthReqInfo = AuthRequestSchemaWithExtraParams.parse(JSON.parse(atob(state)))
193+
// Get the oathReqInfo out of KV
194+
if (!oauthReqInfo.clientId) {
195+
throw new McpError('Invalid State', 400)
196+
}
197+
198+
const [{ accessToken, refreshToken, user, accounts }] = await Promise.all([
199+
getTokenAndUser(c, code, oauthReqInfo.codeVerifier),
200+
c.env.OAUTH_PROVIDER.createClient({
201+
clientId: oauthReqInfo.clientId,
202+
tokenEndpointAuthMethod: 'none',
203+
}),
204+
])
205+
206+
// TODO: Implement auth restriction in staging
207+
// if (
208+
// !user.email.endsWith("@cloudflare.com") &&
209+
// !(c.env.PERMITTED_USERS ?? []).includes(user.email)
210+
// ) {
211+
// throw new McpError(
212+
// `This user ${user.email} is not allowed to access this restricted MCP server`,
213+
// 401,
214+
// );
215+
// }
216+
217+
// Return back to the MCP client a new token
218+
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
219+
request: oauthReqInfo,
220+
userId: user.id,
221+
metadata: {
222+
label: user.email,
223+
},
224+
scope: oauthReqInfo.scope,
225+
// This will be available on this.props inside MyMCP
226+
props: {
227+
user,
228+
accounts,
229+
accessToken,
230+
refreshToken,
231+
},
232+
})
233+
234+
return Response.redirect(redirectTo, 302)
235+
} catch (e) {
236+
console.error(e)
237+
if (e instanceof McpError) {
238+
return c.text(e.message, { status: e.code })
239+
}
240+
return c.text('Internal Error', 500)
241+
}
229242
})
230243

231-
return Response.redirect(redirectTo, 302)
232-
} catch (e) {
233-
console.error(e)
234-
if (e instanceof McpError) {
235-
return c.text(e.message, { status: e.code })
236-
}
237-
return c.text('Internal Error', 500)
244+
return app
238245
}
239-
})
240-
241-
export const CloudflareAuthHandler = app
246+
}

0 commit comments

Comments
 (0)