Skip to content
Open
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
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@

When modifying MCP or OAuth functionality, **always check the latest published MCP specification**:

- **Specification:** https://modelcontextprotocol.io/specification/2025-11-25
- **Authorization section:** https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
- **Specification:** https://modelcontextprotocol.io/specification/2026-07-28
- **Authorization section:** https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization

## Repository structure

Expand All @@ -27,7 +27,8 @@ cloudflare-mcp/
│ ├── metrics.ts # Analytics Engine metrics (auth_user/tool_call)
│ ├── auth/
│ │ ├── types.ts # Auth props schemas (Zod discriminated union)
│ │ ├── api-token-mode.ts # Direct Cloudflare API token support
│ │ ├── api-token-mode.ts # Prefix classification & external resolver
│ │ ├── cloudflare-identity.ts # Owner-aware Cloudflare API identity probes
│ │ ├── cloudflare-auth.ts # PKCE & OAuth utilities
│ │ ├── oauth-handler.ts # OAuth authorization flow
│ │ ├── derived-oauth-scopes.ts # Canonical production OAuth scope API metadata
Expand Down Expand Up @@ -120,10 +121,12 @@ Code execution uses Cloudflare's Worker Loader API to dynamically create isolate

### Authentication

Two modes via Zod discriminated union (`AuthProps`):
Two credential paths produce the same Zod-validated `AuthProps` union:

- **OAuth mode** (default): Uses `@cloudflare/workers-oauth-provider` with PKCE. Supports both account-scoped and user-scoped tokens.
- **API token mode**: Direct Cloudflare API tokens bypass OAuth. Detection: OAuth tokens have 3 colon-separated parts; API tokens don't.
- **OAuth mode** (default): `@cloudflare/workers-oauth-provider` validates its own access tokens and restores encrypted Cloudflare OAuth props. The upstream authorization-code and refresh flows use PKCE and remain separate from direct credential resolution.
- **Direct Cloudflare credential mode**: after the provider's internal lookup misses, `resolveExternalToken` validates the bearer against the Cloudflare API and returns request-local props. Prefixes are owner hints: `cfat_` account tokens query only `/accounts`; `cfut_` user API tokens and `cfoat_` Cloudflare OAuth credentials query `/user` and `/accounts`; unprefixed legacy tokens retain response-based inference. Expected failures use the provider's `ExternalTokenError`, which generates RFC 6750/9728 `401`/`403` challenges and preserves retry guidance.

Validated direct-credential identity is cached by token hash in `OAUTH_KV`; provider-issued MCP tokens never invoke the external resolver.

The consent picker uses the production catalog returned by `GET /oauth/scopes` in every deployment. Staging may register additional scopes, but the MCP picker exposes them only after they reach production. Only the user, account, and offline-access OAuth bootstrap scopes sit outside the API catalog. Terraform registration must land before deploying picker additions. The app does not impose a scope-count cap.

Expand Down
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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"seed:prod": "tsx scripts/seed-r2.ts production"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.8.0",
"@cloudflare/workers-oauth-provider": "https://pkg.pr.new/cloudflare/workers-oauth-provider/@cloudflare/workers-oauth-provider@272",
"@modelcontextprotocol/server": "2.0.0",
"hono": "^4.12.25",
"zod": "^4.3.5"
Expand Down
216 changes: 91 additions & 125 deletions src/auth/api-token-mode.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,54 @@
import { env as cloudflareEnv } from 'cloudflare:workers'

import { getUserAndAccounts } from './oauth-handler'
import {
ExternalTokenError,
type ResolveExternalTokenInput,
type ResolveExternalTokenResult
} from '@cloudflare/workers-oauth-provider'

import {
CloudflareIdentitySchema,
resolveCloudflareCredential,
type CloudflareIdentity,
type CloudflareTokenOwner
} from './cloudflare-identity'
import { AUTH_PROPS_VERSION, type AuthProps } from './types'
import { OAuthError } from './workers-oauth-utils'

import { AUTH_PROPS_VERSION, type AccountSchema, type AuthProps, type UserSchema } from './types'

const env = cloudflareEnv as Env
const API_TOKEN_IDENTITY_CACHE_TTL_SECONDS = 2_592_000

