0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastMCP の Google OAuth サーバーで refresh token を本当に効かせる

0
Last updated at Posted at 2026-09-09

GoogleProvider で MCP サーバーを立てると、OAuth は最初から動く。認可も通るしツールも呼べる。refresh も効いているように見える。サーバーを再起動するまでは。

これは後から直すバグというより、最初に決めておく設定の話だ。OAuthProxy の refresh の実装がそういう作りになっている。この記事では、refresh token を成立させるためにデプロイ側が満たすべき条件と、その満たし方を書く。

検証環境は fastmcp==3.4.2py-key-value-aio==0.4.5

OAuthProxy の refresh はストレージの参照

まず押さえるべき点。GoogleProviderOAuthProxy を継承していて、MCP クライアントが持っているトークンは上流 Google のトークンではない。プロキシが自前で発行した credential で、その実体は key-value ストアへの参照 だ。Google のトークンはサーバー側に留まる。

この設計自体はプロキシの利点そのもので、クライアントに上流の credential を一切見せずに、プロキシが代理でリフレッシュできる。同時に refresh には強い前提が生まれる。ストアにエントリが残っていること

FastMCP 3.4.2 のプロキシは6つのコレクションを持つ。

default_collection="mcp-upstream-tokens"        # 上流 Google のアクセス/リフレッシュトークン
default_collection="mcp-oauth-proxy-clients"    # DCR のクライアント登録
default_collection="mcp-oauth-transactions"     # IdP コールバックの途中状態
default_collection="mcp-authorization-codes"
default_collection="mcp-jti-mappings"
default_collection="mcp-refresh-tokens"         # リフレッシュトークンのメタデータ(ハッシュキー)

リフレッシュが来ると、プロキシは受け取ったトークンをハッシュ化してメタデータを引く。

token_hash = _hash_token(refresh_token)
metadata = await self._refresh_token_store.get(key=token_hash)
if not metadata:
    logger.warning(
        "Refresh token not found for client=%s (token_hash=%s); it was "
        "already rotated, expired, or revoked. Rejecting with invalid_grant, "
        "which forces the client to re-authenticate.", ...
    )

エントリが無ければ refresh は成立しない。invalid_grant を返し、クライアントはフルの再認可に落ちる。6つのうち2つ、mcp-refresh-tokens(grant 本体)と mcp-oauth-proxy-clients(その grant が紐づく DCR のクライアント登録)が、すべての refresh のゲートになっている。

つまり「refresh token に対応する」は、突き詰めると一つの問いになる。そのストアはどこにあって、誰から見えるのか

既定のストアはサーバー用途では答えを間違える

client_storage を渡さないと、プロキシが勝手に作る。

# fastmcp/server/auth/oauth_proxy/proxy.py
if client_storage is None:
    storage_encryption_key = derive_jwt_key(
        high_entropy_material=jwt_signing_key.decode(),
        salt="fastmcp-storage-encryption-key",
    )
    key_fingerprint = hashlib.sha256(storage_encryption_key).hexdigest()[:12]
    storage_dir = settings.home / "oauth-proxy" / key_fingerprint
    storage_dir.mkdir(parents=True, exist_ok=True)

    file_store = FileTreeStore(data_directory=storage_dir, ...)

    client_storage = FernetEncryptionWrapper(
        key_value=file_store,
        fernet=Fernet(key=storage_encryption_key),
        raise_on_decryption_error=False,
    )

ローカルディスク上の暗号化ファイルストア。長生きする単一プロセスなら理想的だが、再デプロイや水平スケールがある環境では成立しない。Cloud Run / Fly / ECS / Kubernetes のいずれもコンテナ FS はリビジョンごと・レプリカごとなので、refresh の状態はデプロイで消えるか、リクエストを捌いているインスタンスから見えないかのどちらかになる。

ここが直感に反するポイントなので触れておくと、JWT の署名鍵の方は揮発しない

if jwt_signing_key is None:
    if upstream_client_secret is None:
        raise ValueError(...)
    jwt_signing_key = derive_jwt_key(
        high_entropy_material=upstream_client_secret,
        salt="fastmcp-jwt-signing-key",
    )

上流クライアントシークレットから決定的に導出していて、シークレットは Secret Manager にある。だから再起動後もトークンの検証自体は通る。ただ参照先が無い。症状は署名エラーではなく /token の 401 として出る。

12:53:44  200  /.well-known/oauth-authorization-server
12:53:44  401  https://mcp-dev.example.com/token     ← リフレッシュが拒否されている
12:54:37  401  /mcp

同じストアが全インスタンスの土台なので、デプロイしていなくてもスケールアウトだけで同じ失敗が起きる。

共有ストアを設定する

client_storagepy-key-value-aioAsyncKeyValue なら何でも受け取る。FastMCP が依存として既に引いているので追加インストールは不要。0.4.5 には Firestore / PostgreSQL / Redis / S3 / DynamoDB / MongoDB のストアが同梱されていて、バックエンド選定はコードではなくデプロイの都合で決められる。

うちは Firestore にした。ランタイムのサービスアカウントの ADC で認証できるので、接続文字列もローテーション対象のシークレットも増えないのが決め手。

