mcpField
M
MCP OAuth Cloudflare

MCP OAuth Cloudflare

github.com/jezweb安全1.1k293 次安装3 周前更新
代码仓库

关于 MCP OAuth Cloudflare

MCP OAuth Cloudflare 是在 Cloudflare Workers 上为 MCP 服务构建 OAuth 身份验证的技能指南,适合需要用户身份和第三方 Google 登录的开发者。它介绍双重 OAuth 角色、动态客户端注册及相关安全措施。

主要功能

  1. 01在 Worker 上实现 OAuth
  2. 02支持动态客户端注册
  3. 03集成 Google 登录与用户资料
  4. 04加密存储上游访问令牌
  5. 05CSRF、状态与会话绑定保护
  6. 06OAuth 与 Bearer Token 并存

适用场景

  1. 01为 MCP 服务添加用户认证
  2. 02将 MCP 部署到 Claude.ai
  3. 03在工具处理中使用用户资料
  4. 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.json
references/
oauth-flow.md
rules/
mcp-oauth.md
templates/
env.d.ts
index.ts
package.json
templates/oauth/
google-handler.ts
utils.ts
workers-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 会:

  1. 在 Workers KV 中存储加密的访问令牌
  2. 向客户端签发自己的令牌
  3. 由 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 设置

  1. 前往 console.cloud.google.com
  2. 创建新项目或选择现有项目
  3. 前往 APIs & Services → Credentials
  4. 点击 Create Credentials → OAuth client ID
  5. 应用类型:Web application
  6. 添加授权重定向 URI:https://your-worker.workers.dev/callback
  7. 复制 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 Docsopenid email profile https://www.googleapis.com/auth/documents
Google Docs + Driveopenid 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 Calendaropenid email profile https://www.googleapis.com/auth/calendar
Google Sheetsopenid email profile https://www.googleapis.com/auth/spreadsheets
Google Slidesopenid email profile https://www.googleapis.com/auth/presentations
YouTube Dataopenid 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),
  };
}
刷新令牌失效的情况

妥善处理:捕获刷新失败,并重定向用户重新授权。

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 TokenOAuth
Token 共享手动(有风险)自动
用户同意无明确批准
过期手动自动刷新
撤销不内置用户可以断开连接
范围全部或无精细控制
兼容 Claude.ai否(需要 DCR)是

必需的 Secret

Secret用途生成方式
GOOGLE_CLIENT_IDOAuth 应用 IDGoogle Cloud Console
GOOGLE_CLIENT_SECRETOAuth 应用 SecretGoogle 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 提供商不签发刷新令牌:

  1. 实施备用策略(存储令牌过期时间,并在过期前重新授权)
  2. 关注 问题 #34,等待官方修复
  3. 将客户端重启作为临时解决方法

有问题的模式:

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)- 当前版本

新功能:

迁移:没有重大变更。CIMD 支持是增量添加的。


v0.1.0(2025-11-07)

新功能:

  • 根据 RFC 7519 验证 OAuth 令牌的受众

重大变更:

  • 令牌现在必须包含正确的 aud 声明
  • 未进行受众验证的现有部署可能会受到影响
  • RFC 8707 路径处理错误,请参阅问题 #108

迁移:

  1. 确保所有令牌都包含正确的 aud 声明
  2. 彻底测试受众验证
  3. 如果使用带路径的资源指示符,请采用问题 #108 中的解决方法

v0.0.x(2025 年 11 月之前)

初始版本尚未进行受众验证。

已规避的问题

  1. RFC 8707 受众路径错误 - 绕过路径组件验证
  2. Claude.ai 连接失败 - 跟踪已知问题
  3. 重新认证循环 - 处理属性更新
  4. 生产环境重定向 URI 不匹配 - 测试并显式注册
  5. CSRF 漏洞 - 使用带有 SameSite 的 HttpOnly Cookie
  6. 状态重放攻击 - 使用一次性 KV 状态
  7. 令牌被盗 - 通过 SHA-256 绑定会话
  8. 缺少 DCR - OAuthProvider 自动处理
  9. Cookie 篡改 - HMAC 签名

参考资料


最后验证时间:2026-01-21 | 技能版本:2.0.0 | 变更:根据训练截止时间后的研究,新增 4 个已知问题(RFC 8707 受众错误、Claude.ai 连接失败、重新认证循环、生产环境重定向 URI 不匹配)、版本历史部分、刷新令牌轮换设计决策、双 OAuth 角色模式重点强调,以及 PKCE 限制说明。已记录的错误预防项从 6 项增至 9 项。

评论

支持 Markdown