关于 MCP OAuth Cloudflare
MCP OAuth Cloudflare 是在 Cloudflare Workers 上为 MCP 服务构建 OAuth 身份验证的技能指南,适合需要用户身份和第三方 Google 登录的开发者。它介绍双重 OAuth 角色、动态客户端注册及相关安全措施。
主要功能
- 01在 Worker 上实现 OAuth
- 02支持动态客户端注册
- 03集成 Google 登录与用户资料
- 04加密存储上游访问令牌
- 05CSRF、状态与会话绑定保护
- 06OAuth 与 Bearer Token 并存
适用场景
- 01为 MCP 服务添加用户认证
- 02将 MCP 部署到 Claude.ai
- 03在工具处理中使用用户资料
- 04用 OAuth 替代静态认证令牌
MCP 相关
指导在 Cloudflare Workers 上构建带 OAuth 的 MCP 服务,供 MCP 客户端认证连接。说明服务如何面向客户端签发自身令牌,并将 OAuth 用户信息提供给工具处理程序。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jezweb/ claude-skills --skill mcp-oauth-cloudflare安装后在 Claude Code 中输入/mcp-oauth-cloudflare确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.md.claude-plugin/plugin.jsonreferences/oauth-flow.mdrules/mcp-oauth.mdtemplates/env.d.tsindex.tspackage.jsontemplates/oauth/google-handler.tsutils.tsworkers-oauth-utils.ts详细介绍
MCP OAuth Cloudflare
适用于 Cloudflare Workers 上 MCP 服务的生产就绪型 OAuth 身份验证。
适用场景
- 构建需要用户身份验证的 MCP 服务
- 将 MCP 部署到 Claude.ai(需要动态客户端注册)
- 用 OAuth 替代静态身份验证令牌,以增强安全性
- 为 MCP 服务添加 Google 登录
- 需要在 MCP 工具处理程序中获取用户上下文(邮箱、姓名、头像)
不适用场景
- 可以接受使用令牌的内部/私有 MCP 服务
- 不涉及用户特定数据的 MCP 服务
- 仅在本地进行的 MCP 开发(为简化流程,可使用令牌)
架构概览
双重 OAuth 角色模式
使用第三方 OAuth 提供方(如 Google)时,MCP 服务既是(面向上游服务的)OAuth 客户端,也是(面向 MCP 客户端的)OAuth 服务端。Worker 会:
- 在 Workers KV 中存储加密的访问令牌
- 向客户端签发自己的令牌
- 由
workers-oauth-provider处理规范合规性
重要:MCP 服务会生成并签发自己的令牌,而不是直接传递第三方令牌。这对安全性和规范合规至关重要。
┌─────────────────────────────────────────────────────────────────────┐
│ Cloudflare Worker │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌──────────────────────────────────┐ │
│ │ OAuthProvider │ │ McpAgent (Durable Object) │ │
│ │ ───────────────── │ │ ──────────────────────────── │ │
│ │ /register (DCR) │ │ MCP Tools with user props: │ │
│ │ /authorize │─────▶│ - this.props.email │ │
│ │ /token │ │ - this.props.id │ │
│ │ /mcp │ │ - this.props.accessToken │ │
│ └─────────────────────┘ └──────────────────────────────────┘ │
│ │ │
│ │ OAuth Flow │
│ ▼ │
│ ┌─────────────────────┐ ┌──────────────────────────────────┐ │
│ │ Google Handler │ │ KV Namespace (OAUTH_KV) │ │
│ │ ───────────────── │ │ ──────────────────────────── │ │
│ │ /authorize (GET) │─────▶│ oauth:state:{token} → AuthReq │ │
│ │ /authorize (POST) │ │ TTL: 10 minutes │ │
│ │ /callback │ └──────────────────────────────────┘ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
快速开始
1. 安装依赖项
npm install @cloudflare/workers-oauth-provider agents @modelcontextprotocol/sdk hono zod
2. 创建 OAuth 目录结构
src/
├── index.ts # Main entry with OAuthProvider
└── oauth/
├── google-handler.ts # OAuth routes (/authorize, /callback)
├── utils.ts # Google token exchange & user info
└── workers-oauth-utils.ts # CSRF, state validation, approval UI
3. 配置 wrangler.jsonc
{
"name": "my-mcp-server",
"main": "src/index.ts",
"compatibility_flags": ["nodejs_compat"],
// KV for OAuth state storage
"kv_namespaces": [
{
"binding": "OAUTH_KV",
"id": "YOUR_KV_NAMESPACE_ID"
}
],
// Durable Objects for MCP sessions
"durable_objects": {
"bindings": [
{
"class_name": "MyMcpServer",
"name": "MCP_OBJECT"
}
]
},
"migrations": [
{
"new_sqlite_classes": ["MyMcpServer"],
"tag": "v1"
}
]
}
4. 设置机密
# Google OAuth credentials (from console.cloud.google.com)
echo "YOUR_GOOGLE_CLIENT_ID" | npx wrangler secret put GOOGLE_CLIENT_ID
echo "YOUR_GOOGLE_CLIENT_SECRET" | npx wrangler secret put GOOGLE_CLIENT_SECRET
# Cookie encryption key (32+ chars)
python3 -c "import secrets; print(secrets.token_urlsafe(32))" | npx wrangler secret put COOKIE_ENCRYPTION_KEY
# Optional: Custom Google OAuth scopes (default: 'openid email profile')
# See "Common Google Scopes" section below for scope recipes
echo "openid email profile https://www.googleapis.com/auth/drive" | npx wrangler secret put GOOGLE_SCOPES
# Deploy to activate secrets
npx wrangler deploy
5. 类型定义(可选,但建议配置)
将 templates/env.d.ts 复制到 src/env.d.ts,以支持 TypeScript 类型:
interface Env {
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
COOKIE_ENCRYPTION_KEY: string;
GOOGLE_SCOPES?: string; // Optional: Override default scopes
OAUTH_KV: KVNamespace;
MCP_OBJECT: DurableObjectNamespace;
}
实现指南
主入口点(index.ts)
import OAuthProvider from '@cloudflare/workers-oauth-provider';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpAgent } from 'agents/mcp';
import { z } from 'zod';
import { GoogleHandler } from './oauth/google-handler';
// Props from OAuth - user info stored in token
type Props = {
id: string;
email: string;
name: string;
picture?: string;
accessToken: string;
refreshToken?: string; // Available on first auth with access_type=offline
};
export class MyMcpServer extends McpAgent<Env, Record<string, never>, Props> {
server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0',
});
async init() {
// Register tools - user info available via this.props
this.server.tool(
'my_tool',
'Tool description',
{ param: z.string() },
async (args) => {
// Access authenticated user
const userEmail = this.props?.email;
console.log(`Tool called by: ${userEmail}`);
return {
content: [{ type: 'text', text: 'Result' }]
};
}
);
}
}
// Wrap with OAuth provider
export default new OAuthProvider({
apiHandlers: {
'/sse': MyMcpServer.serveSSE('/sse'),
'/mcp': MyMcpServer.serve('/mcp'),
},
authorizeEndpoint: '/authorize',
clientRegistrationEndpoint: '/register',
defaultHandler: GoogleHandler as any,
tokenEndpoint: '/token',
});
Google 处理程序(oauth/google-handler.ts)
import { env } from 'cloudflare:workers';
import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider';
import { Hono } from 'hono';
import { fetchUpstreamAuthToken, fetchGoogleUserInfo, getUpstreamAuthorizeUrl, type Props } from './utils';
import {
addApprovedClient,
bindStateToSession,
createOAuthState,
generateCSRFProtection,
isClientApproved,
OAuthError,
renderApprovalDialog,
validateCSRFToken,
validateOAuthState,
} from './workers-oauth-utils';
const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>();
// GET /authorize - Show approval dialog or redirect to Google
app.get('/authorize', async (c) => {
const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
const { clientId } = oauthReqInfo;
if (!clientId) return c.text('Invalid request', 400);
// Skip approval if client already approved
if (await isClientApproved(c.req.raw, clientId, env.COOKIE_ENCRYPTION_KEY)) {
const { stateToken } = await createOAuthState(oauthReqInfo, c.env.OAUTH_KV);
const { setCookie } = await bindStateToSession(stateToken);
return redirectToGoogle(c.req.raw, stateToken, { 'Set-Cookie': setCookie });
}
// Show approval dialog with CSRF protection
const { token: csrfToken, setCookie } = generateCSRFProtection();
return renderApprovalDialog(c.req.raw, {
client: await c.env.OAUTH_PROVIDER.lookupClient(clientId),
csrfToken,
server: {
name: 'My MCP Server',
description: 'Description of your server',
logo: 'https://example.com/logo.png',
},
setCookie,
state: { oauthReqInfo },
});
});
// POST /authorize - Process approval form
app.post('/authorize', async (c) => {
try {
const formData = await c.req.raw.formData();
validateCSRFToken(formData, c.req.raw);
const encodedState = formData.get('state') as string;
const state = JSON.parse(atob(encodedState));
// Add to approved clients
const approvedCookie = await addApprovedClient(
c.req.raw, state.oauthReqInfo.clientId, c.env.COOKIE_ENCRYPTION_KEY
);
// Create state and redirect
const { stateToken } = await createOAuthState(state.oauthReqInfo, c.env.OAUTH_KV);
const { setCookie } = await bindStateToSession(stateToken);
const headers = new Headers();
headers.append('Set-Cookie', approvedCookie);
headers.append('Set-Cookie', setCookie);
return redirectToGoogle(c.req.raw, stateToken, Object.fromEntries(headers));
} catch (error: any) {
if (error instanceof OAuthError) return error.toResponse();
return c.text(`Error: ${error.message}`, 500);
}
});
// GET /callback - Handle Google OAuth callback
app.get('/callback', async (c) => {
const { oauthReqInfo, clearCookie } = await validateOAuthState(c.req.raw, c.env.OAUTH_KV);
// Exchange code for token
const [accessToken, err] = await fetchUpstreamAuthToken({
client_id: c.env.GOOGLE_CLIENT_ID,
client_secret: c.env.GOOGLE_CLIENT_SECRET,
code: c.req.query('code'),
redirect_uri: new URL('/callback', c.req.url).href,
upstream_url: 'https://oauth2.googleapis.com/token',
});
if (err) return err;
// Get user info
const user = await fetchGoogleUserInfo(accessToken);
if (!user) return c.text('Failed to fetch user info', 500);
// Complete authorization
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
props: {
accessToken,
email: user.email,
id: user.id,
name: user.name,
picture: user.picture,
} as Props,
request: oauthReqInfo,
scope: oauthReqInfo.scope,
userId: user.id,
});
return new Response(null, {
status: 302,
headers: { Location: redirectTo, 'Set-Cookie': clearCookie },
});
});
async function redirectToGoogle(request: Request, stateToken: string, headers: Record<string, string> = {}) {
// Scopes configurable via GOOGLE_SCOPES env var (see "Common Google Scopes" section)
const scopes = env.GOOGLE_SCOPES || 'openid email profile';
return new Response(null, {
status: 302,
headers: {
...headers,
location: getUpstreamAuthorizeUrl({
client_id: env.GOOGLE_CLIENT_ID,
redirect_uri: new URL('/callback', request.url).href,
scope: scopes,
state: stateToken,
upstream_url: 'https://accounts.google.com/o/oauth2/v2/auth',
}),
},
});
}
export { app as GoogleHandler };
OAuth 流程图
User clicks "Connect" in Claude.ai
│
▼
┌─────────────────────────────────┐
│ 1. /register (DCR) │ ◄── Claude.ai registers as client
│ Returns client credentials │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 2. GET /authorize │
│ - Check approved clients │
│ - Show approval dialog │
│ - Generate CSRF token │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 3. POST /authorize │
│ - Validate CSRF │
│ - Create state in KV │
│ - Redirect to Google │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 4. Google OAuth │
│ - User signs in │
│ - Consents to scopes │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 5. GET /callback │
│ - Validate state │
│ - Exchange code for token │
│ - Fetch user info │
│ - Complete authorization │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 6. User props available │
│ this.props.email │
│ this.props.id │
│ this.props.accessToken │
└─────────────────────────────────┘
安全功能
CSRF 防护
// Generate CSRF token with HttpOnly cookie
export function generateCSRFProtection(): CSRFProtectionResult {
const token = crypto.randomUUID();
const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
return { token, setCookie };
}
状态验证(防止重放攻击)
// Create one-time-use state in KV
export async function createOAuthState(oauthReqInfo: AuthRequest, kv: KVNamespace) {
const stateToken = crypto.randomUUID();
await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {
expirationTtl: 600, // 10 minutes
});
return { stateToken };
}
会话绑定(防止令牌被盗)
// Bind state to browser session via SHA-256 hash
export async function bindStateToSession(stateToken: string) {
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stateToken));
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0')).join('');
const setCookie = `__Host-CONSENTED_STATE=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
return { setCookie };
}
客户端授权缓存(减少重复授权)
// HMAC-signed cookie tracks approved clients (30-day TTL)
export async function addApprovedClient(request: Request, clientId: string, cookieSecret: string) {
const existing = await getApprovedClientsFromCookie(request, cookieSecret) || [];
const updated = [...new Set([...existing, clientId])];
const payload = JSON.stringify(updated);
const signature = await signData(payload, cookieSecret);
return `__Host-APPROVED_CLIENTS=${signature}.${btoa(payload)}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`;
}
PKCE 方法(当前限制)
注意:该库目前同时接受 plain 和 S256 PKCE 方法。没有配置选项可以强制仅使用 S256,而 S256 是 OAuth 2.1 推荐的方法。
安全注意事项:为获得最高安全性,你可能需要仅允许 S256。该功能请求已记录在 GitHub Issue #113 中。
替代方案:在此项变为可配置之前,该库会接受这两种方法。现代 OAuth 客户端(包括 Claude.ai)默认使用 S256。
Google Cloud Console 设置
- 前往 console.cloud.google.com
- 创建新项目或选择现有项目
- 前往 APIs & Services → Credentials
- 点击 Create Credentials → OAuth client ID
- 应用类型:Web application
- 添加授权重定向 URI:
https://your-worker.workers.dev/callback - 复制 Client ID 和 Client Secret
常用 Google 范围
通过 GOOGLE_SCOPES 环境变量配置范围,或修改 redirectToGoogle 函数。
| 用例 | 范围 |
|---|---|
| 基本用户信息(默认) | openid email profile |
| Google Drive(完全访问权限) | openid email profile https://www.googleapis.com/auth/drive |
| Google Drive(仅文件级访问权限) | openid email profile https://www.googleapis.com/auth/drive.file |
| Google Docs | openid email profile https://www.googleapis.com/auth/documents |
| Google Docs + Drive | openid email profile https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/documents |
| Gmail(读取/发送) | openid email profile https://www.googleapis.com/auth/gmail.modify |
| Gmail(只读) | openid email profile https://www.googleapis.com/auth/gmail.readonly |
| Google Calendar | openid email profile https://www.googleapis.com/auth/calendar |
| Google Sheets | openid email profile https://www.googleapis.com/auth/spreadsheets |
| Google Slides | openid email profile https://www.googleapis.com/auth/presentations |
| YouTube Data | openid email profile https://www.googleapis.com/auth/youtube |
设置范围:
# Option 1: Via environment variable (recommended for flexibility)
echo "openid email profile https://www.googleapis.com/auth/drive" | npx wrangler secret put GOOGLE_SCOPES
# Option 2: In wrangler.jsonc (for non-sensitive scopes)
{
"vars": {
"GOOGLE_SCOPES": "openid email profile https://www.googleapis.com/auth/drive"
}
}
重要说明:
- 始终包含
openid email profile,这是识别用户所必需的 - 必须在 Google Cloud Console 中启用其他范围(APIs & Services → Library)
- 某些范围在生产环境中使用时需要通过 OAuth 同意屏幕验证
drive.file只能访问应用创建的文件,或用户明确使用该应用打开的文件
刷新令牌生命周期(v0.2.0+)
对于长时会话(Google APIs、Gmail、Drive),你需要刷新令牌。
设计决策:两个有效的刷新令牌
注意:@cloudflare/workers-oauth-provider 实现了非标准的刷新令牌轮换策略。在任何时刻,一个授权都可能有两个有效的刷新令牌。客户端使用其中一个时,另一个会失效,并生成一个新的令牌。
与 OAuth 2.1 的差异:OAuth 2.1 要求公共客户端使用一次性刷新令牌。然而,该库的作者认为一次性令牌存在根本缺陷,因为它假设每个刷新请求都能无错误地完成。在现实中,网络错误或软件故障可能导致客户端无法保存新的刷新令牌。
安全权衡:允许使用前一个刷新令牌会禁用重放攻击检测。对于机密客户端(大多数 MCP 服务),这符合 OAuth 2.1。对于公共客户端,如有需要,请考虑采用更严格的轮换策略。
来源:GitHub Issue #43,记录于 README
请求刷新令牌
在授权 URL 中添加 access_type=offline:
// In google-handler.ts, redirectToGoogle function
googleAuthUrl.searchParams.set('access_type', 'offline');
googleAuthUrl.searchParams.set('prompt', 'consent'); // Forces new refresh token
适用 access_type=offline 的场景:
- MCP 服务需要在初始授权后调用 Google APIs
- 长时会话(后台任务、计划作业)
- 用户数据同步
适用 access_type=online(默认)的场景:
- 仅需进行简单的用户身份识别
- 除初始授权外,不调用 API
- 短时会话(管理员登录、一次性操作)
存储刷新令牌
在你的 Props 类型中加密存储:
export type Props = {
id: string;
email: string;
name: string;
picture?: string;
accessToken: string;
refreshToken?: string; // Store when received
tokenExpiresAt?: number; // Track expiration
};
刷新过期令牌
export async function refreshAccessToken(
client_id: string,
client_secret: string,
refresh_token: string
): Promise<{ accessToken: string; expiresAt: number } | null> {
const resp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id,
client_secret,
refresh_token,
grant_type: 'refresh_token',
}).toString(),
});
if (!resp.ok) return null; // Token revoked, requires re-auth
const body = await resp.json();
return {
accessToken: body.access_token,
expiresAt: Date.now() + (body.expires_in * 1000),
};
}
刷新令牌失效的情况
- 用户在 https://myaccount.google.com/permissions 撤销访问权限
- 用户更改密码(如果使用某些范围)
- 令牌连续 6 个月以上未使用
- OAuth 应用凭据重新生成
妥善处理:捕获刷新失败,并重定向用户重新授权。
Bearer 令牌与 OAuth 共存
现代 MCP 服务同时支持 OAuth(Claude.ai)和 Bearer 令牌(命令行工具、ElevenLabs):
// In your main fetch handler
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const authHeader = request.headers.get('Authorization');
const url = new URL(request.url);
// Check for Bearer token auth on MCP endpoints
if (env.AUTH_TOKEN && authHeader?.startsWith('Bearer ') &&
(url.pathname === '/sse' || url.pathname === '/mcp')) {
const token = authHeader.slice(7);
if (token === env.AUTH_TOKEN) {
// Programmatic access (CLI, ElevenLabs)
const headerAuthCtx = { ...ctx, props: { source: 'bearer' } };
return mcpHandler.fetch(request, env, headerAuthCtx);
}
// NOT env.AUTH_TOKEN - fall through to OAuth provider
// (it may be an OAuth token from Claude.ai)
}
// OAuth flow for web clients
return oauthProvider.fetch(request, env, ctx);
}
};
关键模式:不匹配的 Bearer 令牌必须继续交由 OAuth 提供方处理,而不是返回 401。Claude.ai 的 OAuth 令牌也会作为 Bearer 令牌发送。
添加 AUTH_TOKEN 密钥:
python3 -c "import secrets; print(secrets.token_urlsafe(32))" | npx wrangler secret put AUTH_TOKEN
npx wrangler deploy # Required to activate
常见问题
“Invalid state” 错误
原因:状态已过期(超过 10 分钟)或 KV 查询失败
解决方法:重新启动 OAuth 流程——状态只能使用一次
“CSRF token mismatch”
原因:提交表单时没有匹配的 Cookie
解决方法:确保已启用 Cookie,且未被浏览器扩展程序拦截
Claude.ai 显示“Connection Failed”
原因:缺少 DCR 端点或响应无效
解决方法:确保在 OAuthProvider 配置中设置了 clientRegistrationEndpoint: '/register'
用户属性未定义
原因:OAuth 完成前访问了 this.props
解决方法:访问用户数据前,先检查 if (this.props)
OAuth 与 Auth Token 对比
| 方面 | Auth Token | OAuth |
|---|---|---|
| Token 共享 | 手动(有风险) | 自动 |
| 用户同意 | 无 | 明确批准 |
| 过期 | 手动 | 自动刷新 |
| 撤销 | 不内置 | 用户可以断开连接 |
| 范围 | 全部或无 | 精细控制 |
| 兼容 Claude.ai | 否(需要 DCR) | 是 |
必需的 Secret
| Secret | 用途 | 生成方式 |
|---|---|---|
GOOGLE_CLIENT_ID | OAuth 应用 ID | Google Cloud Console |
GOOGLE_CLIENT_SECRET | OAuth 应用 Secret | Google Cloud Console |
COOKIE_ENCRYPTION_KEY | 对批准 Cookie 签名 | secrets.token_urlsafe(32) |
GOOGLE_SCOPES(可选) | 覆盖默认 OAuth 范围 | 参见“常见 Google 范围”部分 |
Token 效率
| 无 Skill | 使用 Skill | 节省 |
|---|---|---|
| 约 20k tokens,尝试 3–5 次 | 约 6k tokens,一次成功 | 约 70% |
已知问题预防
此 Skill 可预防 9 个已记录的问题。
问题 #1:RFC 8707 受众验证在包含路径组件时失败(v0.1.0+)
错误:invalid_token: Token audience does not match resource server
来源:GitHub Issue #108
影响范围:使用带路径的 RFC 8707 资源指示符时的 v0.1.0+(例如,ChatGPT 自定义连接器)
发生原因:resourceServer 仅使用源站(https://example.com)计算,但 RFC 8707 建议使用包含路径的完整 URL(https://example.com/api)。当以下内容不一致时,audienceMatches 中的严格相等检查会失败:
- Token 受众:
https://example.com/api(来自resource参数) - 资源服务器:
https://example.com(仅根据请求 URL 的源站计算)
预防方法:
如果使用带路径的 RFC 8707 资源指示符,请将库 vendoring,并修改 handleApiRequest:
// Workaround: Include pathname in resourceServer computation
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
或者,在上游修复之前,避免在资源指示符中使用路径。
问题 #2:Claude.ai 客户端无法连接(v0.2.2)
错误:Claude.ai MCP 客户端在 OAuth 流程中无法连接 来源:GitHub Issue #133 影响范围:v0.2.2、Claude.ai MCP 客户端
发生原因:audienceMatches 函数中有一个“/”字符,导致 Claude.ai 无法连接。这可能与问题 #1(RFC 8707 路径处理)有关。
预防方法:关注 Issue #133,了解最新进展。可能需要更新库,或将库 vendoring 并应用修复。
问题 #3:重新授权后属性未更新(上游 OAuth 过期)
错误:上游 OAuth 提供商未提供刷新令牌时,陷入无限重新授权循环 来源:GitHub 问题 #34 影响范围:使用不提供刷新令牌的上游 OAuth 提供商的 MCP 服务
原因:在 tokenExchangeCallback 中抛出 invalid_grant 会触发重新授权,但 completeAuthorization() 不会更新属性。过时的属性会导致反复授权失败,直到 OAuth 客户端重启。
预防措施:
如果上游 OAuth 提供商不签发刷新令牌:
- 实施备用策略(存储令牌过期时间,并在过期前重新授权)
- 关注 问题 #34,等待官方修复
- 将客户端重启作为临时解决方法
有问题的模式:
tokenExchangeCallback: async (options) => {
if (options.grantType === "refresh_token") {
const response = await fetchNewToken(options.props.accessToken);
if (!response.ok) {
// Triggers re-auth but props remain stale
throw new Error(JSON.stringify({
error: "invalid_grant",
error_description: "access token expired"
}));
}
}
}
问题 #4:生产环境中的重定向 URI 不匹配(开发环境与生产环境的行为差异)
错误:Invalid redirect URI. The redirect URI provided does not match any registered URI for this client
来源:GitHub 问题 #29(来自社区)
影响范围:生产环境部署;在本地 wrangler dev 中运行正常
原因:动态客户端注册(DCR)在本地环境和生产环境中的行为不同。DCR 期间会自动注册重定向 URI,但在生产环境中某个环节会失败。根本原因尚不明确,但使用 MCP 客户端(Cursor、Windsurf、PyCharm)的多个用户都受到影响。
预防措施:
- 尽可能显式注册重定向 URI,而不是依赖 DCR 自动注册
- 部署到 Claude.ai 前,在生产环境中测试 OAuth 流程
- 关注 问题 #29,等待问题解决
问题 #5:CSRF 漏洞
错误:会话劫持、OAuth 回调拦截 预防措施:使用带有 SameSite 属性的 HttpOnly Cookie
const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
问题 #6:状态重放攻击
错误:OAuth 状态在多次授权尝试中重复使用 预防措施:使用仅可用一次的 KV 状态,并设置 10 分钟的 TTL
await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {
expirationTtl: 600,
});
问题 #7:通过会话固定窃取令牌
错误:OAuth 状态被窃取,并在不同的浏览器会话中使用 预防措施:通过 SHA-256 哈希绑定会话
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stateToken));
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0')).join('');
问题 #8:缺少动态客户端注册(DCR)
错误:尝试连接时,Claude.ai 显示“连接失败”
预防措施:OAuthProvider 通过 clientRegistrationEndpoint: '/register' 自动处理 DCR
问题 #9:Cookie 篡改
错误:修改已批准客户端列表以绕过授权 预防措施:对批准 Cookie 使用 HMAC 签名
const signature = await signData(payload, cookieSecret);
const cookie = `__Host-APPROVED_CLIENTS=${signature}.${btoa(payload)}`;
版本历史与重大变更
v0.2.2(2025-12-20)- 当前版本
新功能:
- 支持客户端 ID 元数据文档(CIMD)——允许将 HTTPS URL 用作
client_id值 - 符合新的 MCP 授权规范:https://modelcontextprotocol.io/specification/draft/basic/authorization
迁移:没有重大变更。CIMD 支持是增量添加的。
v0.1.0(2025-11-07)
新功能:
- 根据 RFC 7519 验证 OAuth 令牌的受众
重大变更:
- 令牌现在必须包含正确的
aud声明 - 未进行受众验证的现有部署可能会受到影响
- RFC 8707 路径处理错误,请参阅问题 #108
迁移:
- 确保所有令牌都包含正确的
aud声明 - 彻底测试受众验证
- 如果使用带路径的资源指示符,请采用问题 #108 中的解决方法
v0.0.x(2025 年 11 月之前)
初始版本尚未进行受众验证。
已规避的问题
- RFC 8707 受众路径错误 - 绕过路径组件验证
- Claude.ai 连接失败 - 跟踪已知问题
- 重新认证循环 - 处理属性更新
- 生产环境重定向 URI 不匹配 - 测试并显式注册
- CSRF 漏洞 - 使用带有 SameSite 的 HttpOnly Cookie
- 状态重放攻击 - 使用一次性 KV 状态
- 令牌被盗 - 通过 SHA-256 绑定会话
- 缺少 DCR - OAuthProvider 自动处理
- Cookie 篡改 - HMAC 签名
参考资料
- Cloudflare Workers OAuth Provider
- GitHub - cloudflare/workers-oauth-provider
- MCP 规范
- MCP 授权规范
- Google OAuth 文档
- Cloudflare Agents SDK
最后验证时间:2026-01-21 | 技能版本:2.0.0 | 变更:根据训练截止时间后的研究,新增 4 个已知问题(RFC 8707 受众错误、Claude.ai 连接失败、重新认证循环、生产环境重定向 URI 不匹配)、版本历史部分、刷新令牌轮换设计决策、双 OAuth 角色模式重点强调,以及 PKCE 限制说明。已记录的错误预防项从 6 项增至 9 项。