Skip to content

Commit 3be5560

Browse files
authored
feat(auth): support Client ID Metadata Documents (#195)
* fix(auth): stop creating phantom clients during callbacks * feat(auth): enable Client ID Metadata Documents * build: upgrade workers OAuth provider to 0.10.2 Client auth method negotiation now offers the provider-supported none alternative alongside private_key_jwt, and CIMD documents are parsed with the typed metadata parser pinned to draft-ietf-oauth-client-id-metadata- document-00. Adopt the 0.10.1 resource-handling semantics in tests: a wrong authorize resource redirects with invalid_target, and a token request omitting resource inherits the grant's canonical resource instead of failing. The provider types now accept the Hono default handler, so drop the ts-ignore.
1 parent 855732b commit 3be5560

10 files changed

Lines changed: 6568 additions & 2438 deletions

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"dev": "wrangler dev",
88
"deploy": "wrangler deploy --env staging",
99
"deploy:prod": "wrangler deploy --env production",
10-
"types": "wrangler types",
10+
"types": "wrangler types --env-file .env_example",
1111
"typecheck": "tsc --noEmit",
1212
"lint": "oxlint src/",
1313
"format": "oxfmt --write src/",
@@ -19,7 +19,7 @@
1919
"seed:prod": "tsx scripts/seed-r2.ts production"
2020
},
2121
"dependencies": {
22-
"@cloudflare/workers-oauth-provider": "0.10.0",
22+
"@cloudflare/workers-oauth-provider": "0.10.2",
2323
"@modelcontextprotocol/server": "2.0.0",
2424
"hono": "^4.12.25",
2525
"zod": "^4.3.5"

src/auth/oauth-handler.ts

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createOAuthState,
1414
bindStateToSession,
1515
generateCSRFProtection,
16+
isAllowedOAuthRedirectUri,
1617
parseRedirectApproval,
1718
renderApprovalDialog,
1819
renderErrorPage,
@@ -141,6 +142,30 @@ async function redirectToCloudflare(
141142
})
142143
}
143144

