「FAPI 2.0」に準拠した、よりセキュアな認可フローを「Keycloak」で実装しよう
第5回の今回は、金融・医療等で求められる「FAPI 2.0」のセキュリティ要件(PAR・DPoP・private_key_jwt)をKeycloakで実装し、MCPの認可をより堅牢にする方法について解説します。
6:30
はじめに
前回の第4回では、最新のMCPの認可仕様について解説し、Keycloakを用いてその仕様に沿った認証・認可フローを実装する方法について解説しました。
第5回となる今回は「FAPI」というセキュリティプロファイルを用いた、よりセキュアな認証・認可フローを実現するための技術仕様について解説し、Keycloakを用いてその仕様に沿ったフローを構築する方法について解説します。
「FAPI 2.0」について
FAPIとは、OAuth 2.0およびOpenID Connectの仕様をベースとして、金融・医療・公共サービスなど機密性の高いデータを安全に扱うため、より厳格なセキュリティ要件を定義したセキュリティプロファイル(仕様の組み合わせ)です。
FAPIには複数のプロファイルが存在しますが、近年の主流はFAPI 2.0 Security Profileです(以降、“FAPI”という表現はこのセキュリティプロファイルを指すこととします)。このプロファイルが要求する仕様を満たすために、第3回で解説したOAuth 2.1の仕様に追加する必要のある主な要件を以下に示します。
PAR(Pushed Authorization Request)の必須化
通常のOAuthでは、認可リクエストは
/authorize?scope=...&redirect Uri=...のようにブラウザからURLで送られます。このように、ブラウザやURL上に認可リクエストの内容が露出すると、パラメータの改ざんや漏えいのリスクがあります。
これに対し、FAPIでは「PAR」を必須化しています。PARとは、クライアントが認可リクエストの内容を事前に認可サーバへ送信し、その内容を参照するためのrequest_uriをクライアントに返却する仕組みです。認可サーバは受け取ったリクエストを保存しておき、クライアントへrequest_uriを返却します。その後、ブラウザ経由の認可リクエストでは、クライアントはリクエスト内容そのものではなくrequest_uriのみを認可エンドポイントへ渡します。
これにより、認可リクエストの詳細がブラウザやURL上に露出せず、パラメータ改ざんや情報漏えいのリスクを低減できます。
Sender-Constrained Token (トークン盗難対策)
従来のOAuthでは、Bearerトークンは「持っている」だけで利用できました。そのため、ネットワーク盗聴やログへの出力、アプリケーションの脆弱性などによりアクセストークンが漏えいすると、攻撃者がそのトークンを使って正規ユーザになりすますことが可能でした。
FAPIではこのようなリスクを低減するため、原則としてBearerトークンの利用を認めず、アクセストークンをクライアントに結び付ける「Sender-Constrained Token(送信者制約付きトークン)」を要求しています。これにより、たとえトークンそのものが盗まれたとしても、正規のクライアント以外からは利用できなくなります。
この送信者制約付きトークンの実装方法には、以下の2種類が推奨されています。
①mTLS(Mutual TLS)
クライアント証明書を用いた認証方式です。本記事では扱わないため、詳細は割愛します。
②DPoP(Demonstrating Proof of Possession)
クライアントが公開鍵・秘密鍵のペアを保持し、HTTPリクエストごとに秘密鍵で署名した DPoP Proof を送信する方式です。認可サーバはアクセストークンをクライアントの公開鍵へ紐付けて発行し、リソースサーバはDPoP Proofの署名を検証することでトークンの正当な所有者であることを確認します。
mTLSのようなクライアント証明書基盤を必要とせず、アプリケーションレベルで実装できるため、近年はこちらが広く利用されるようになっています。
本記事では、②の DPoP を使用してFAPIが要求する送信者制約付きトークンを実装します。
クライアント認証方式の制約
OAuthでは、アクセストークンを発行する前に、認可サーバが「そのクライアントが正当なクライアントであること」を確認するためのクライアント認証を行います。
従来はclient_secretを用いた認証が広く利用されてきましたが、client_secretは単なる共有秘密情報であり、漏えいした場合に第三者が正規クライアントになりすますことが可能です。そのため、FAPIではclient_secretベースの認証は許容されず、より強固なクライアント認証方式の利用が求められます。
FAPIでは、以下の2種類が推奨されています。
①mTLS
前項でも登場した、クライアント証明書を使った認証方法です。本記事では扱わないため、詳細は割愛します。
②private_key_jwt
クライアントが保持する秘密鍵でJWTに署名し、そのJWTを認可サーバへ提示する認証方式です。認可サーバは事前に登録された公開鍵を用いて署名を検証し、正当なクライアントであることを確認します。秘密鍵そのものを送信する必要がなく、秘密情報の漏えいリスクを低減できるため、FAPIでは広く利用されています。
本記事では、②の private_key_jwt を使用してクライアント認証を行います。
ハンズオン
本稿執筆時点のKeycloakでは、前回で紹介したClient ID Metadata Document (CIMD)とPARの組み合わせに制限があります。そのため、本稿のハンズオンではクライアント登録方法として動的クライアント登録(Dynamic Client Registration:DCR)を採用します。なお、Keycloakコミュニティでは CIMD 機能の拡張が継続的に進められており、PARのサポートについても将来的に対応される可能性があります。
今回実装するハンズオン構成をまとめます。
- Keycloak
認可サーバ。localhost:8080で起動。ユーザー認証やアクセストークンの発行を行い、FAPIの各種セキュリティ要件を適用します。 - mcp_client_05.py
MCPクライアント。localhost:3000で起動。Keycloakから取得したアクセストークンを使用してMCPサーバへアクセスします。 - mcp_server_05.py
MCPサーバ。localhost:9000で起動。アクセストークンを検証した上で、MCPクライアントからのリクエストに応じてMCPツールを提供します。アクセストークンの検証結果と、メッセージを返却する“protected_echo”ツールを実装しています。
筆者の実行環境および使用したライブラリを以下に示します。
| 実行環境/ Pythonライブラリ | Version | 用途 |
| OpenJDK | 24 | Keycloakの実行環境 |
| Keycloak | 26.3.3 | 認可サーバ |
| Python | 3.10.17 | MCPクライアント/MCPサーバ |
| uv | 0.6.16 | Pythonのパッケージ管理 |
| FastMCP | 3.4.3 | MCPの公式SDK |
| Authlib | 1.7.2 | 認証ライブラリ |
| Flask | 3.1.3 | クライアント用Webアプリケーション |
| cryptography | 49.0.0 | JWT署名・鍵生成・暗号処理 |
| PyJWT | 2.13.0 | JWTを扱うためのライブラリ |
| Python-dotenv | 1.2.2 | .envファイルからの設定値読み込み |
| HTTPX | 0.28.1 | HTTPリクエスト発行(非同期) |
| Requests | 2.34.2 | HTTPリクエスト発行 |
環境設定
- 本ハンズオンで使用するレルム“mcp-fapi2”を作成します。
- テスト用のユーザを作成し、パスワードも設定しておきます。
- DCRのための設定を行います。左ペインの“Clients”メニューから“Client registration”タブを開き、“Trusted Hosts”メニューを開きます。Trusted Hostsに“localhost”および“127.0.0.1”を設定しておきます。
- private_key_jwtでのクライアント認証に使う秘密鍵を作成します。
このコマンドで作成される秘密鍵“client-private.pem”は、DCRリクエスト時に公開鍵をKeycloakに登録するために使われるほか、PARやトークンリクエスト時のJWTの署名に使われます。本ハンズオン用の作業フォルダに置いてください。> openssl genrsa -out client-private.pem 2048 - 以下のコマンドでDPoP用の秘密鍵を作成します。
作成される“dpop-private.pem”は、所持証明(Proof of Possession)のための秘密鍵です。private_key_jwtの秘密鍵と同様に、本ハンズオン用の作業フォルダに置いてください。> openssl ecparam -name prime256v1 -genkey -noout -out dpop-private.pem - Keycloakのクライアントポリシーにて、FAPI用のクライアントプロファイルを設定します。これにより、認可サーバ側でセキュリティ要件を強制できます。クライアントポリシー機能についての詳細は第3回を参照してください。
左ペインのRealm settingsからClient policiesタブを選択し、さらにPoliciesタブを開いてCreate client policyをクリックします。
“fapi2-dpop-policy”というクライアントポリシーを作成します。 - Add conditionをクリックし、ポリシーを適用する条件を設定します。本ハンズオンでは、Confidentialクライアントに適用することとします。
Condition typeで“client-access-type”を選択し、Client Access Typeに“confidential”が追加されていることを確認してAddをクリックします。 - Add client profileをクリックし、適用するクライアントプロファイルを設定します。
DPoP用のFAPI 2.0 Security Profileである“fapi-2-dpop-security-profile”を選択し、Addをクリックします。 - 最後に、本ハンズオン用のGitHubディレクトリからMCPクライアント・MCPサーバ・.envファイルのサンプルを取得し、作業用のフォルダに配置してください。本項で用意した秘密鍵と同じフォルダに入れる必要があります。
<ハンズオン作業用フォルダー>/ ├── .env ├── mcp_client_05.py ├── mcp_server_05.py ├── client-private.pem ├── dpop-private.pem ├── …
以上で、環境設定は完了です。
正常系フローの実行
それでは、MCPクライアントおよびMCPサーバを起動し、アクセストークンの取得・アクセストークンを用いたMCPサーバのツール実行を試してみましょう。
> uv run mcp_server_05.py
> uv run mcp_client_05.py- ブラウザで https://localhost:3000 にアクセスすると、MCPクライアントのWebアプリケーションが開きます。
- 一番上の“Get access token for MCP Server”をクリックすると、まずDCRによるクライアント登録が行われます。その後、PARを用いた認可リクエストが送信され、Keycloakのログイン画面が表示されます。
- なお、ブラウザの開発者ツールを使うと、認可リクエストの内容を確認できます。リクエスト内のパラメータを見るとrequest_uriが含まれている一方、scopeやstate、nonceといったパラメータが含まれておらず、PARを用いた認可リクエストが送信されていることが分かります。
※図10はEdgeの場合の例です。リダイレクト後も通信履歴を残すために、予め“Preserve log”にチェックを入れておく必要があります。 - ログイン画面に戻り、登録済みのユーザーでログインします。ログイン処理と同意の取得後アクセストークンの取得に成功すると、下記のような画面に遷移します。ここでは、DCRにより登録されたクライアント情報の一部と、取得したアクセストークンの中身を出力しています。
- “Access MCP Server with this access token”ボタンをクリックします。これにより、取得したアクセストークンを使って起動中のMCPサーバにアクセスします。アクセスに成功すると、MCPクライアントはMCPサーバの“protected_echo”という名前のツールを呼び出し、その結果を画面に出力します。
- MCPサーバへのアクセスが成功し、“DPoP-protected MCP tool call succeeded”というメッセージが返ってきていることが分かります。
異常系の検証
それでは、いくつかのFAPIの要件を満たさない状態でフローを実行した場合に、アクセスが拒否されることを確認していきます。
client_secretによるクライアント認証
private_key_jwtではなく、client_secretによるクライアント認証を実施します。
アプリケーションのトップ画面にある、上から2つ目のボタン“Get access token for MCP Server (with client secret)”をクリックするとclient_secretによる認可コードフローが流れますが、下記のようなエラーでトークン取得に失敗します。
”invalid_client_metadata”というエラーが出ていることが分かります。これは、“token_endpoint_auth_method”: “client_secret_basic”というDCRリクエストが拒否されているためで、FAPIのクライアントプロファイルのうち、“secure-client-authenticator”というExecutorによるものです。
DPoPヘッダー削除
次は、PARとトークンリクエストからDPoPヘッダーを削除してみます。Login()関数内のPARで、以下のようにDPoPヘッダー部分をコメントアウトします。
par_response = requests.post(
PAR_ENDPOINT,
# headers={"DPoP": par_dpop_proof},
data={
"response_type": "code",
"client_id": client_id,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"client_assertion_type": CLIENT_ASSERTION_TYPE,
"client_assertion": client_assertion,
},
timeout=10,
)同様に、callback()関数内のトークンリクエストで、以下のようにDPoPヘッダー部分をコメントアウトします。
response = requests.post(
TOKEN_ENDPOINT,
# headers={"DPoP": token_dpop_proof},
data={
"grant_type": "authorization_code",
"client_id": client_id,
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
"client_assertion_type": CLIENT_ASSERTION_TYPE,
"client_assertion": client_assertion,
},
timeout=10,
)この状態で認可コードフローを流すと、以下のようなエラーが出ます。
DPoP Proofのないトークンリクエストが拒否され、400エラーが返ってきていることが分かります。これは、FAPIのクライアントプロファイルのうち、“dpop-bind- enforcer”というExecutorによるものです。
このように、FAPIの要件を満たさない認可フローは、Keycloakの設定により適切に拒否されることが分かります。
まとめ
本稿では、FAPI 2.0 Security Profileの要件であるPAR、private_key_jwt、DPoPについて解説し、Keycloakを用いてそれらを適用した認証・認可フローを実装しました。
OAuthやOpenID Connectは柔軟性の高い仕様である一方、そのままでは実装や設定によってセキュリティレベルに差が生じます。FAPIは、そのような自由度の高い仕様に対して明確なセキュリティ要件を定めることで、より高い安全性を実現するための指針を提供しています。そしてKeycloakでは、これらの要件をクライアントポリシーとして適用することで、認可サーバ側から一貫したセキュリティ基準を簡単に適用できます。
本連載では、MCPの認証・認可をテーマとして、OAuth 2.1の認可コードフローの基礎から始まり、Keycloakを用いた認証基盤の構築、MCP Authorization Specificationへの対応、そして本稿で取り上げたFAPIまで、段階的に解説してきました。MCPの普及に伴い、AIエージェントが機密性の高いデータや業務システムへアクセスする機会は今後さらに増えていくと考えられます。そのような環境では、「認証・認可を実装すること」だけでなく「どのようなセキュリティ要件のもとで実装するか」がますます重要になります。
本連載が、MCPサーバやAIエージェントを安全に運用するための認証・認可の理解、そして実践的な実装の一助となれば幸いです。
最後までお読みいただき、ありがとうございました。
この記事をシェアしてください