type ApiTokenIdentity = {
user: UserSchema | null
accounts: AccountSchema[]
accountCount?: number
/** Prefixes are ownership hints; unprefixed legacy credentials remain supported. */
export function cloudflareTokenOwner(token: string): CloudflareTokenOwner {
if (token.startsWith('cfat_')) return 'account'
if (token.startsWith('cfut_') || token.startsWith('cfoat_')) return 'user'
return 'unknown'
}

async function hashApiToken(token: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}

async function getCachedApiTokenIdentity(token: string): Promise<ApiTokenIdentity> {
const tokenHash = await hashApiToken(token)
// v2 namespace: abandons pre-versioning entries that may hold a truncated
// first page of accounts rather than the full (or count-only) list.
const cacheKey = `api-token-identity:v2:${tokenHash}`
async function getCachedIdentity(
token: string,
tokenOwner: CloudflareTokenOwner,
kv: KVNamespace
): Promise<CloudflareIdentity> {
const cacheKey = `api-token-identity:v4:${await hashApiToken(token)}`

try {
const cached = await env.OAUTH_KV.get<ApiTokenIdentity>(cacheKey, 'json')
if (cached) {
return cached
const cachedValue = await kv.get(cacheKey, 'json')
if (cachedValue !== null) {
const cached = CloudflareIdentitySchema.safeParse(cachedValue)
if (cached.success) return cached.data
console.warn('api_token_identity_probe ignored invalid cache entry')
}
} catch (error) {
console.warn('api_token_identity_probe kv-cache read failed', error)
}

const identity = await getUserAndAccounts(token, 'api_token_identity_probe')
const identity = await resolveCloudflareCredential(token, tokenOwner)

try {
await env.OAUTH_KV.put(cacheKey, JSON.stringify(identity), {
await kv.put(cacheKey, JSON.stringify(identity), {
expirationTtl: API_TOKEN_IDENTITY_CACHE_TTL_SECONDS
})
} catch (error) {
Expand All @@ -46,117 +58,71 @@ async function getCachedApiTokenIdentity(token: string): Promise<ApiTokenIdentit
return identity
}

/**
* Check if the request contains a direct Cloudflare API token
* (as opposed to an OAuth token issued by workers-oauth-provider)
*
* OAuth tokens have format: userId:grantId:secret (3 colon-separated parts)
* Direct API tokens do NOT have this format
*/
export function isDirectApiToken(request: Request): boolean {
const authHeader = request.headers.get('Authorization')
if (!authHeader?.startsWith('Bearer ')) return false

const token = authHeader.slice(7)
const parts = token.split(':')

// OAuth tokens have exactly 3 parts separated by colons
return parts.length !== 3
}

/**
* Extract bearer token from request
*/
export function extractBearerToken(request: Request): string | null {
const authHeader = request.headers.get('Authorization')
if (!authHeader?.startsWith('Bearer ')) return null
return authHeader.slice(7)
}

/**
* Handle requests with direct Cloudflare API tokens
* Returns null if this is not an API token request (let OAuth handle it)
*/
export async function handleApiTokenRequest(
request: Request,
createMcpResponse: (props: AuthProps) => Promise<Response>
): Promise<Response | null> {
if (!isDirectApiToken(request)) {
return null
function externalTokenError(
error: OAuthError,
tokenOwner: CloudflareTokenOwner
): ExternalTokenError {
const common = { description: error.description, headers: error.headers }

switch (error.code) {
case 'invalid_token':
return new ExternalTokenError('invalid_token', { ...common, statusCode: 401 })
case 'insufficient_scope':
return new ExternalTokenError('insufficient_scope', {
...common,
statusCode: 403,
requiredScopes: tokenOwner === 'account' ? ['account:read'] : ['user:read', 'account:read']
})
case 'temporarily_unavailable':
return new ExternalTokenError('temporarily_unavailable', {
...common,
statusCode: error.statusCode
})
case 'server_error':
return new ExternalTokenError('server_error', {
...common,
statusCode: error.statusCode
})
default:
return new ExternalTokenError(error.statusCode >= 500 ? 'server_error' : 'invalid_token', {
...common,
statusCode: error.statusCode >= 500 ? 502 : 401
})
}
}

const token = extractBearerToken(request)
if (!token) {
return new Response(JSON.stringify({ error: 'Authorization header required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}

try {
const { user, accounts, accountCount } = await getCachedApiTokenIdentity(token)

// Account-scoped token
if (!user) {
if (accounts.length === 0) {
return new Response(JSON.stringify({ error: 'Invalid token' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
/** Convert a verified Cloudflare identity into request-local tool props. */
export function buildAuthProps(token: string, identity: CloudflareIdentity): AuthProps {
switch (identity.type) {
case 'account':
return {
type: 'account_token',
accessToken: token,
account: identity.account
}
if (accounts.length > 1) {
return new Response(
JSON.stringify({
error: 'Token has access to multiple accounts - use account_id parameter'
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
case 'user':
return {
type: 'user_token',
accessToken: token,
user: identity.user,
accounts: identity.accounts,
accountCount: identity.accountCount,
version: AUTH_PROPS_VERSION
}
const props = buildAuthProps(token, null, accounts)
return createMcpResponse(props)
}

// User token
const props = buildAuthProps(token, user, accounts, accountCount)
return createMcpResponse(props)
} catch (err) {
if (err instanceof OAuthError) {
return err.toResponse()
}
return new Response(JSON.stringify({ error: 'Token verification failed' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
}

/**
* Build AuthProps from verified token info
*/
export function buildAuthProps(
token: string,
user?: { id: string; email: string } | null,
accounts?: Array<{ id: string; name: string }>,
accountCount?: number
): AuthProps {
if (user) {
return {
type: 'user_token',
accessToken: token,
user,
accounts: accounts || [],
accountCount,
version: AUTH_PROPS_VERSION
}
}

if (!accounts || accounts.length === 0) {
throw new Error('Cannot build auth props: no user or account information')
}

return {
type: 'account_token',
accessToken: token,
account: accounts[0]
/** Resolve direct Cloudflare credentials after the provider's internal token lookup misses. */
export async function resolveExternalToken({
token,
env
}: ResolveExternalTokenInput<Env>): Promise<ResolveExternalTokenResult> {
const tokenOwner = cloudflareTokenOwner(token)
try {
const identity = await getCachedIdentity(token, tokenOwner, env.OAUTH_KV)
return { props: buildAuthProps(token, identity) }
} catch (error) {
if (error instanceof OAuthError) throw externalTokenError(error, tokenOwner)
throw error
}
}
Loading