Claude の connector を作って、独自の OpenID Connect (OAuth2) プロバイダーで認証させる

2026-08-02 14:35 (6 hours ago)

Claude が MCP サーバーのツールを呼び、認証済みユーザーのログイン名を返している画面

Claude のカスタムコネクタ (リモート MCP サーバー) を作り、自前の OpenID Connect プロバイダーで認証させる手順をまとめます。

作るものは、認証したユーザーのログイン名を返すだけの小さな MCP サーバーです。Claude.ai / Claude Desktop / モバイル / Cowork はどれも同じコネクタ基盤を使うので、これを 1 つ登録すればどこからでも使えます。

この記事で使う構成は次のとおりです。

役割 使うもの
MCP サーバー Python + FastMCP 3.x (Streamable HTTP / uvicorn)
OIDC プロバイダー (認可サーバー) Django + Django OAuth Toolkit 3.x
インフラ Kubernetes (ingress-nginx 経由で公開)

ただし、Claude が要求する仕様そのものは実装や環境に依存しません。 RFC 9728 の protected resource metadata、issuer 直下の discovery、PKCE、audience の検証といった話は、別の言語・別の OIDC プロバイダー・別のインフラでもそのまま当てはまります。ライブラリ固有の話が出てくるところは、そう分かるように書いています。

Claude のカスタムコネクタとは

カスタムコネクタは、実はただの MCP サーバーです。 「コネクタ」という専用の形式やフレームワークがあるわけではなく、Streamable HTTP でインターネットに公開された MCP サーバーを、Claude の設定画面から URL で登録するだけです。「Cowork 専用のコネクタ」のようなものも存在しません。

重要なのは、OAuth のクライアントとしての機能が Claude 側に既に組み込まれていることです。認証を付けるからといって、ログイン画面もコールバックの受け口も自分で書く必要はありません。

