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. 01Workers での OAuth 実装
  2. 02動的クライアント登録への対応
  3. 03Google ログインとユーザープロフィール
  4. 04上流アクセストークンの暗号化保存
  5. 05CSRF、state、セッションの保護
  6. 06OAuth と Bearer トークンの併用

活用シーン

  1. 01MCP サーバーにユーザー認証を追加する
  2. 02MCP サーバーを 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 の二重ロールパターン

Google などのサードパーティー OAuth プロバイダーを使用する場合、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. 型定義(任意ですが推奨)

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 の設定

  1. console.cloud.google.com に移動します
  2. 新しいプロジェクトを作成するか、既存のプロジェクトを選択します
  3. API とサービス → 認証情報に移動します
  4. 認証情報を作成 → OAuth クライアント IDをクリックします
  5. アプリケーションの種類として、ウェブアプリケーションを選択します
  6. 承認済みのリダイレクト URI を追加します:https://your-worker.workers.dev/callback
  7. クライアント 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 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(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_IDOAuth アプリ IDGoogle Cloud Console
GOOGLE_CLIENT_SECRETOAuth アプリのシークレット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 プロバイダーがリフレッシュトークンを発行しない場合:

  1. フォールバック戦略を実装します(トークンの有効期限を保存し、期限切れ前に再認証します)
  2. 公式の修正についてはIssue #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"
      }));
    }
  }
}

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) - 現行

新機能:

移行:破壊的変更はありません。CIMD のサポートは追加機能です。


v0.1.0 (2025-11-07)

新機能:

  • RFC 7519 に準拠した OAuth トークンの Audience 検証

破壊的変更:

  • トークンに正しい aud クレームが必要になりました
  • Audience 検証を行わない既存のデプロイでは、動作しなくなる可能性があります
  • RFC 8707 のパス処理のバグについては Issue #108 を参照してください

移行:

  1. すべてのトークンに正しい aud クレームが含まれていることを確認します
  2. Audience 検証を十分にテストします
  3. パスを含むリソースインジケーターを使用している場合は、Issue #108 の回避策を適用します

v0.0.x (2025 年 11 月より前)

Audience 検証を含まない初期リリース。

防止されるエラー

  1. RFC 8707 のオーディエンスパスの不具合 - パスコンポーネントの検証に対する回避策
  2. Claude.ai の接続失敗 - 既知の問題の追跡
  3. 再認証ループ - props の更新処理
  4. 本番環境でのリダイレクト URI の不一致 - テストと明示的な登録
  5. CSRF の脆弱性 - SameSite 属性を設定した HttpOnly Cookie
  6. state リプレイ攻撃 - 1 回限り使用できる KV state
  7. トークンの窃取 - SHA-256 によるセッションバインディング
  8. DCR の不足 - OAuthProvider が自動的に処理
  9. Cookie の改ざん - HMAC 署名

参考資料


最終確認日:2026-01-21 | スキルのバージョン:2.0.0 | 変更点:トレーニングカットオフ後の調査に基づく既知の問題を 4 件追加(RFC 8707 のオーディエンスの不具合、Claude.ai の接続失敗、再認証ループ、本番環境でのリダイレクト URI の不一致)、バージョン履歴セクション、リフレッシュトークンのローテーションに関する設計上の決定、2 つの OAuth 役割パターンの重要性、PKCE の制限に関する注記を追加しました。文書化されたエラー防止策を 6 件から 9 件に更新しました。

コメント