Skip to content
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "wrangler dev",
"deploy": "wrangler deploy --env staging",
"deploy:prod": "wrangler deploy --env production",
"types": "wrangler types",
"types": "wrangler types --env-file .env_example",
"typecheck": "tsc --noEmit",
"lint": "oxlint src/",
"format": "oxfmt --write src/",
Expand All @@ -19,7 +19,7 @@
"seed:prod": "tsx scripts/seed-r2.ts production"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "0.10.0",
"@cloudflare/workers-oauth-provider": "0.10.2",
"@modelcontextprotocol/server": "2.0.0",
"hono": "^4.12.25",
"zod": "^4.3.5"
Expand Down
76 changes: 54 additions & 22 deletions src/auth/oauth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createOAuthState,
bindStateToSession,
generateCSRFProtection,
isAllowedOAuthRedirectUri,
parseRedirectApproval,
renderApprovalDialog,
renderErrorPage,
Expand Down Expand Up @@ -141,6 +142,30 @@ async function redirectToCloudflare(
})
}

function cimdUnavailableResponse(): Response {
return new OAuthError(
'temporarily_unavailable',
'Client metadata is temporarily unavailable. Please try again.',
503,
{ 'Retry-After': '30' }
).toHtmlResponse()
}

function cimdCallbackFailureResponse(): Response {
return new OAuthError(
'server_error',
'Client metadata could not be verified after sign-in. Restart authorization from your MCP client.',
500
).toHtmlResponse()
}

function invalidRedirectUriResponse(): Response {
return new OAuthError(
'invalid_request',
'Redirect URI must use HTTPS or a local loopback address'
).toHtmlResponse()
}

/**
* Create OAuth route handlers using patterns from workers-oauth-provider
*/
Expand All @@ -155,7 +180,7 @@ export function createAuthHandlers() {
oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw)
} catch (error) {
if (error instanceof AuthorizationError) {
if (!error.redirectUri) {
if (!error.redirectUri || !isAllowedOAuthRedirectUri(error.redirectUri)) {
return new OAuthError(error.code, error.description).toHtmlResponse()
}
const redirect = new URL(error.redirectUri)
Expand All @@ -169,15 +194,13 @@ export function createAuthHandlers() {
})
}
if (error instanceof CimdFetchError) {
return new OAuthError(
'temporarily_unavailable',
'Client metadata is temporarily unavailable. Please try again.',
503,
{ 'Retry-After': '30' }
).toHtmlResponse()
return cimdUnavailableResponse()
}
throw error
}
if (!isAllowedOAuthRedirectUri(oauthReqInfo.redirectUri)) {
return invalidRedirectUriResponse()
}
const defaultScopes = [...SCOPE_TEMPLATES[DEFAULT_TEMPLATE].scopes]
const requestedScopes = oauthReqInfo.scope ?? []
const unknownScopes = requestedScopes.filter((scope) => !ALLOWED_SCOPES.has(scope))
Expand All @@ -199,6 +222,7 @@ export function createAuthHandlers() {

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

const oauthReqInfo = state.oauthReqInfo as AuthRequest
if (!isAllowedOAuthRedirectUri(oauthReqInfo.redirectUri)) {
return invalidRedirectUriResponse()
}

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

if (!isAllowedOAuthRedirectUri(oauthReqInfo.redirectUri)) {
const response = invalidRedirectUriResponse()
response.headers.append('Set-Cookie', clearCookie)
return response
}

if (!oauthReqInfo.clientId) {
return new OAuthError('invalid_request', 'Invalid OAuth request info').toHtmlResponse()
}

// Exchange code for tokens and ensure client is registered
const [{ access_token, refresh_token }] = await Promise.all([
getAuthToken({
client_id: env.CLOUDFLARE_CLIENT_ID,
client_secret: env.CLOUDFLARE_CLIENT_SECRET,
redirect_uri: new URL('/oauth/callback', c.req.url).href,
code,
code_verifier: codeVerifier,
oauthDomain: env.CLOUDFLARE_OAUTH_DOMAIN
}),
env.OAUTH_PROVIDER.createClient({
clientId: oauthReqInfo.clientId,
tokenEndpointAuthMethod: 'none'
})
])
const { access_token, refresh_token } = await getAuthToken({
client_id: env.CLOUDFLARE_CLIENT_ID,
client_secret: env.CLOUDFLARE_CLIENT_SECRET,
redirect_uri: new URL('/oauth/callback', c.req.url).href,
code,
code_verifier: codeVerifier,
oauthDomain: env.CLOUDFLARE_OAUTH_DOMAIN
})

const identity = await getCloudflareOAuthUser(access_token)

Expand All @@ -328,6 +355,10 @@ export function createAuthHandlers() {
} satisfies AuthProps
})

if (!isAllowedOAuthRedirectUri(redirectTo)) {
throw new OAuthError('server_error', 'Authorization produced an unsafe redirect URI')
}

metrics.logEvent(new AuthUser({ userId: identity.user.id }))