Claude が肩代わりしてくれるもの:

  • protected resource metadata と認可サーバーメタデータの discovery
  • PKCE のクライアント側 (code_verifier の生成、code_challenge の導出、トークン要求時の verifier 送信)
  • 認可コードフローの実行 (ブラウザを開いて認可画面を出し、コードをトークンに交換する)
  • リダイレクト URI のホスト (https://claude.ai/api/mcp/auth_callback)
  • トークンの保管とリフレッシュ
  • 以降のリクエストへの Authorization: Bearer の付与

PKCE の検証は認可サーバーの仕事です。 Claude がやるのは verifier / challenge の生成と送信までで、challenge を保存して verifier と突き合わせるのは認可サーバー側です (RFC 7636 §4.6)。「Claude がやってくれるから自分は何もしなくてよい」ではありません。認可サーバーが code_challenge_methods_supportedS256 を広告し、実際に検証していることを確認してください。

自分で書くのは、リソースサーバー側の 3 点だけです。

  1. 未認証なら 401 を返し、WWW-Authenticate で「認証の入口はここ」と教える
  2. protected resource metadata を配信する (どの認可サーバーを使うか、どのスコープが要るか)
  3. 受け取った Bearer トークンを検証する

コネクタ登録時に client_secret を渡せるのは、Claude が confidential client だからです。RFC 6749 §2.1 の分類では、資格情報の秘匿を維持できるクライアントが confidential、できないものが public です。ブラウザ内 SPA やネイティブアプリと違い、Claude はサーバー側で secret を保管するので前者にあたります。

用語の対応

OAuth 2.0 / OIDC のロール 今回の登場人物
認可サーバー (Authorization Server) / OpenID Provider 自前の OIDC プロバイダー
クライアント (Client) / Relying Party Claude
リソースサーバー (Resource Server) これから作る MCP サーバー
リソースオーナー (Resource Owner) コネクタを使うユーザー

ちなみに、この「クライアント」をコンシューマー (Consumer) と呼ぶ人がいますが、**OAuth 2.0 にその役割名はありません。「クライアント」が正しい呼び方です。**Consumer / Service Provider は OAuth 1.0 Core の用語で、RFC 5849 の時点で client / server に改められ、OAuth 2.0 (RFC 6749) では一貫して client です。OIDC の文脈なら Relying Party (RP) と呼ぶのがより正確になります。

全体の流れ

Claude ──(1) POST /mcp (トークン無し)──────────────> MCP サーバー
       <──(2) 401 + WWW-Authenticate: Bearer resource_metadata="…" ──
       ──(3) GET /.well-known/oauth-protected-resource/mcp ────────>
       <──(4) { resource, authorization_servers, scopes_supported } ─
       ──(5) GET https://id.example.com/.well-known/openid-configuration ──> OIDC プロバイダー
       ──(6) 認可コードフロー (PKCE S256) + ユーザー同意 ─────────>
       ──(7) POST /mcp (Bearer トークン) ─────────────> MCP サーバー
                                                       └─(8) POST /o/introspect/ ──> OIDC プロバイダー

Claude は「401 が返ってきたら、そこに書いてあるメタデータの URL を読み、そこに書いてある認可サーバーを使う」という順番で動きます。この 401 → メタデータ → discovery の連鎖が 1 箇所でも途切れると、認可画面にすらたどり着きません。

Claude 側が要求する仕様

項目 内容
トランスポート リモート MCP (Streamable HTTP)
未認証時の応答 401 + WWW-Authenticate: Bearer resource_metadata="…"。200 に付けても無視される
リソースメタデータ RFC 9728。resourceユーザーが Claude に入力する URL と完全一致(パス込み)
認可サーバーメタデータ RFC 8414 か OIDC Discovery を、issuer の /.well-known/ 直下で配信
PKCE 必須。code_challenge_method=S256
リダイレクト URI https://claude.ai/api/mcp/auth_callback
クライアント登録 DCR (動的クライアント登録, RFC 7591) / CIMD (Client ID Metadata Document) / カスタムコネクタなら client_id + secret を手入力
認可グラント ユーザー同意を伴う認可コードフロー。client_credentials は非対応
タイムアウト discovery / registration / token は 10 秒、refresh は 30 秒
送信元 IP 160.79.104.0/21 (Anthropic の Outbound レンジ)

出典: Building connectors — AuthenticationIP addressesリダイレクト URI・タイムアウト・IP レンジは変わりうるので、実装時に原文を確認してください。

MCP サーバーを作る

以下のコード例は抜粋です。 import や設定値の読み込みは省いてあるので、そのままでは動きません。全体の構造が分かるように、要点だけ載せています。

Python なら FastMCP 3.x が MCP と OAuth の配線をだいたい持っています。

uv add "fastmcp==3.4.5" "uvicorn[standard]"

バージョンは固定してください。 FastMCP は 3.x で認証まわりの API が 2.x から変わっており、ネット上の情報は 2.x 前提のものが多く混ざっています。

認証プロバイダーの組み立て

RemoteAuthProvider に「トークンを検証する人」と「認可サーバーの URL」を渡すと、RFC 9728 のメタデータエンドポイントと 401 応答を生やしてくれます。

先に 2 点だけ断っておきます。どちらも後で詳しく説明します。

  • トークンの検証に introspection を使っています。UserInfo ではありません (理由は「トークンの検証は introspection で行う」)
  • ここで渡す client_id / client_secret は、Claude に入力するものとは別の Application のものです (理由は「資格情報は 2 つに分ける」)
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier

verifier = IntrospectionTokenVerifier(
    introspection_url="https://id.example.com/o/introspect/",
    client_id=RESOURCE_SERVER_CLIENT_ID,
    client_secret=RESOURCE_SERVER_CLIENT_SECRET,
    client_auth_method="client_secret_basic",
    required_scopes=["openid"],
    cache_ttl_seconds=60,
)

auth = RemoteAuthProvider(
    token_verifier=verifier,
    authorization_servers=["https://id.example.com"],
    base_url="https://mcp.example.com",   # パスを含めない
    scopes_supported=["openid", "email"],  # Claude に要求させるスコープ
    resource_name="my-connector",
)

mcp = FastMCP("my-connector", auth=auth)

base_url にパスを入れないでください。この後の mcp.http_app(path=...) で渡すパスと連結されて resource になります。

ツールを書く

認証が通ったリクエストのトークンは get_access_token() で取れます。introspection のレスポンスがそのまま claims に入っているので、そこからログイン名を読みます。

from fastmcp.server.dependencies import get_access_token

@mcp.tool
def get_login_name() -> dict:
    """認証しているユーザーのログイン名を返す。"""
    token = get_access_token()
    claims = token.claims or {}
    return {
        "login_name": claims.get("username"),
        "scopes": list(token.scopes or []),
    }

戻り値は allowlist で組んでください。 return claims と書きたくなりますが、それをやると認可サーバー側でフィールドが増えたときに、そのまま Claude のコンテキストへ流れます。返してよい項目だけを明示的に選びます。

ヘルスチェック用のパスも用意しておきます。認証の手前に置く必要があるので、ツールではなくカスタムルートにします。

from starlette.requests import Request
from starlette.responses import PlainTextResponse

@mcp.custom_route("/healthz", methods=["GET"])
async def healthz(request: Request) -> PlainTextResponse:
    return PlainTextResponse("ok")

ASGI アプリにする

http_app() で Starlette アプリになります。ここで渡す path が MCP のエンドポイントで、base_url と連結されて resource になります。

app = mcp.http_app(path="/mcp")
fix_authorization_server_issuer(app, ["https://id.example.com"])  # 後述
add_root_metadata_fallback(app, "/mcp")                           # 後述 (この順序で)
uvicorn --host 0.0.0.0 --port 8000 --workers 1 --no-proxy-headers main:app

--workers 1--no-proxy-headers には理由があります (「運用上の注意」)。

メタデータのパス

RFC 9728 §3 により、リソースが https://mcp.example.com/mcp なら、メタデータのパスは

/.well-known/oauth-protected-resource/mcp

です。仕様は「well-known URI 文字列を、ホスト部とパス部の間に挿入する」と定めています。ルート直下ではありません。

ただしクライアントによってはルート直下を引きに来ることがあるので、同じ内容をルートにも生やしておくと確実です。同じエンドポイント関数を使い回して、2 つのパスで内容がズレないようにします。

from starlette.routing import Route

WELL_KNOWN = "/.well-known/oauth-protected-resource"

def add_root_metadata_fallback(app, mcp_path: str) -> None:
    canonical = f"{WELL_KNOWN}{mcp_path}"
    for route in app.routes:
        if getattr(route, "path", None) == canonical:
            app.router.routes.append(
                Route(WELL_KNOWN, endpoint=route.endpoint, methods=["GET", "OPTIONS"])
            )
            return
    raise RuntimeError(f"metadata route not found: {canonical}")

authorization_servers の末尾スラッシュに注意

MCP SDK は URL を pydantic の AnyHttpUrl で正規化するため、パスを持たない URL に末尾スラッシュが付きます

{"authorization_servers": ["https://id.example.com/"]}

一方、OIDC プロバイダーが名乗る issuerhttps://id.example.com (スラッシュ無し) であることが多いです。RFC 8414 §3.3 は issuer の文字列一致を要求するので、厳密に検証するクライアントはここで弾かれます。

メタデータのレスポンスを最後に書き換えて揃えるのが確実です。メタデータの生成自体はライブラリに任せたまま、出口で直します。

import json


class AuthorizationServerIssuerFix:
    """PRM の authorization_servers を issuer の表記に揃える ASGI ラッパー。"""

    def __init__(self, app, issuers: list[str]):
        self.app = app
        # 末尾スラッシュを足された形 -> 本来の issuer
        self._fix = {f"{issuer}/": issuer for issuer in issuers}

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        start_message = None
        body = b""

        async def capture(message):
            nonlocal start_message, body
            if message["type"] == "http.response.start":
                start_message = message
                return
            if message["type"] == "http.response.body":
                body += message.get("body", b"")
                if message.get("more_body"):
                    return
                await self._send_fixed(send, start_message, body)
                return
            # 知らないメッセージ型 (拡張) は素通しする
            await send(message)

        await self.app(scope, receive, capture)

    async def _send_fixed(self, send, start_message, body):
        try:
            document = json.loads(body)
            servers = document.get("authorization_servers")
            if isinstance(servers, list):
                document["authorization_servers"] = [
                    self._fix.get(s, s) for s in servers
                ]
                body = json.dumps(document).encode()
        except ValueError:
            pass  # JSON でなければ触らない

        headers = [
            (k, v) for k, v in start_message["headers"]
            if k.lower() != b"content-length"
        ]
        headers.append((b"content-length", str(len(body)).encode()))
        await send({**start_message, "headers": headers})
        await send({"type": "http.response.body", "body": body})

置き換えるのは、設定した issuer と一致する要素だけにしてください。無条件に末尾スラッシュを落とすと、パス付き issuer (https://id.example.com/auth) の正規形を壊します。Content-Length の付け直しも忘れずに。

これをメタデータのルートに被せます。ここに罠があります。

def fix_authorization_server_issuer(app, issuers: list[str]) -> None:
    for route in app.routes:
        path = getattr(route, "path", None)
        if not path or not path.startswith(WELL_KNOWN):
            continue
        route.endpoint = AuthorizationServerIssuerFix(route.endpoint, issuers)
        # これが要る。Starlette は Route の生成時に endpoint から ASGI アプリを
        # 組み立てて route.app に持つので、endpoint を差し替えただけでは配信されない。
        route.app = route.endpoint

route.endpoint を差し替えるだけでは何も変わりません。Starlette は Route を作った時点で endpoint から ASGI アプリを組み立てて route.app に保持しているためです。route.app にも代入して初めて反映されます。

呼ぶ順序も決まっています。フォールバックを生やす前に行ってください。フォールバックは正規ルートのエンドポイントを参照でコピーするので、逆にすると 2 つのパスで内容が食い違います。

app = mcp.http_app(path="/mcp")
fix_authorization_server_issuer(app, ["https://id.example.com"])
add_root_metadata_fallback(app, "/mcp")

OIDC プロバイダー側 (Django OAuth Toolkit)

メタデータを issuer 直下で配信する

DOT は通常 /o/ にマウントするので、discovery は /o/.well-known/openid-configuration にしか生えません。しかし issuer がパスを持たない (https://id.example.com) なら、メタデータはホストのルート直下に無ければなりません

# urls.py
from oauth2_provider.views import ConnectDiscoveryInfoView, OAuthServerMetadataView

well_known = [
    path("openid-configuration", ConnectDiscoveryInfoView.as_view()),
    path("oauth-authorization-server", OAuthServerMetadataView.as_view()),
]

urlpatterns = [
    path("o/", include(oauth2_urls)),
    path(".well-known/", include(well_known)),
]

末尾スラッシュ無しのパスで登録してください。 APPEND_SLASH の 301 を挟むと失敗するクライアントがあります。

なお issuer がパスを持つ場合、URL の組み立て規則が 2 つの仕様で違います。 RFC 8414 は /.well-known/oauth-authorization-server/<issuer のパス>、OIDC Discovery は /<issuer のパス>/.well-known/openid-configuration です。パス無しの issuer なら両者は一致するので、この記事の例では問題になりません。

フロントエンドを CDN や静的ホスティングで配信していて、API だけ別オリジンに向けている構成なら、/.well-known/* を API 側に向けるルーティングも要ります。ここを忘れると Django 側を直しても届きません。

OAuth Application を登録する

Django Admin から手動で作ります。

項目
Client type Confidential
Authorization grant type Authorization code
Redirect URIs https://claude.ai/api/mcp/auth_callback
Algorithm RSA with SHA-2 256 (RS256)
Skip authorization オフ

Algorithm を No OIDC support にしてはいけません。 Claude は openid を含むスコープで来るため、oauthlib が ID トークンを作りにいきます。

# oauthlib/openid/connect/core/grant_types/base.py
def add_id_token(self, token, token_handler, request, nonce=None):
    # Treat it as normal OAuth 2 auth code request if openid is not present
    if not request.scopes or 'openid' not in request.scopes:
        return token

この直後に response_type を見る分岐もありますが、認可コードフローのトークン要求には response_type が無いので素通りし、ID トークンの生成に進みます。結果として No OIDC support だと Application.jwk_keyNone になり、トークンエンドポイントが 500 で落ちます。認可画面までは進んでコード交換で死ぬので、原因が分かりにくい失敗の仕方をします。

RS256 を使うには OAUTH2_PROVIDER['OIDC_RSA_PRIVATE_KEY'] の設定が要ります。

なお openid を一切要求しない設計 (独自スコープだけで introspection 検証する) なら No OIDC support が正解です。アルゴリズムの選択は好みではなく、「ID トークンを発行するかどうか」から決まります。

動的クライアント登録は開けなくてよい

DOT には DCR の実装自体がありますが、既定で無効で、既定の権限クラスは登録用アクセストークンを要求します。Claude の DCR は匿名なので、対応するにはインターネットの誰でも OAuth Application を作れる状態にする必要があります。

社内向けの private なコネクタなら、カスタムコネクタとして client_id / client_secret を手入力する運用で十分です。

トークンの検証は introspection で行う

MCP サーバー (= リソースサーバー) が受け取った Bearer トークンを検証する方法は 2 つあります。

  • UserInfo エンドポイント (OpenID Connect Core 1.0 §5.3) を叩いて 200 が返るか見る
  • Token Introspection (RFC 7662) で検証する

どちらも標準仕様です。UserInfo は OIDC Core の一部なので、たいていのプロバイダーにあります (ただし Discovery のメタデータ上は RECOMMENDED であり、必須ではありません)。Introspection は OAuth 2.0 の拡張仕様なので、実装されていないこともあります。

どちらもエンドポイントの URL は仕様で決まっていません。 discovery ドキュメントから引いてください。この記事で /o/... というパスが出てくるのは Django OAuth Toolkit を /o/ にマウントした場合の例で、実装ごとに変わります。

注意: 2 つは別々のメタデータ文書に載ります。 userinfo_endpoint は OIDC Discovery、introspection_endpoint は RFC 8414 のメタデータです。DOT の場合、OIDC 側の文書に introspection_endpoint は含まれません。

# UserInfo は OIDC Discovery
curl -sS https://id.example.com/.well-known/openid-configuration | jq .userinfo_endpoint

# Introspection は RFC 8414 の認可サーバーメタデータ
curl -sS https://id.example.com/.well-known/oauth-authorization-server | jq .introspection_endpoint

introspection を選んでください。 理由が 2 つあります。

1. UserInfo は resource バインドされたトークンでは叩けない

MCP の認可仕様に従い、Claude は認可リクエストに RFC 8707 の resource パラメーターを付けてきます。

GET /o/authorize/?...&resource=https%3A%2F%2Fmcp.example.com%2Fmcp

DOT 3.4 はこれを AccessToken.resource に保存し、トークンが使われるたびに audience を検証します。しかもデフォルトで有効です。

# oauth2_provider/settings.py
"RESOURCE_SERVER_TOKEN_RESOURCE_VALIDATOR": (
    "oauth2_provider.oauth2_validators.validate_resource_as_url_prefix"
),
# oauth2_provider/oauth2_validators.py  validate_bearer_token
if access_token.resource:
    request_uri = (getattr(request, "uri", None) or "").split("?")[0]
    if not access_token.allows_audience(request_uri):
        ... ("error", "invalid_token") ... return False

https://mcp.example.com/mcp に束縛されたトークンで、認可サーバー自身のエンドポイント (userinfo_endpoint) を叩けば当然一致しません。invalid_token の 401 が返ります。 これは認可サーバーが仕様どおりに動いている結果です。

audience を検証しない認可サーバーなら、同じことをしても動いてしまいます。ただしそれは「検証されていないだけ」で、リソースサーバー宛に発行されたトークンを認可サーバーのエンドポイントで使うのは RFC 8707 の趣旨に反します。いま動いていても、プロバイダーが検証を実装した時点で壊れます。

2. introspection なら audience・スコープ・有効期限が全部取れる

introspection のレスポンスから、検証に必要なものが揃います。

{
  "active": true,
  "scope": "openid email",
  "exp": 1785000000,
  "client_id": "...",
  "username": "user@example.com",
  "aud": ["https://mcp.example.com/mcp"]
}

introspection スコープは要らない

DOT の IntrospectTokenViewrequired_scopes = ["introspection"] を持っていますが、HTTP Basic のクライアント認証で叩く分には評価されません。

# oauth2_provider/views/mixins.py  ClientProtectedResourceMixin.dispatch
valid = self.authenticate_client(request)   # HTTP Basic が先に試される
if not valid:
    valid, r = self.verify_request(request) # ここで初めて required_scopes が効く

さらに authenticate_client は client_id の存在と client_secret の一致しか見ません (grant type も scope も見ない)。認可サーバー側にスコープを追加する必要はありません。

RFC 7662 §2.1 もクライアント認証による保護を認めているので仕様上も妥当ですが、これは DOT の dispatch の評価順に依存する挙動でもあります。バージョンを上げたら実際に叩いて確かめてください。

資格情報は 2 つに分ける

OAuth Application を 2 つ登録してください。

Application 用途 grant type client_secret の置き場所
クライアント用 Claude がユーザーを認可する Authorization code Claude のコネクタ設定のみ
リソースサーバー用 MCP サーバーが introspection を叩く Client credentials MCP サーバーの環境変数のみ

1 つで兼用しても動きますが、分ける理由が 3 つあります。

  1. 役割が違う。 前者は OAuth クライアント、後者はリソースサーバーが自分を名乗るための資格情報です。兼用すると、MCP サーバーの Pod が「OAuth クライアントとして振る舞える資格情報」を抱えることになります
  2. ローテーションが連動しない。 兼用だと secret を回すたびに Claude 側の設定とサーバー側の設定を同時に更新する必要があり、片方だけだとどちらかが壊れます
  3. secret のコピーが増えない。 同じ値が 2 箇所に置かれる状態を避けられます

リソースサーバー用は grant type が実質何でも通りますが、Client credentials にしておくと役割が読んで分かります。リダイレクト URI も不要です。

セキュリティ上の注意

audience を必ず検証する

これが最重要です。 introspection が通っただけでは「認可サーバーが発行した有効なトークン」しか分かりません。別のアプリケーション向けに発行されたトークンでも通ってしまいます。 MCP の認可仕様が求める「自分宛でないトークンを受けない」を満たすには、自分で aud を見る必要があります。

FastMCP の IntrospectionTokenVerifieractive / exp / required_scopes までしか見ないので、継承して足します。

判定の方針は 3 つです。

  1. aud があれば、自分の resource (base_url + MCP のパス) が完全一致で含まれることを要求する
  2. aud が無ければ、client_id がクライアント用 Application (後述) と一致することを要求する
  3. どちらも判定できなければ拒否する
import logging

logger = logging.getLogger(__name__)


class ResourceBoundIntrospectionTokenVerifier(IntrospectionTokenVerifier):
    def __init__(self, *, resource: str, expected_client_id: str, **kwargs):
        super().__init__(**kwargs)
        self._resource = resource
        self._expected_client_id = expected_client_id

    async def verify_token(self, token):
        access_token = await super().verify_token(token)
        if access_token is None:
            return None
        error = self._binding_error(access_token.claims or {})
        if error is not None:
            logger.warning("rejected a token that is not for this resource: %s", error)
            return None
        return access_token

    def _binding_error(self, claims: dict) -> str | None:
        audiences = _parse_audiences(claims.get("aud"))
        if audiences is None:
            return "unparsable aud claim"
        if audiences:
            if self._resource in audiences:
                return None
            return f"audience mismatch: {audiences}"
        if claims.get("client_id") == self._expected_client_id:
            return None
        return "client_id mismatch and no audience to fall back on"


def _parse_audiences(value) -> list[str] | None:
    """aud を文字列のリストにする。解釈できない型なら None。"""
    if value is None:
        return []
    if isinstance(value, str):
        return [value] if value.strip() else []
    if isinstance(value, list):
        return [str(item) for item in value if item]
    return None

部分一致にしないでください。 前方一致にすると /mcp/mcp-admin を混同します。サブドメインも別物として扱うべきです。

2 番目のフォールバックには限界があります。 introspection の client_id は「トークンを要求した OAuth クライアント」の識別子であって、audience ではありません (RFC 7662 §2.2)。一致したところで「このトークンが自分向けに発行された」ことの証明にはなりません。「このクライアント用 Application は、この MCP サーバー専用に登録してある」という運用上の前提があって初めて意味を持つ判定です。RFC 8707 の audience 検証と同等ではありません。

それでも残しておくのは、クライアントが resource を送らなくなったときに「少なくとも別アプリ向けのトークンは弾く」という保証だけは維持したいからです。resource が送られてくる限り、経路 1 で判定されます。

aud が文字列でも配列でもない型で来た場合も拒否します (_parse_audiencesNone を返す経路)。パース失敗を「制限なし」に倒すと、認可サーバー側の異常が audience 検証の素通りに化けます。

一方、空の aud ([]) は「制限なし」として扱ってください。 これは DOT 固有の互換のための扱いです。DOT の内部表現では空リストが「特定のリソースサーバーに限定しない」を意味し、現在の実装は空のとき aud 自体をレスポンスに含めません。将来含めるようになったときに全認証が落ちるのを避けるための保険です。

なお JWT の aud の規則 (RFC 7519 §4.1.3) では、aud が存在するなら自分が含まれない限り拒否します。空配列を通すのはその規則からの逸脱なので、使っているプロバイダーが空配列に別の意味を持たせているなら、そちらに合わせてください。

認証と認可は別物

「認証が通ること」と「このサーバーを使ってよいこと」は違います。 自前の OIDC プロバイダーが社外ユーザーの登録も受け付けているなら、認証だけでは足りません。

実用的な絞り方は 2 つです。

  • ログイン名 (email) のドメイン — introspection の username から判定する
  • 所属組織 — 組織の概念があるなら、そのメンバーであることを要求する

ドメインで絞る前に、そのプロバイダーで username が何を意味するかを確認してください。この記事の例が成り立つのは、USERNAME_FIELD が email で、登録時にメールアドレスの所有確認が済んでいるからです。ユーザーが任意に変更できるログイン名を認可の根拠にすると、なりすましを許します。

ドメイン判定は雑に書くと抜けます。

  • ドメイン部は完全一致で見る。 後方一致・部分一致にすると attacker@evil-example.com が通ります。サブドメイン (user@sub.example.com) も別物として扱います
  • @ が 2 つ以上ある文字列は拒否する。 「最後の @ より後ろ」で切ると、outsider@other.com@example.com の後ろ半分が許可ドメインと解釈されます
  • 比較は小文字同士 (DNS のドメイン名は大文字小文字を区別しない)
  • 空白を trim してから比較しない。 user@ example.com が通ってしまいます

正規表現で縛るのが安全です。

import re

_EMAIL_PATTERN = re.compile(
    r"^[^@\s]+@((?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,})$",
    re.IGNORECASE,
)

所属組織で絞る場合、標準の introspection レスポンスには組織情報が入っていません。RFC 7662 は独自メンバーの追加を認めているので、拡張クレームとして足すのが素直です。DOT なら IntrospectTokenView を継承し、include(oauth2_urls) よりに登録します。

# urls.py — include(oauth2_urls) より前に置く (先勝ちで解決される)
urlpatterns = [
    path("o/introspect/", CustomIntrospectTokenView.as_view(), name="introspect"),
    path("o/", include(oauth2_urls)),
]
import hashlib
import json

from django.http import JsonResponse
from oauth2_provider.models import get_access_token_model
from oauth2_provider.views import IntrospectTokenView as BaseIntrospectTokenView


class CustomIntrospectTokenView(BaseIntrospectTokenView):
    @staticmethod
    def get_token_response(token_value=None):
        response = BaseIntrospectTokenView.get_token_response(token_value)
        if response.status_code != 200:
            return response
        data = json.loads(response.content)
        if not data.get("active"):
            return response

        user_id = _get_token_user_id(token_value)
        if user_id is None:
            return response   # 付けずに返す = リソースサーバー側で拒否される

        data["organization_ids"] = [
            str(organization_id)
            for organization_id in Organization.objects.filter(
                organizationuser__user_id=user_id,
                organizationuser__status=ACCEPTED,
            ).values_list("id", flat=True)
        ]
        return JsonResponse(data)


def _get_token_user_id(token_value):
    # DOT はトークンを token_checksum (SHA-256 の hexdigest) で索引している
    checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest()
    return (
        get_access_token_model()
        .objects.filter(token_checksum=checksum)
        .values_list("user_id", flat=True)
        .first()
    )

get_token_response はトークン文字列しか受け取らないので、ユーザーを引き直す必要があります。DOT はトークンを token_checksum (SHA-256 の hexdigest) で索引しているので、同じ方法で引きます。ライブラリ側が索引の作り方を変えたら追従が要る箇所なので、テストで固定しておくと安全です。

なお DOT は既定でアクセストークンを平文でも保存します。 token_checksum は索引用で、ハッシュ化して保管しているわけではありません。RFC 9700 に沿って平文を残さない運用にしたいなら COMPLIANT_BCP_RFC9700_TOKEN_STORAGE を有効にしてください (既定は無効)。

所属の状態にも注意してください。招待されただけで承認していないユーザーを所属として扱わないよう、ステータスで絞ります。

本体のロジックを丸ごと書き直さないでください。 結果を受け取ってから足す形にしておくと、ライブラリがレスポンス項目を増やしたときに追従できます (実際 aud は DOT 3.4 で増えました)。

判定できないときは拒否する

設計の指針として一貫させてください。

状況 挙動
username が無い / email 形式でない 拒否
拡張クレームが無い (プロバイダー側が未デプロイ) 拒否
クレームの型が想定外 拒否
設定値が不正 (UUID でない等) 起動時に落とす

「判定できないから通す」に倒すと、制限が黙って無効になります。設定値の検証を起動時に行うのも同じ理由で、間違った設定のまま動き続けるより、起動しないほうが気付けます。

なお、許可リストを空にすることで制限を外せる設計にするなら、空のときは起動時に警告ログを出しておくと事故に気付けます。

許可リストの意味を名前で示す

複数の値を許可リストに書けるようにする場合、リスト内が OR (どれか 1 つ) なのか AND (全部) なのかを名前で分かるようにしてください。 REQUIRED_ORGANIZATION_IDS のような名前だと「列挙した全部に所属していること」と読めますが、実装が intersection (any-of) なら意味が逆です。

「条件を厳しくしよう」と 2 つ目の値を足したときに、意図と逆に許可範囲が広がることになります。ALLOWED_ で始めるのが無難です。

拒否は 401 になる (403 ではない)

ここは仕様上の制約として知っておいてください。FastMCP には verify_tokenNone を返す以外の拒否経路がありません。認可の失敗 (このユーザーは使ってよくない) でも、クライアントには 401 が返ります。

スコープ不足も同じです。IntrospectionTokenVerifierrequired_scopes を自前で先に検査して None を返すため、MCP SDK 側の 403 insufficient_scope の経路には到達しません。

その結果、許可されていないユーザーから見ると再認証を繰り返す形になります。切り分けのためにサーバー側で拒否理由をログに残しておいてください。

キャッシュは失効を遅らせる

introspection はトークンごとに認可サーバーへの往復が発生するので、結果をキャッシュしたくなります。FastMCP の IntrospectionTokenVerifier にも cache_ttl_seconds があります (既定は無効)。

キャッシュしている間は、トークンの失効が反映されません。 ユーザーの連携解除や管理者によるトークン取り消しが、最大で TTL の分だけ遅れて効きます。introspection の利点の 1 つが「リアルタイムな失効の反映」なので、TTL を延ばすほどその利点を捨てることになります。

秒単位の失効反映が要る用途なら 0 (無効) に、そうでなければ数十秒程度に留めるのが無難です。

秘密情報と個人情報を出さない

  • アクセストークンの値をログにもツールの戻り値にも出さない。アクセスログには present / absent だけ。クエリ文字列もキー名だけにする。現行の Best Current Practice である RFC 9700 §4.3.2 は URI クエリでのトークン送信を MUST NOT としており (RFC 6750 の時点では NOT RECOMMENDED)、MCP の認可仕様も禁止しています。それでも誤って送ってくる実装に備えて、値をログに残さない実装にしておきます
  • ツールの戻り値は allowlist で組む。 introspection のレスポンスを丸ごと返すと、プロバイダー側でフィールドが増えたときに黙って漏れます。返してよい項目を明示的に選び、それ以外はキー名のリストだけ返す
  • 拒否ログに個人を特定する情報を書かない。 切り分けにはドメイン名で足ります
  • 秘匿値を含みうるコマンドの出力を、そのまま端末に出さない。 端末のスクロールバック、ターミナルのログ、画面共有、AI エージェントの会話ログに残ります。確認したいときはキー名だけに絞る
your-env-dump-command | sed -E 's/=.*/=<set>/'

introspection のレスポンスは同意でゲートされない

知っておくべき性質です。introspection エンドポイントは、有効な client_id / client_secret の組を持つ Application なら誰でも叩けます。 DOT の Basic 認証は client type すら見ておらず、client_id が存在することと secret が一致することだけを検証します。呼び出し元とトークンの関係 (audience や、そのトークンを発行された相手かどうか) は一切見ません。

そして DOT 標準の introspection は username (= ユーザーの email) を、email スコープの有無に関係なく返します。

if token.user:
    data["username"] = token.user.get_username()

つまり、UserInfo 側にあるスコープ同意のゲートは introspection では効きません。拡張クレームを足すときは、それが「トークン文字列を持っている全ての confidential アプリから読める」ことを前提に判断してください。

自己サービスでのアプリ登録 (DCR) を開けている場合や、第三者にクライアント資格情報を発行している場合は、リソースサーバーの client_id を許可リストで絞るか、スコープでゲートする必要があります。

送信元 IP を絞る

Claude からの接続は Anthropic の Outbound レンジから来ます。ingress-nginx なら:

nginx.ingress.kubernetes.io/whitelist-source-range: "160.79.104.0/21"

Inbound の 160.79.104.0/23 と間違えないでください。別物です。 公式ドキュメントの Outbound IPv4 を使います。

前提として、ingress が実際のクライアント IP を見えている必要があります。 クラウドのロードバランサーを前段に置いている場合、externalTrafficPolicy: Local や proxy protocol の設定が無いと、ingress からは LB の SNAT 後の IP しか見えません。そのまま許可リストを入れると全遮断になるか、逆に LB の IP が通ってしまい実質無効になります。

入れる前に、必ず一度接続を成功させてアクセスログで実際の送信元を確認してください。順序を逆にすると、繋がらない原因が IP 制限なのか OAuth なのか切り分けられなくなります。

有効化後は手元からの curl も 403 になります。また ingress で落ちたリクエストはアプリのアクセスログに一切残りません。繋がらなくなったときはまず ingress のログを見てください。

運用上の注意

プロセスは 1 つに保つ

FastMCP の既定 (stateful) では、Streamable HTTP のセッションがプロセス内に保持されます。複数ワーカー / 複数レプリカにすると、mcp-session-id を発行したプロセスと次のリクエストを受けるプロセスが食い違い、セッションが見つからなくなります。

(FastMCP には stateless_http=True もあり、その場合はリクエストごとに transport を作るのでこの制約は外れます。以下はこの記事の stateful 構成での話です。)

uvicorn --workers 1 --no-proxy-headers main:app

Kubernetes なら replicas: 1 に加えて strategy: Recreate にします。RollingUpdate だと更新中だけ新旧 2 Pod が Service 配下に入り、同じ問題が起きます。

--no-proxy-headers は別の理由です。uvicorn の proxy_headersデフォルトが有効で、request.clientX-Forwarded-For の値で上書きされます。アクセスログで「ingress から来たピア IP」と「クライアントが名乗った IP」を区別できなくなるので、送信元を検証したいなら切っておきます。

SSE をバッファさせない

リバースプロキシの既定設定は、多くの場合レスポンスをバッファします。text/event-stream が流れなくなるので切ってください。無通信期間があるので読み取りタイムアウトも延ばします。

nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"

接続確認

デプロイしたら、Claude に登録する前に自分で叩いて確認します。

# 401 と WWW-Authenticate が返るか
curl -sS -D - -o /dev/null -X POST https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'

# protected resource metadata
curl -sS https://mcp.example.com/.well-known/oauth-protected-resource/mcp

# 認可サーバーのメタデータが issuer 直下にあるか
curl -sS https://id.example.com/.well-known/openid-configuration

メタデータはこうなっているはずです。resource に MCP のパスが残っていること、authorization_servers が issuer と文字列として一致していることを目視してください。

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://id.example.com"],
  "scopes_supported": ["openid", "email"],
  "bearer_methods_supported": ["header"]
}

introspection のクライアント認証も先に確かめておきます。存在しないトークンで {"active": false} が返れば、認証自体は通っています (資格情報が違えば 403 になります)。

curl -sS -u "$RS_CLIENT_ID:$RS_CLIENT_SECRET" \
  -X POST https://id.example.com/o/introspect/ -d 'token=dummy'

あとは Claude の設定 → コネクタ → カスタムコネクタを追加で、URL に https://mcp.example.com/mcp を入れ、詳細設定にクライアント用 Application の client_id / client_secret を入れます。

URL はメタデータの resource と完全一致させてください。 仕様が要求するのは一致であって「末尾スラッシュ禁止」ではありませんが、ユーザーが入力する URL・MCP のエンドポイント・メタデータの resource の 3 つを揃える必要があるので、スラッシュ無しに統一しておくのが事故が少ないです。

評価をお願いします (会員登録・ログイン不要)
まだ評価がありません
著者は、アプリケーション開発会社 Cyberneura を運営しています。
開発相談をお待ちしています。

アーカイブ