MCP OAuth Cloudflare について
MCP OAuth Cloudflare は、Cloudflare Workers 上で MCP サーバーに OAuth 認証を実装するためのガイドです。ユーザー情報や Google ログインが必要な開発者向けに、二重の OAuth ロール、動的クライアント登録、セキュリティ対策を説明します。
主な機能
- 01Workers での OAuth 実装
- 02動的クライアント登録への対応
- 03Google ログインとユーザープロフィール
- 04上流アクセストークンの暗号化保存
- 05CSRF、state、セッションの保護
- 06OAuth と Bearer トークンの併用
活用シーン
- 01MCP サーバーにユーザー認証を追加する
- 02MCP サーバーを 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 の二重ロールパターン
Google などのサードパーティー OAuth プロバイダーを使用する場合、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. 型定義(任意ですが推奨)
TypeScript の型サポートを利用するには、templates/env.d.ts を src/env.d.ts にコピーします。
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 };
}
State の検証(リプレイ攻撃を防止)
// 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 メソッドを受け付けます。OAuth 2.1 で推奨される S256 のみを強制する設定オプションはありません。
セキュリティ上の考慮事項:最大限のセキュリティを確保するには、S256 のみを使用することが望ましい場合があります。この機能リクエストは、GitHub Issue #113 で追跡されています。
回避策:この設定が可能になるまでは、ライブラリは両方のメソッドを受け付けます。Claude.ai を含む最新の OAuth クライアントは、デフォルトで S256 を使用します。
Google Cloud Console の設定
- console.cloud.google.com に移動します
- 新しいプロジェクトを作成するか、既存のプロジェクトを選択します
- API とサービス → 認証情報に移動します
- 認証情報を作成 → OAuth クライアント IDをクリックします
- アプリケーションの種類として、ウェブアプリケーションを選択します
- 承認済みのリダイレクト URI を追加します:
https://your-worker.workers.dev/callback - クライアント ID とクライアントシークレットをコピーします
一般的な 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(API とサービス → ライブラリ)で有効にする必要があります
- スコープによっては、本番環境で使用するために OAuth 同意画面の確認が必要です
drive.fileでは、アプリが作成したファイル、またはユーザーが明示的にアプリで開いたファイルのみアクセスできます
リフレッシュトークンのライフサイクル(v0.2.0+)
長期間のセッション(Google API、Gmail、Drive)では、リフレッシュトークンが必要です。
設計上の決定:有効なリフレッシュトークンを 2 つ保持
注:@cloudflare/workers-oauth-provider は、標準とは異なるリフレッシュトークンのローテーション方式を実装しています。常に、1 つのグラントに対して有効なリフレッシュトークンが 2 つ存在する場合があります。クライアントが一方を使用すると、もう一方は無効になり、新しいトークンが生成されます。
OAuth 2.1 との違い:OAuth 2.1 では、パブリッククライアントに対して 1 回限り使用可能なリフレッシュトークンが求められます。しかし、このライブラリの作者は、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 API を呼び出す必要がある
- 長期間のセッション(バックグラウンドタスク、スケジュールされたジョブ)
- ユーザーデータの同期
access_type=online(デフォルト)を使用する場合:
- 簡単なユーザー識別のみを行う
- 初回認証後に API を呼び出さない
- 短期間のセッション(管理者ログイン、1 回限りの操作)
リフレッシュトークンの保存
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 トークン(CLI ツール、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 トークンは 401 を返さず、OAuth プロバイダーにフォールスルーさせる必要があります。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 トークンの比較
| 項目 | Auth トークン | 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件のエラーを防止します。
問題 #1:RFC 8707 のオーディエンス検証がパス要素を含む場合に失敗する(v0.1.0+)
エラー:invalid_token: Token audience does not match resource server
出典:GitHub Issue #108
影響範囲:パスを含む RFC 8707 リソースインジケーター(例:ChatGPT のカスタムコネクター)を使用する場合の v0.1.0+
発生する理由:resourceServer はオリジン(https://example.com)のみを使用して計算されますが、RFC 8707 ではパスを含む完全な URL(https://example.com/api)の使用が推奨されています。audienceMatches の厳密な等価性チェックは、次の場合に失敗します。
- トークンのオーディエンス:
resourceパラメーターから取得したhttps://example.com/api - リソースサーバー:リクエスト URL のオリジンのみから計算した
https://example.com
防止策:
パスを含む RFC 8707 リソースインジケーターを使用する場合は、ライブラリをベンダー化し、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で確認してください。ライブラリの更新、または修正を加えてライブラリをベンダー化する必要がある場合があります。
Issue #3:再認証後にプロパティが更新されない(上流 OAuth の有効期限切れ)
エラー:上流の OAuth プロバイダーがリフレッシュトークンを提供しない場合、再認証が無限に繰り返されます ソース:GitHub Issue #34 影響対象:リフレッシュトークンを提供しない上流 OAuth プロバイダーを使用する MCP サーバー
発生理由:tokenExchangeCallback で invalid_grant をスローすると再認証が開始されますが、completeAuthorization() はプロパティを更新しません。古いプロパティが原因で認証が繰り返し失敗し、OAuth クライアントを再起動するまで解消されません。
予防策:
上流の OAuth プロバイダーがリフレッシュトークンを発行しない場合:
- フォールバック戦略を実装します(トークンの有効期限を保存し、期限切れ前に再認証します)
- 公式の修正についてはIssue #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"
}));
}
}
}
Issue #4:本番環境でのリダイレクト URI の不一致(開発環境と本番環境の動作の違い)
エラー:Invalid redirect URI. The redirect URI provided does not match any registered URI for this client
ソース:GitHub Issue #29(コミュニティからの報告)
影響対象:本番環境へのデプロイ。ローカルの wrangler dev では問題なく動作します
発生理由:動的クライアント登録(DCR)の動作は、ローカル環境と本番環境で異なります。DCR 中にリダイレクト URI は自動登録されますが、本番環境では何らかの問題が発生します。根本原因は不明ですが、MCP クライアント(Cursor、Windsurf、PyCharm)を使用する複数のユーザーに影響しています。
予防策:
- DCR の自動登録に頼らず、可能な場合はリダイレクト URI を明示的に登録します
- Claude.ai にデプロイする前に、本番環境で OAuth フローをテストします
- 解決状況についてはIssue #29を確認します
Issue #5:CSRF の脆弱性
エラー:セッションの乗っ取り、OAuth コールバックの傍受 予防策:SameSite 属性を設定した HttpOnly Cookie
const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
Issue #6:State のリプレイ攻撃
エラー:OAuth state が複数の認証試行で再利用されます 予防策:有効期限 10 分の一度限り使用できる KV state
await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {
expirationTtl: 600,
});
Issue #7:セッション固定によるトークンの窃取
エラー:OAuth state が窃取され、別のブラウザーセッションから使用されます 予防策: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('');
Issue #8:動的クライアント登録(DCR)の欠如
エラー:接続しようとすると Claude.ai に「接続に失敗しました」と表示されます
予防策:OAuthProvider は clientRegistrationEndpoint: '/register' を介して DCR を自動的に処理します
Issue #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)のサポート。
client_idの値として HTTPS URL を使用できます - 新しい MCP 認可仕様に準拠:https://modelcontextprotocol.io/specification/draft/basic/authorization
移行:破壊的変更はありません。CIMD のサポートは追加機能です。
v0.1.0 (2025-11-07)
新機能:
- RFC 7519 に準拠した OAuth トークンの Audience 検証
破壊的変更:
- トークンに正しい
audクレームが必要になりました - Audience 検証を行わない既存のデプロイでは、動作しなくなる可能性があります
- RFC 8707 のパス処理のバグについては Issue #108 を参照してください
移行:
- すべてのトークンに正しい
audクレームが含まれていることを確認します - Audience 検証を十分にテストします
- パスを含むリソースインジケーターを使用している場合は、Issue #108 の回避策を適用します
v0.0.x (2025 年 11 月より前)
Audience 検証を含まない初期リリース。
防止されるエラー
- RFC 8707 のオーディエンスパスの不具合 - パスコンポーネントの検証に対する回避策
- Claude.ai の接続失敗 - 既知の問題の追跡
- 再認証ループ - props の更新処理
- 本番環境でのリダイレクト URI の不一致 - テストと明示的な登録
- CSRF の脆弱性 - SameSite 属性を設定した HttpOnly Cookie
- state リプレイ攻撃 - 1 回限り使用できる KV state
- トークンの窃取 - SHA-256 によるセッションバインディング
- DCR の不足 - OAuthProvider が自動的に処理
- Cookie の改ざん - HMAC 署名
参考資料
- Cloudflare Workers OAuth Provider
- GitHub - cloudflare/workers-oauth-provider
- MCP Specification
- MCP Authorization Spec
- Google OAuth Documentation
- Cloudflare Agents SDK
最終確認日:2026-01-21 | スキルのバージョン:2.0.0 | 変更点:トレーニングカットオフ後の調査に基づく既知の問題を 4 件追加(RFC 8707 のオーディエンスの不具合、Claude.ai の接続失敗、再認証ループ、本番環境でのリダイレクト URI の不一致)、バージョン履歴セクション、リフレッシュトークンのローテーションに関する設計上の決定、2 つの OAuth 役割パターンの重要性、PKCE の制限に関する注記を追加しました。文書化されたエラー防止策を 6 件から 9 件に更新しました。