return new Response(null, {
Expand All @@ -338,6 +369,7 @@ export function createAuthHandlers() {
}
})
} catch (e) {
if (e instanceof CimdFetchError) return cimdCallbackFailureResponse()
metrics.logEvent(new AuthUser({ errorMessage: authErrorMessage('Callback Error', e) }))
if (e instanceof OAuthError) return e.toHtmlResponse()
const errorId = crypto.randomUUID()
Expand Down
132 changes: 123 additions & 9 deletions src/auth/workers-oauth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export interface ScopeDefinition {
*/
export interface ApprovalDialogOptions {
client: ClientInfo | null
redirectUri: string
server: {
name: string
logo?: string
Expand Down Expand Up @@ -139,6 +140,55 @@ function sanitizeHtml(unsafe: string): string {
.replace(/'/g, ''')
}

function hostnameFromUrl(value: string, requireHttps = false): string | undefined {
try {
const url = new URL(value)
if (requireHttps && url.protocol !== 'https:') return undefined
return url.hostname || undefined
} catch {
return undefined
}
}

function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase()
if (normalized === 'localhost' || normalized === '::1' || normalized === '[::1]') return true

const octets = normalized.split('.')
return (
octets.length === 4 &&
octets[0] === '127' &&
octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
)
}

/**
* MCP requires authorization redirects to use HTTPS, except for loopback
* callbacks used by native clients. Reject URL features that make the
* destination ambiguous or are forbidden for OAuth redirect endpoints.
*/
export function isAllowedOAuthRedirectUri(value: string): boolean {
if (value !== value.trim()) return false

try {
const url = new URL(value)
if (!url.hostname || url.username || url.password || url.hash) return false
if (url.protocol === 'https:') return true
return url.protocol === 'http:' && isLoopbackHostname(url.hostname)
} catch {
return false
}
}

function isLoopbackRedirectUri(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'http:' && isLoopbackHostname(url.hostname)
} catch {
return false
}
}

/**
* Override labels for resources whose humanized form would mangle acronyms
* or brand names (e.g. `url_scanner` → "Url scanner", `cfone` → "Cfone").
Expand Down Expand Up @@ -356,6 +406,7 @@ const ACTION_LABELS: Record<string, string> = {
export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response {
const {
client,
redirectUri,
state,
csrfToken,
setCookie,
Expand All @@ -368,6 +419,12 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp

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

Expand Down Expand Up @@ -509,7 +566,8 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
.card-subtitle { font-size: 14px; color: var(--cf-text-subtle); letter-spacing: -0.16px; }
.card-body { padding: 1.5rem 2rem; }

/* Client badge */
/* Client identity */
.client-identity { margin-bottom: 1.5rem; }
.client-badge {
display: inline-flex;
align-items: center;
Expand All @@ -519,7 +577,7 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
border-radius: var(--border-radius);
font-size: 14px;
font-weight: 500;
margin-bottom: 1.5rem;
margin-bottom: 0.75rem;
border: 1px solid var(--cf-hairline);
}
.client-badge-icon {
Expand All @@ -532,6 +590,39 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
justify-content: center;
}
.client-badge-icon svg { width: 12px; height: 12px; }
.client-details {
border: 1px solid var(--cf-hairline);
border-radius: var(--border-radius);
background: var(--cf-elevated);
overflow: hidden;
}
.client-detail {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.55rem 0.85rem;
}
.client-detail + .client-detail { border-top: 1px solid var(--cf-hairline); }
.client-detail-label { color: var(--cf-text-subtle); }
.client-detail-hostname {
color: var(--cf-text-default);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
text-align: right;
}
.local-redirect-warning {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--cf-orange);
border-radius: var(--border-radius);
background: rgba(246, 130, 31, 0.08);
color: var(--cf-text-default);
font-size: 13px;
line-height: 1.45;
}

/* Section labels (match dashboard 'Edit policy' heading: 14px/500/subtle) */
.section { margin-bottom: 1.5rem; }
Expand Down Expand Up @@ -946,13 +1037,36 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
</div>

<div class="card-body">
<div class="client-badge">
<span class="client-badge-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</span>
${clientName}
<div class="client-identity">
<div class="client-badge">
<span class="client-badge-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</span>
${clientName}
</div>
<div class="client-details" aria-label="OAuth client identity and redirect destination">
${
clientIdHostname
? `<div class="client-detail">
<span class="client-detail-label">Client ID hostname</span>
<strong class="client-detail-hostname">${sanitizeHtml(clientIdHostname)}</strong>
</div>`
: ''
}
<div class="client-detail">
<span class="client-detail-label">Redirect URI hostname</span>
<strong class="client-detail-hostname">${sanitizeHtml(redirectHostname)}</strong>
</div>
</div>
${
isLocalRedirect
? `<div class="local-redirect-warning" role="alert">
Local redirect: this client will receive the authorization code on this device. Only continue if you trust the application that opened this page.
</div>`
: ''
}
</div>

<form method="post" action="${new URL(request.url).pathname}" id="authForm">
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ export default {
apiHandlers: {
[MCP_ROUTE]: oauthMcpHandler
},
// @ts-ignore - Hono apps are compatible with ExportedHandler at runtime
defaultHandler: createAuthHandlers(),
authorizeEndpoint: '/authorize',
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
clientIdMetadataDocumentEnabled: true,
resolveExternalToken,
tokenExchangeCallback: (options) =>
handleTokenExchangeCallback(
Expand Down
Loading