MCP OAuth Cloudflare by jezweb/claude-skills
npx skills add https://github.com/jezweb/claude-skills --skill 'MCP OAuth Cloudflare'适用于 Cloudflare Workers 上 MCP 服务器的生产就绪 OAuth 身份验证。
当使用第三方 OAuth 提供商(如 Google)时,MCP 服务器既充当 OAuth 客户端(对上游服务)又充当 OAuth 服务器(对 MCP 客户端)。Worker:
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 │ └──────────────────────────────────┘ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
npm install @cloudflare/workers-oauth-provider agents @modelcontextprotocol/sdk hono zod
src/
├── index.ts # 主入口点,包含 OAuthProvider
└── oauth/
├── google-handler.ts # OAuth 路由 (/authorize, /callback)
├── utils.ts # Google 令牌交换和用户信息
└── workers-oauth-utils.ts # CSRF、状态验证、批准界面
{
"name": "my-mcp-server",
"main": "src/index.ts",
"compatibility_flags": ["nodejs_compat"],
// KV 用于 OAuth 状态存储
"kv_namespaces": [
{
"binding": "OAUTH_KV",
"id": "YOUR_KV_NAMESPACE_ID"
}
],
// Durable Objects 用于 MCP 会话
"durable_objects": {
"bindings": [
{
"class_name": "MyMcpServer",
"name": "MCP_OBJECT"
}
]
},
"migrations": [
{
"new_sqlite_classes": ["MyMcpServer"],
"tag": "v1"
}
]
}
# Google OAuth 凭证(来自 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 加密密钥(32+ 字符)
python3 -c "import secrets; print(secrets.token_urlsafe(32))" | npx wrangler secret put COOKIE_ENCRYPTION_KEY
# 可选:自定义 Google OAuth 作用域(默认:'openid email profile')
# 查看下面的“常见 Google 作用域”部分了解作用域配置
echo "openid email profile https://www.googleapis.com/auth/drive" | npx wrangler secret put GOOGLE_SCOPES
# 部署以激活密钥
npx wrangler deploy
将 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; // 可选:覆盖默认作用域
OAUTH_KV: KVNamespace;
MCP_OBJECT: DurableObjectNamespace;
}
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';
// 来自 OAuth 的属性 - 用户信息存储在令牌中
type Props = {
id: string;
email: string;
name: string;
picture?: string;
accessToken: string;
refreshToken?: string; // 在首次使用 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() {
// 注册工具 - 用户信息可通过 this.props 访问
this.server.tool(
'my_tool',
'工具描述',
{ param: z.string() },
async (args) => {
// 访问已认证用户
const userEmail = this.props?.email;
console.log(`工具调用者:${userEmail}`);
return {
content: [{ type: 'text', text: '结果' }]
};
}
);
}
}
// 使用 OAuth 提供程序包装
export default new OAuthProvider({
apiHandlers: {
'/sse': MyMcpServer.serveSSE('/sse'),
'/mcp': MyMcpServer.serve('/mcp'),
},
authorizeEndpoint: '/authorize',
clientRegistrationEndpoint: '/register',
defaultHandler: GoogleHandler as any,
tokenEndpoint: '/token',
});
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 - 显示批准对话框或重定向到 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('无效请求', 400);
// 如果客户端已批准,则跳过批准步骤
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 });
}
// 显示带有 CSRF 保护的批准对话框
const { token: csrfToken, setCookie } = generateCSRFProtection();
return renderApprovalDialog(c.req.raw, {
client: await c.env.OAUTH_PROVIDER.lookupClient(clientId),
csrfToken,
server: {
name: '我的 MCP 服务器',
description: '您的服务器描述',
logo: 'https://example.com/logo.png',
},
setCookie,
state: { oauthReqInfo },
});
});
// POST /authorize - 处理批准表单
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));
// 添加到已批准客户端列表
const approvedCookie = await addApprovedClient(
c.req.raw, state.oauthReqInfo.clientId, c.env.COOKIE_ENCRYPTION_KEY
);
// 创建状态并重定向
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.message}`, 500);
}
});
// GET /callback - 处理 Google OAuth 回调
app.get('/callback', async (c) => {
const { oauthReqInfo, clearCookie } = await validateOAuthState(c.req.raw, c.env.OAUTH_KV);
// 交换代码获取令牌
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;
// 获取用户信息
const user = await fetchGoogleUserInfo(accessToken);
if (!user) return c.text('获取用户信息失败', 500);
// 完成授权
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> = {}) {
// 作用域可通过 GOOGLE_SCOPES 环境变量配置(参见“常见 Google 作用域”部分)
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 };
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 │
└─────────────────────────────────┘
// 使用 HttpOnly cookie 生成 CSRF 令牌
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 };
}
// 在 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 分钟
});
return { stateToken };
}
// 通过 SHA-256 哈希将状态绑定到浏览器会话
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 签名的 cookie 跟踪已批准的客户端(30 天 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`;
}
注意:该库当前接受 plain 和 S256 两种 PKCE 方法。没有配置选项来强制仅使用 S256,而 S256 是 OAuth 2.1 推荐的方法。
安全考虑:为了最大安全性,您可能希望仅使用 S256。这在 GitHub Issue #113 中作为功能请求被跟踪。
变通方案:在此可配置之前,该库将接受两种方法。现代 OAuth 客户端(包括 Claude.ai)默认使用 S256。
https://your-worker.workers.dev/callback通过 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 |
设置作用域:
# 选项 1:通过环境变量(推荐,灵活性高)
echo "openid email profile https://www.googleapis.com/auth/drive" | npx wrangler secret put GOOGLE_SCOPES
# 选项 2:在 wrangler.jsonc 中(用于非敏感作用域)
{
"vars": {
"GOOGLE_SCOPES": "openid email profile https://www.googleapis.com/auth/drive"
}
}
重要注意事项:
openid email profile - 用户标识所必需drive.file 仅访问应用创建或用户明确使用它打开的文件对于长期会话(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:
// 在 google-handler.ts 的 redirectToGoogle 函数中
googleAuthUrl.searchParams.set('access_type', 'offline');
googleAuthUrl.searchParams.set('prompt', 'consent'); // 强制新的刷新令牌
何时使用 access_type=offline:
何时使用 access_type=online(默认):
在您的 Props 类型中加密存储:
export type Props = {
id: string;
email: string;
name: string;
picture?: string;
accessToken: string;
refreshToken?: string; // 收到时存储
tokenExpiresAt?: number; // 跟踪过期时间
};
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; // 令牌已撤销,需要重新认证
const body = await resp.json();
return {
accessToken: body.access_token,
expiresAt: Date.now() + (body.expires_in * 1000),
};
}
优雅处理:捕获刷新失败并重定向到重新授权。
现代 MCP 服务器支持同时使用 OAuth(Claude.ai)和 Bearer 令牌(CLI 工具、ElevenLabs):
// 在您的主 fetch 处理程序中
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const authHeader = request.headers.get('Authorization');
const url = new URL(request.url);
// 检查 MCP 端点上的 Bearer 令牌认证
if (env.AUTH_TOKEN && authHeader?.startsWith('Bearer ') &&
(url.pathname === '/sse' || url.pathname === '/mcp')) {
const token = authHeader.slice(7);
if (token === env.AUTH_TOKEN) {
// 编程访问(CLI、ElevenLabs)
const headerAuthCtx = { ...ctx, props: { source: 'bearer' } };
return mcpHandler.fetch(request, env, headerAuthCtx);
}
// 不是 env.AUTH_TOKEN - 回退到 OAuth 提供程序
// (可能是来自 Claude.ai 的 OAuth 令牌)
}
// Web 客户端的 OAuth 流程
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 # 需要激活
原因:状态已过期(>10 分钟)或 KV 查找失败
修复:重新启动 OAuth 流程 - 状态是一次性使用的
原因:表单提交时没有匹配的 cookie
修复:确保浏览器启用了 cookie 且未被浏览器扩展程序阻止
原因:缺少 DCR 端点或响应无效
修复:确保在 OAuthProvider 配置中设置了 clientRegistrationEndpoint: '/register'
原因:在 OAuth 完成前访问 this.props
修复:在访问用户数据前检查 if (this.props)
| 方面 | 认证令牌 | OAuth |
|---|---|---|
| 令牌共享 | 手动(有风险) | 自动 |
| 用户同意 | 无 | 明确批准 |
| 过期 | 手动 | 自动刷新 |
| 撤销 | 无内置功能 | 用户可以断开连接 |
| 作用域 | 全部或无 | 细粒度 |
| Claude.ai 兼容 | 否(需要 DCR) | 是 |
| 密钥 | 用途 | 生成方式 |
|---|---|---|
GOOGLE_CLIENT_ID | OAuth 应用 ID | Google Cloud Console |
GOOGLE_CLIENT_SECRET | OAuth 应用密钥 | Google Cloud Console |
COOKIE_ENCRYPTION_KEY | 签名批准 cookie | secrets.token_urlsafe(32) |
GOOGLE_SCOPES(可选) | 覆盖默认 OAuth 作用域 | 参见“常见 Google 作用域”部分 |
| 无技能 | 有技能 | 节省 |
|---|---|---|
| ~20k 令牌,3-5 次尝试 | ~6k 令牌,首次成功 | ~70% |
此技能预防了 9 个已记录的错误。
错误:invalid_token: Token audience does not match resource server
来源:GitHub Issue #108
影响:v0.1.0+ 当使用带有路径的 RFC 8707 资源指示器时(例如,ChatGPT 自定义连接器)
发生原因:resourceServer 仅使用源(https://example.com)计算,但 RFC 8707 建议使用包含路径的完整 URL(https://example.com/api)。当以下情况时,audienceMatches 中的严格相等检查会失败:
https://example.com/api(来自 resource 参数)https://example.com(仅从请求 URL 源计算)预防:
如果使用带有路径的 RFC 8707 资源指示器,请定制库并修改 handleApiRequest:
// 变通方案:在 resourceServer 计算中包含路径名
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
或者,在上游修复此问题之前,避免在资源指示器中使用路径。
错误:Claude.ai MCP 客户端在 OAuth 流程期间无法连接 来源:GitHub Issue #133 影响:v0.2.2,Claude.ai MCP 客户端
发生原因:audienceMatches 函数中存在 '/' 字符,阻止了 Claude.ai 连接。可能与问题 #1(RFC 8707 路径处理)相关。
预防:关注 Issue #133 的更新。这可能需要库更新或使用修复后的定制库。
错误:当上游 OAuth 提供程序不提供刷新令牌时,无限重新授权循环 来源:GitHub Issue #34 影响:使用没有刷新令牌的上游 OAuth 提供程序的 MCP 服务器
发生原因:在 tokenExchangeCallback 中抛出 invalid_grant 会触发重新授权,但 completeAuthorization() 不会更新属性。过时的属性会导致重复的认证失败,直到 OAuth 客户端重启。
预防:
如果您的上游 OAuth 提供程序不颁发刷新令牌:
有问题的模式:
tokenExchangeCallback: async (options) => {
if (options.grantType === "refresh_token") {
const response = await fetchNewToken(options.props.accessToken);
if (!response.ok) {
// 触发重新授权但属性保持过时
throw new Error(JSON.stringify({
error: "invalid_grant",
error_description: "access token expired"
}));
}
}
}
错误:Invalid redirect URI. The redirect URI provided does not match any registered URI for this client
来源:GitHub Issue #29(社区来源)
影响:生产部署;在本地 wrangler dev 中正常工作
发生原因:动态客户端注册(DCR)行为在本地和生产环境之间存在差异。重定向 URI 在 DCR 期间自动注册,但在生产环境中某些环节失败。根本原因不明确,但影响多个使用 MCP 客户端的用户(Cursor、Windsurf、PyCharm)。
预防:
错误:会话劫持,OAuth 回调拦截 预防:使用带有 SameSite 属性的 HttpOnly cookie
const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
错误:OAuth 状态在多次授权尝试中被重复使用 预防:一次性使用的 KV 状态,10 分钟 TTL
await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {
expirationTtl: 600,
});
错误: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('');
错误:Claude.ai 尝试连接时显示"连接失败"
预防:OAuthProvider 通过 clientRegistrationEndpoint: '/register' 自动处理 DCR
错误:已批准客户端列表被修改以绕过同意 预防:批准 cookie 上的 HMAC 签名
const signature = await signData(payload, cookieSecret);
const cookie = `__Host-APPROVED_CLIENTS=${signature}.${btoa(payload)}`;
新功能:
client_id 值迁移:无破坏性变更。CIMD 支持是附加的。
新功能:
破坏性变更:
aud 声明迁移:
aud 声明初始版本,无受众验证。
**
Production-ready OAuth authentication for MCP servers on Cloudflare Workers.
When using a third-party OAuth provider (like Google), the MCP Server acts as both an OAuth client (to upstream service) and as an OAuth server (to MCP clients). The Worker:
workers-oauth-provider handles spec complianceCritical : The MCP server generates and issues its own token rather than passing through the third-party token. This is essential for security and spec compliance.
┌─────────────────────────────────────────────────────────────────────┐
│ 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 │ └──────────────────────────────────┘ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
npm install @cloudflare/workers-oauth-provider agents @modelcontextprotocol/sdk hono zod
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
{
"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"
}
]
}
# 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
Copy templates/env.d.ts to src/env.d.ts for TypeScript type support:
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;
}
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',
});
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 };
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 │
└─────────────────────────────────┘
// 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`;
}
Note : The library currently accepts both plain and S256 PKCE methods. There is no configuration option to enforce S256-only, which is the OAuth 2.1 recommended method.
Security Consideration : For maximum security, you may want S256-only. This is tracked in GitHub Issue #113 as a feature request.
Workaround : Until this is configurable, the library will accept both methods. Modern OAuth clients (including Claude.ai) use S256 by default.
https://your-worker.workers.dev/callbackConfigure scopes via the GOOGLE_SCOPES environment variable or modify the redirectToGoogle function.
| Use Case | Scopes |
|---|---|
| Basic user info (default) | openid email profile |
| Google Drive (full access) | openid email profile https://www.googleapis.com/auth/drive |
| Google Drive (file-level only) | 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 |
Setting Scopes:
# 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"
}
}
Important Notes:
openid email profile - required for user identificationdrive.file only accesses files the app created or user explicitly opened with itFor long-lived sessions (Google APIs, Gmail, Drive), you need refresh tokens.
Note : @cloudflare/workers-oauth-provider implements a non-standard refresh token rotation strategy. At any time, a grant may have two valid refresh tokens. When the client uses one, the other is invalidated and a new one is generated.
Why It Differs from OAuth 2.1 : OAuth 2.1 requires single-use refresh tokens for public clients. However, the library author argues that single-use tokens are fundamentally flawed because they assume every refresh request completes with no errors. In the real world, network errors or software faults could mean the client fails to store the new refresh token.
Security Trade-off : Allowing the previous refresh token disables replay attack detection. For confidential clients (most MCP servers), this is compliant with OAuth 2.1. For public clients, consider stricter rotation if needed.
Source : GitHub Issue #43, documented in README
Add access_type=offline to the authorization URL:
// In google-handler.ts, redirectToGoogle function
googleAuthUrl.searchParams.set('access_type', 'offline');
googleAuthUrl.searchParams.set('prompt', 'consent'); // Forces new refresh token
When to useaccess_type=offline:
When to useaccess_type=online (default):
Store encrypted in your Props type:
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),
};
}
Handle gracefully : Catch refresh failures and redirect to re-authorize.
Modern MCP servers support both OAuth (Claude.ai) and Bearer tokens (CLI tools, 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);
}
};
Critical Pattern : Non-matching Bearer tokens must fall through to OAuth provider, not return 401. OAuth tokens from Claude.ai are also sent as Bearer tokens.
Adding AUTH_TOKEN secret:
python3 -c "import secrets; print(secrets.token_urlsafe(32))" | npx wrangler secret put AUTH_TOKEN
npx wrangler deploy # Required to activate
Cause : State expired (>10 min) or KV lookup failed
Fix : Restart the OAuth flow - states are one-time-use
Cause : Form submitted without matching cookie
Fix : Ensure cookies are enabled and not blocked by browser extensions
Cause : Missing DCR endpoint or invalid response
Fix : Ensure clientRegistrationEndpoint: '/register' is set in OAuthProvider config
Cause : Accessing this.props before OAuth completes
Fix : Check if (this.props) before accessing user data
| Aspect | Auth Tokens | OAuth |
|---|---|---|
| Token sharing | Manual (risky) | Automatic |
| User consent | None | Explicit approval |
| Expiration | Manual | Automatic refresh |
| Revocation | None built-in | User can disconnect |
| Scope | All-or-nothing | Fine-grained |
| Claude.ai compatible | No (DCR required) | Yes |
| Secret | Purpose | Generate |
|---|---|---|
GOOGLE_CLIENT_ID | OAuth app ID | Google Cloud Console |
GOOGLE_CLIENT_SECRET | OAuth app secret | Google Cloud Console |
COOKIE_ENCRYPTION_KEY | Sign approval cookies | secrets.token_urlsafe(32) |
GOOGLE_SCOPES (optional) | Override default OAuth scopes | See "Common Google Scopes" section |
| Without Skill | With Skill | Savings |
|---|---|---|
| ~20k tokens, 3-5 attempts | ~6k tokens, first try | ~70% |
This skill prevents 9 documented errors.
Error : invalid_token: Token audience does not match resource server Source : GitHub Issue #108 Affects : v0.1.0+ when using RFC 8707 resource indicators with paths (e.g., ChatGPT custom connectors)
Why It Happens : The resourceServer is computed using only the origin (https://example.com) but RFC 8707 recommends using full URLs with paths (https://example.com/api). The strict equality check in audienceMatches fails when:
https://example.com/api (from resource parameter)https://example.com (computed from request URL origin only)Prevention :
If using RFC 8707 resource indicators with paths, vendor the library and modify handleApiRequest:
// Workaround: Include pathname in resourceServer computation
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
Or avoid using paths in resource indicators until this is fixed upstream.
Error : Claude.ai MCP client fails to connect during OAuth flow Source : GitHub Issue #133 Affects : v0.2.2, Claude.ai MCP clients
Why It Happens : There is a '/' character in the audienceMatches function that prevents Claude.ai from connecting. Likely related to Issue #1 (RFC 8707 path handling).
Prevention : Monitor Issue #133 for updates. This may require a library update or vendoring the library with a fix.
Error : Infinite re-auth loop when upstream OAuth provider doesn't provide refresh tokens Source : GitHub Issue #34 Affects : MCP servers using upstream OAuth providers without refresh tokens
Why It Happens : Throwing invalid_grant in tokenExchangeCallback triggers re-authorization, but completeAuthorization() doesn't update props. Stale props cause repeated auth failures until the OAuth client restarts.
Prevention :
If your upstream OAuth provider doesn't issue refresh tokens:
Problematic Pattern:
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"
}));
}
}
}
Error : Invalid redirect URI. The redirect URI provided does not match any registered URI for this client Source : GitHub Issue #29 (Community-sourced) Affects : Production deployments; works fine in local wrangler dev
Why It Happens : Dynamic Client Registration (DCR) behavior differs between local and production environments. Redirect URIs auto-register during DCR, but something fails in production. Root cause unclear but affecting multiple users with MCP clients (Cursor, Windsurf, PyCharm).
Prevention :
Error : Session hijacking, OAuth callback interception Prevention : HttpOnly cookies with SameSite attribute
const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
Error : OAuth state reused across multiple authorization attempts Prevention : One-time-use KV state with 10-minute TTL
await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {
expirationTtl: 600,
});
Error : OAuth state stolen and used from different browser session Prevention : Session binding via SHA-256 hash
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('');
Error : Claude.ai shows "Connection Failed" when trying to connect Prevention : OAuthProvider handles DCR automatically via clientRegistrationEndpoint: '/register'
Error : Approved clients list modified to bypass consent Prevention : HMAC signatures on approval cookies
const signature = await signData(payload, cookieSecret);
const cookie = `__Host-APPROVED_CLIENTS=${signature}.${btoa(payload)}`;
New Features :
client_id valuesMigration : No breaking changes. CIMD support is additive.
New Features :
Breaking Changes :
aud claimMigration :
aud claimInitial releases without audience validation.
Last verified : 2026-01-21 | Skill version : 2.0.0 | Changes : Added 4 new known issues from post-training-cutoff research (RFC 8707 audience bugs, Claude.ai connection failures, re-auth loops, production redirect URI mismatches), version history section, refresh token rotation design decision, dual OAuth role pattern emphasis, and PKCE limitation note. Updated from 6 to 9 documented error preventions.
Weekly Installs
–
Repository
GitHub Stars
643
First Seen
–
Security Audits
Azure 升级评估与自动化工具 - 轻松迁移 Functions 计划、托管层级和 SKU
73,200 周安装
| Gmail (read/send) | openid email profile https://www.googleapis.com/auth/gmail.modify |
| Gmail (read only) | 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 |