145+
function cimdUnavailableResponse(): Response {
146+
return new OAuthError(
147+
'temporarily_unavailable',
148+
'Client metadata is temporarily unavailable. Please try again.',
149+
503,
150+
{ 'Retry-After': '30' }
151+
).toHtmlResponse()
152+
}
153+
154+
function cimdCallbackFailureResponse(): Response {
155+
return new OAuthError(
156+
'server_error',
157+
'Client metadata could not be verified after sign-in. Restart authorization from your MCP client.',
158+
500
159+
).toHtmlResponse()
160+
}
161+
162+
function invalidRedirectUriResponse(): Response {
163+
return new OAuthError(
164+
'invalid_request',
165+
'Redirect URI must use HTTPS or a local loopback address'
166+
).toHtmlResponse()
167+
}
168+
144169
/**
145170
* Create OAuth route handlers using patterns from workers-oauth-provider
146171
*/
@@ -155,7 +180,7 @@ export function createAuthHandlers() {
155180
oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw)
156181
} catch (error) {
157182
if (error instanceof AuthorizationError) {
158-
if (!error.redirectUri) {
183+
if (!error.redirectUri || !isAllowedOAuthRedirectUri(error.redirectUri)) {
159184
return new OAuthError(error.code, error.description).toHtmlResponse()
160185
}
161186
const redirect = new URL(error.redirectUri)
@@ -169,15 +194,13 @@ export function createAuthHandlers() {
169194
})
170195
}
171196
if (error instanceof CimdFetchError) {
172-
return new OAuthError(
173-
'temporarily_unavailable',
174-
'Client metadata is temporarily unavailable. Please try again.',
175-
503,
176-
{ 'Retry-After': '30' }
177-
).toHtmlResponse()
197+
return cimdUnavailableResponse()
178198
}
179199
throw error
180200
}
201+
if (!isAllowedOAuthRedirectUri(oauthReqInfo.redirectUri)) {
202+
return invalidRedirectUriResponse()
203+
}
181204
const defaultScopes = [...SCOPE_TEMPLATES[DEFAULT_TEMPLATE].scopes]
182205
const requestedScopes = oauthReqInfo.scope ?? []
183206
const unknownScopes = requestedScopes.filter((scope) => !ALLOWED_SCOPES.has(scope))
@@ -199,6 +222,7 @@ export function createAuthHandlers() {
199222

200223
return renderApprovalDialog(c.req.raw, {
201224
client: await env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId),
225+
redirectUri: oauthReqInfo.redirectUri,
202226
server: {
203227
name: 'Cloudflare API MCP',
204228
logo: 'https://www.cloudflare.com/favicon.ico',
@@ -214,6 +238,7 @@ export function createAuthHandlers() {
214238
initialScopes: scopesToRequest
215239
})
216240
} catch (e) {
241+
if (e instanceof CimdFetchError) return cimdUnavailableResponse()
217242
metrics.logEvent(new AuthUser({ errorMessage: authErrorMessage('Authorize Error', e) }))
218243
if (e instanceof OAuthError) return e.toHtmlResponse()
219244
const errorId = crypto.randomUUID()
@@ -237,6 +262,9 @@ export function createAuthHandlers() {
237262
}
238263

239264
const oauthReqInfo = state.oauthReqInfo as AuthRequest
265+
if (!isAllowedOAuthRedirectUri(oauthReqInfo.redirectUri)) {
266+
return invalidRedirectUriResponse()
267+
}
240268

241269
// Drop stale custom-template entries and always restore required bootstrap scopes.
242270
const scopesToRequest = Array.from(
@@ -289,25 +317,24 @@ export function createAuthHandlers() {
289317
env.OAUTH_KV
290318
)
291319

320+
if (!isAllowedOAuthRedirectUri(oauthReqInfo.redirectUri)) {
321+
const response = invalidRedirectUriResponse()
322+
response.headers.append('Set-Cookie', clearCookie)
323+
return response
324+
}
325+
292326
if (!oauthReqInfo.clientId) {
293327
return new OAuthError('invalid_request', 'Invalid OAuth request info').toHtmlResponse()
294328
}
295329

296-
// Exchange code for tokens and ensure client is registered
297-
const [{ access_token, refresh_token }] = await Promise.all([
298-
getAuthToken({
299-
client_id: env.CLOUDFLARE_CLIENT_ID,
300-
client_secret: env.CLOUDFLARE_CLIENT_SECRET,
301-
redirect_uri: new URL('/oauth/callback', c.req.url).href,
302-
code,
303-
code_verifier: codeVerifier,
304-
oauthDomain: env.CLOUDFLARE_OAUTH_DOMAIN
305-
}),
306-
env.OAUTH_PROVIDER.createClient({
307-
clientId: oauthReqInfo.clientId,
308-
tokenEndpointAuthMethod: 'none'
309-
})
310-
])
330+
const { access_token, refresh_token } = await getAuthToken({
331+
client_id: env.CLOUDFLARE_CLIENT_ID,
332+
client_secret: env.CLOUDFLARE_CLIENT_SECRET,
333+
redirect_uri: new URL('/oauth/callback', c.req.url).href,
334+
code,
335+
code_verifier: codeVerifier,
336+
oauthDomain: env.CLOUDFLARE_OAUTH_DOMAIN
337+
})
311338

312339
const identity = await getCloudflareOAuthUser(access_token)
313340

@@ -328,6 +355,10 @@ export function createAuthHandlers() {
328355
} satisfies AuthProps
329356
})
330357

358+
if (!isAllowedOAuthRedirectUri(redirectTo)) {
359+
throw new OAuthError('server_error', 'Authorization produced an unsafe redirect URI')
360+
}
361+
331362
metrics.logEvent(new AuthUser({ userId: identity.user.id }))
332363

333364
return new Response(null, {
@@ -338,6 +369,7 @@ export function createAuthHandlers() {
338369
}
339370
})
340371
} catch (e) {
372+
if (e instanceof CimdFetchError) return cimdCallbackFailureResponse()
341373
metrics.logEvent(new AuthUser({ errorMessage: authErrorMessage('Callback Error', e) }))
342374
if (e instanceof OAuthError) return e.toHtmlResponse()
343375
const errorId = crypto.randomUUID()

src/auth/workers-oauth-utils.ts

Lines changed: 123 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export interface ScopeDefinition {
112112
*/
113113
export interface ApprovalDialogOptions {
114114
client: ClientInfo | null
115+
redirectUri: string
115116
server: {
116117
name: string
117118
logo?: string
@@ -139,6 +140,55 @@ function sanitizeHtml(unsafe: string): string {
139140
.replace(/'/g, ''')
140141
}
141142

143+
function hostnameFromUrl(value: string, requireHttps = false): string | undefined {
144+
try {
145+
const url = new URL(value)
146+
if (requireHttps && url.protocol !== 'https:') return undefined
147+
return url.hostname || undefined
148+
} catch {
149+
return undefined
150+
}
151+
}
152+
153+
function isLoopbackHostname(hostname: string): boolean {
154+
const normalized = hostname.toLowerCase()
155+
if (normalized === 'localhost' || normalized === '::1' || normalized === '[::1]') return true
156+
157+
const octets = normalized.split('.')
158+
return (
159+
octets.length === 4 &&
160+
octets[0] === '127' &&
161+
octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
162+
)
163+
}
164+
165+
/**
166+
* MCP requires authorization redirects to use HTTPS, except for loopback
167+
* callbacks used by native clients. Reject URL features that make the
168+
* destination ambiguous or are forbidden for OAuth redirect endpoints.
169+
*/
170+
export function isAllowedOAuthRedirectUri(value: string): boolean {
171+
if (value !== value.trim()) return false
172+
173+
try {
174+
const url = new URL(value)
175+
if (!url.hostname || url.username || url.password || url.hash) return false
176+
if (url.protocol === 'https:') return true
177+
return url.protocol === 'http:' && isLoopbackHostname(url.hostname)
178+
} catch {
179+
return false
180+
}
181+
}
182+
183+
function isLoopbackRedirectUri(value: string): boolean {
184+
try {
185+
const url = new URL(value)
186+
return url.protocol === 'http:' && isLoopbackHostname(url.hostname)
187+
} catch {
188+
return false
189+
}
190+
}
191+
142192
/**
143193
* Override labels for resources whose humanized form would mangle acronyms
144194
* or brand names (e.g. `url_scanner` → "Url scanner", `cfone` → "Cfone").
@@ -356,6 +406,7 @@ const ACTION_LABELS: Record<string, string> = {
356406
export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response {
357407
const {
358408
client,
409+
redirectUri,
359410
state,
360411
csrfToken,
361412
setCookie,
@@ -368,6 +419,12 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
368419

369420
const encodedState = encodeBase64Utf8(JSON.stringify(state))
370421
const clientName = client?.clientName ? sanitizeHtml(client.clientName) : 'Unknown MCP Client'
422+
const redirectHostname = hostnameFromUrl(redirectUri)
423+
if (!redirectHostname) {
424+
throw new OAuthError('invalid_request', 'Redirect URI must include a hostname')
425+
}
426+
const clientIdHostname = client ? hostnameFromUrl(client.clientId, true) : undefined
427+
const isLocalRedirect = isLoopbackRedirectUri(redirectUri)
371428
const requiredSet = new Set(requiredScopes)
372429
const categories = groupScopesByCategory(scopeDefinitions, requiredSet)
373430

@@ -509,7 +566,8 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
509566
.card-subtitle { font-size: 14px; color: var(--cf-text-subtle); letter-spacing: -0.16px; }
510567
.card-body { padding: 1.5rem 2rem; }
511568
512-
/* Client badge */
569+
/* Client identity */
570+
.client-identity { margin-bottom: 1.5rem; }
513571
.client-badge {
514572
display: inline-flex;
515573
align-items: center;
@@ -519,7 +577,7 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
519577
border-radius: var(--border-radius);
520578
font-size: 14px;
521579
font-weight: 500;
522-
margin-bottom: 1.5rem;
580+
margin-bottom: 0.75rem;
523581
border: 1px solid var(--cf-hairline);
524582
}
525583
.client-badge-icon {
@@ -532,6 +590,39 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
532590
justify-content: center;
533591
}
534592
.client-badge-icon svg { width: 12px; height: 12px; }
593+
.client-details {
594+
border: 1px solid var(--cf-hairline);
595+
border-radius: var(--border-radius);
596+
background: var(--cf-elevated);
597+
overflow: hidden;
598+
}
599+
.client-detail {
600+
display: flex;
601+
align-items: baseline;
602+
justify-content: space-between;
603+
gap: 1rem;
604+
padding: 0.55rem 0.85rem;
605+
}
606+
.client-detail + .client-detail { border-top: 1px solid var(--cf-hairline); }
607+
.client-detail-label { color: var(--cf-text-subtle); }
608+
.client-detail-hostname {
609+
color: var(--cf-text-default);
610+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
611+
font-size: 13px;
612+
font-weight: 600;
613+
overflow-wrap: anywhere;
614+
text-align: right;
615+
}
616+
.local-redirect-warning {
617+
margin-top: 0.75rem;
618+
padding: 0.75rem 0.85rem;
619+
border: 1px solid var(--cf-orange);
620+
border-radius: var(--border-radius);
621+
background: rgba(246, 130, 31, 0.08);
622+
color: var(--cf-text-default);
623+
font-size: 13px;
624+
line-height: 1.45;
625+
}
535626
536627
/* Section labels (match dashboard 'Edit policy' heading: 14px/500/subtle) */
537628
.section { margin-bottom: 1.5rem; }
@@ -946,13 +1037,36 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
9461037
</div>
9471038
9481039
<div class="card-body">
949-
<div class="client-badge">
950-
<span class="client-badge-icon">
951-
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5">
952-
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
953-
</svg>
954-
</span>
955-
${clientName}
1040+
<div class="client-identity">
1041+
<div class="client-badge">
1042+
<span class="client-badge-icon">
1043+
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5">
1044+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
1045+
</svg>
1046+
</span>
1047+
${clientName}
1048+
</div>
1049+
<div class="client-details" aria-label="OAuth client identity and redirect destination">
1050+
${
1051+
clientIdHostname
1052+
? `<div class="client-detail">
1053+
<span class="client-detail-label">Client ID hostname</span>
1054+
<strong class="client-detail-hostname">${sanitizeHtml(clientIdHostname)}</strong>
1055+
</div>`
1056+
: ''
1057+
}
1058+
<div class="client-detail">
1059+
<span class="client-detail-label">Redirect URI hostname</span>
1060+
<strong class="client-detail-hostname">${sanitizeHtml(redirectHostname)}</strong>
1061+
</div>
1062+
</div>
1063+
${
1064+
isLocalRedirect
1065+
? `<div class="local-redirect-warning" role="alert">
1066+
Local redirect: this client will receive the authorization code on this device. Only continue if you trust the application that opened this page.
1067+
</div>`
1068+
: ''
1069+
}
9561070
</div>
9571071
9581072
<form method="post" action="${new URL(request.url).pathname}" id="authForm">

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ export default {
3939
apiHandlers: {
4040
[MCP_ROUTE]: oauthMcpHandler
4141
},
42-
// @ts-ignore - Hono apps are compatible with ExportedHandler at runtime
4342
defaultHandler: createAuthHandlers(),
4443
authorizeEndpoint: '/authorize',
4544
tokenEndpoint: '/token',
4645
clientRegistrationEndpoint: '/register',
46+
clientIdMetadataDocumentEnabled: true,
4747
resolveExternalToken,
4848
tokenExchangeCallback: (options) =>
4949
handleTokenExchangeCallback(

0 commit comments

Comments
 (0)