from cryptography.fernet import Fernet
from fastmcp.server.auth.jwt_issuer import derive_jwt_key
from key_value.aio.protocols import AsyncKeyValue

_JWT_SIGNING_KEY_SALT = "fastmcp-jwt-signing-key"
_STORAGE_ENCRYPTION_KEY_SALT = "fastmcp-storage-encryption-key"


def _derive_storage_encryption_key(client_secret: str) -> bytes:
    """FastMCP と同じ導出チェーンを再現する"""
    jwt_signing_key = derive_jwt_key(
        high_entropy_material=client_secret,
        salt=_JWT_SIGNING_KEY_SALT,
    )
    return derive_jwt_key(
        high_entropy_material=jwt_signing_key.decode(),
        salt=_STORAGE_ENCRYPTION_KEY_SALT,
    )


def build_client_storage(database: str, namespace: str, project: str, client_secret: str) -> AsyncKeyValue:
    from key_value.aio.stores.firestore import (
        FirestoreStore,
        FirestoreV1CollectionSanitizationStrategy,
        FirestoreV1KeySanitizationStrategy,
    )
    from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
    from key_value.aio.wrappers.prefix_collections import PrefixCollectionsWrapper

    return FernetEncryptionWrapper(
        key_value=PrefixCollectionsWrapper(
            key_value=FirestoreStore(
                project=project,
                database=database,
                key_sanitization_strategy=FirestoreV1KeySanitizationStrategy(),
                collection_sanitization_strategy=FirestoreV1CollectionSanitizationStrategy(),
            ),
            prefix=namespace,
        ),
        fernet=Fernet(key=_derive_storage_encryption_key(client_secret)),
        raise_on_decryption_error=False,
    )

プロバイダに差し込む。

auth_provider = GoogleProvider(
    client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID"),
    client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET"),
    base_url=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL"),
    client_storage=build_client_storage(...),
)

このラッパの積み方は全部意味がある。以下、層ごとの理由。

暗号化は自分で被せ直す

さっきの既定パスをもう一度見てほしい。FernetEncryptionWrapperif client_storage is None の中にいる。自前のストアを渡した瞬間、あの暗号化は適用されない。上流 Google のアクセストークンとリフレッシュトークンが mcp-upstream-tokens に、バックエンドが書く生の形(Firestore ならただのドキュメント)で載る。

鍵を同じクライアントシークレットから導出しておけば、管理対象のシークレットは増えず、守っている当の credential と一緒にローテートされる。

raise_on_decryption_error=False を明示する

FastMCP は自前のラッパに False を渡している。FernetEncryptionWrapper の既定は True

この差はクライアントシークレットをローテートした日に効いてくる。True だと古い暗号文で例外が飛んで 500 を返す。False ならキャッシュミス扱いになり、クライアントが再登録・再認証する。壊れるより劣化で済ませたいので FastMCP に合わせる。

バックエンドに合わせてキーをサニタイズする

OAuthProxy は CIMD(Client ID Metadata Document)を既定で有効にしている(enable_cimd: bool = True)。そして CIMD の client_id は URL だ。

if self._cimd_manager is not None and self._cimd_manager.is_cimd_client_id(client_id):
    cimd_client = await self._cimd_manager.get_client(client_id)
    if cimd_client is not None:
        await self._client_store.put(key=client_id, value=cimd_client)

この URL がそのままストレージのキーになる。Firestore のドキュメント ID に / は使えない。FirestoreStore の既定は無変換のパススルーなので、こちらから指定する必要がある。FastMCP の既定ストアが FileTreeV1KeySanitizationStrategy をわざわざ明示しているのも同じ理由で、どのバックエンドを選ぶにせよキーの制約は確認しておく。

DB を共有するならコレクションを prefix する

さっきの6つのコレクション名は定数だ。2つの MCP サーバーが同じ DB を向くと mcp-oauth-proxy-clients もその他も全部共有してしまう。PrefixCollectionsWrapper(prefix=<サービス名>) で名前空間を切る。Google OAuth の MCP を今後増やす前提なら、これがあって初めて「共有 DB 1個」という構成が成立する。

揮発ストアへフォールバックしない

ストレージのエラーを握って既定のファイルストアに落とす実装は書きたくなるが、やめたほうがいい。黙ってフォールバックすると、ストアを設定して避けたはずの挙動がそのまま戻る。しかも今度は見えない。サービスは健康に見えるのに、次のデプロイで全セッションが静かに消える。設定不備は起動時に落とし、実行時のストレージエラーは表に出す。

まとめ

  • OAuthProxy ではクライアントが持つトークンは key-value ストアへの参照。refresh は mcp-refresh-tokens の引き当てと、mcp-oauth-proxy-clients のクライアント登録で成立する
  • 既定のストアはコンテナローカルのファイルストアなので、再デプロイにもスケールアウトにも耐えない。JWT の署名鍵はクライアントシークレット由来で生き残るため、症状は署名エラーではなく /token の 401 になる
  • refresh token に対応するとは、共有の client_storage を渡すこと。そのうえで暗号化ラッパを被せ直し、raise_on_decryption_error=False を合わせ、バックエンドに合ったキーのサニタイズを指定する
  • DB を複数サーバーで共有するならコレクションを prefix で分離する。揮発ストアへの暗黙のフォールバックは作らない
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?