はじめに
こんにちは、ふくちと申します。
皆様、AIエージェントとチャットアプリを連携していらっしゃいますでしょうか。
私はチーム運営の作業をAIエージェントにどんどん寄せていく実験をしていて、その一環で「メンバー各自のClaude CodeからSlackを読み書きできるようにしたい」という要望が出てきました。
そこでAgentCore Gatewayを使ってやってみた結果、SlackのOAuth同意画面で「Allow」を押すところまでは全部順調だったのに、API呼び出しが
AccessDeniedException: Invalid or expired session
で失敗するという問題にぶつかりました。
エラーメッセージ的にはセッションが無効か期限切れなのですが、同意した直後にAPIコールしてもこれが返ってきます。恐らく無効でも期限切れではないはず。ということでこれについて色々調査し、ついでに認証認可周りのお勉強もしてみました。
本記事は、エラーの調査過程・最終的なアーキテクチャと解決策・調査の過程で学んだOAuth 3LO(3-legged OAuth)とセッションバインディングの解説記事です。
なお、AgentCore Gatewayのインバウンド認証で用いるSSOやOAuth 2.0・OIDCの基礎は前回の記事で詳しめに書いたので、今回はアウトバウンド認証、つまりエージェントが外部サービスへアクセスする際に利用者本人の権限でアクセスする部分がメインです。
今回やりたかったこと
やりたいことを整理すると、こんな感じです。
- チームメンバー各自が、自分のローカルのClaude CodeからSlackのチャンネル(プライベートチャンネル含む)を読めるようにしたい
- ただし見えてよいのは、その人自身がSlack上で見えるものだけ
- 認証の手間は最小限にしたい(デプロイのたびに再認証みたいな運用は避けたい)
Slackには公式のMCPサーバーがあり、これはSlackのユーザートークン、つまり利用者本人として振る舞うトークンで動く設計です。
本人のトークンで動くので、権限の境界はSlack側で管理してくれます。つまりプライベートチャンネルが見えるかどうかも、Slack上の本人の権限次第です。要件にはかなり合致してそうですね。
ただ、各自のMCPクライアントからSlack MCPへ直接つなぐ構成にはしませんでした。
理由は2つあります。1つは接続設定やトークンの管理が完全に個人任せになること。
もう1つは、今後Slack以外のMCPサーバーも増やしたときに、入口の認証を毎回別々に作りたくないことです。
各メンバーが必要なMCPサーバーそれぞれに接続設定をすると、こんな感じで矢印が大量に生えてしまいます。

そこでAgentCore Gatewayに複数のMCPサーバーを集約し、入口の認証は組織のIAM Identity Center(以下IIC)によるSSOに統一する構成を選びました。
ちなみにIICとAgentCore Gatewayの間にはCognitoを挟みます。なぜ挟むのかは後述します。
こうすると、各メンバーはAgentCore Gatewayだけ接続すれば良くなるので非常にシンプルかつわかりやすく効率的です。

結論と最終アーキテクチャ
全体のアーキテクチャはこうなります。
普段の利用時は図の実線、初回だけ図の点線のフローが必要です。
普段、利用者はClaude CodeをAgentCore Gatewayに繋いで利用します。
GatewayがCognitoのJWTを検証したうえで、その利用者のSlackトークンをAgentCore Identityのトークンボールトから取り出し、Slack公式MCPへリクエストを転送します。
ただし初回だけは、SlackのOAuth同意とその完了処理が必要です。
完了処理においては開始時と同一のトークン文字列が必要というという制約があるため、ログイン・AgentCore Gateway呼び出し・Slack同意・完了処理を1つのプロセス内で通して行うCLIを作り、各メンバーは初回に1回だけそれを実行すれば良い形にしました。
2回目以降は何もしなくてよく、デプロイしても再認証は発生しません。
そして今回の構成では認証が3つ登場し、それぞれに対応するトークンも3つ登場します。この辺りの仕組みと、AgentCoreでどう実装していけばよいかについて着目していければと思います。
| # | 認証 | 発行されるもの | 用途 |
|---|---|---|---|
| 1 | IICへのSSOログイン | SAML assertion | MCPサーバー利用者が組織の本物のメンバーであることをCognitoに伝える |
| 2 | Cognitoによるトークン発行 | アクセストークン(JWT) | AgentCore Gatewayの入口で確認 |
| 3 | SlackのOAuth同意 | Slackユーザートークン | Slack APIを本人の権限で呼ぶ |
用語の整理
フローの詳細解説へ入る前に、今回登場する概念を整理します。OAuth 2.0、OIDC、SAML、PKCEといった標準仕様そのものの説明は前回の記事に書いたので、ここでは今回の構成の理解に必要な範囲に絞ります。
大きく2つのカテゴリにわけて解説します。
- 一般的な概念や規格など
- AWSに関するもの
1. 一般的な概念や規格など
こちらはAWSに限らない、認証認可で用いられる一般的な用語を解説します。
IICとCognitoでのフェデレーション
前回記事で解説しているので簡単に。
フェデレーションというのは、あるシステムでの認証結果を、別のシステムが信頼する仕組みです。
この信頼関係があることで、一度のログインで複数のサービスへアクセスできるようになります。
フェデレーションには以下2つの用語が登場します。
- IdP(Identity Provider): 認証基盤側、本人確認して"認証済み"のステータスを発行する
- SP(Service Provider): サービス提供側、IdPの結果を受け取り検証してサービスを提供する
そしてSAMLは、IdPからSPへ「認証済み」という結果を渡すためのルールです。
SPは、IdPでのログイン完了を示すデータを受け取って検証し、ユーザーがサービスへアクセスできるようにします。
今回はIICがIdP、CognitoがSPなので下記の感じになります。
JWT(JASON Web Token)とクレーム
JWTは署名付きのJSONだと思ってください。
構成要素はトークンの種類や署名方式を示すヘッダー、クレームを格納するペイロード、改ざんを検知する署名の3部分です。
クレーム(claim)はJWTのペイロードに入る1項目です。実体はkey-valueの集まりで、代表的なものに iss(誰が発行したか)、 sub(どのユーザーのトークンか)、 aud(誰に宛てたトークンか)、 exp(いつまで有効か)などがあります。
JWTには署名が付いているので、受け取った側は発行者の公開鍵を使って改ざんされていないかを検証でき、これによってセキュアなエージェント呼び出しやMCPサーバー呼び出しができるようになるという寸法です。
2LOと3LO
OAuth 2.0におけるトークンの取得方法には、大きく2つあります。
2LO(2-legged OAuth)はClient Credentials Grantのことで、利用者を介さずアプリ自身の資格情報でトークンを取ります。登場人物がアプリと認可サーバーの2者なので2-leggedですね。
CognitoではMachine to Machine(M2M)と表現されたりするものです。

AgentCore RuntimeでAIエージェントをホスティングしており、そこからAgentCore GatewayをMCP接続する際などに使ったりします。詳しくは下記ブログをご参照ください。
一方、3LO(3-legged OAuth)はAuthorization Code grantのことで、利用者本人が同意画面でこのアプリに私の権限を渡して良いかを確認し、利用者に委譲されたトークンをアプリが受け取ります。
利用者、アプリ、認可サーバーの3者が登場するので3-leggedです。
今回の要件的には、各ユーザーの権限によって見られるSlackチャンネルが変化してほしいのでこちらが必須です。
つまり、ログインしてきた人の権限に基づいてAIエージェントに作業させたりMCPサーバーを使いたいときはこちらを採用するのが良いでしょう。
ちなみにこの構成でうまく動くと、下記のような形で同意が求められます。これが上記図の3.と4.にあたります。確かに「あなたに付与されている権限を<アプリ名>と共有します」って書いてますね。

MCPとエリシテーション
MCP(Model Context Protocol)は皆さんご存知、AIエージェントとツール群をつなぐプロトコルです。
その中で今回関係するのはエリシテーションです。直訳すると引き出す・誘い出すという意味。
MCPの文脈では、MCPサーバーがユーザーに対し追加情報を要求できる方法です。
エリシテーションは大きく2種類あり、フォームモードとURLモードの2つがあります。
- フォームモード:JSONスキーマを使用して応答を検証する構造化データをユーザーに要求できる
- URLモード:MCPクライアントを経由させてはならない機密性の高いやり取りのために、ユーザーを外部URLに誘導できる
ということで、使い分けとしては機密情報を扱うかどうかが1つになるようです。
つまり、パスワード・アクセストークン・支払い情報などの機密情報をやり取りする際はURLモードを使う必要があります。
例えば今回のケースでいくと、AgentCore GatewayはSlackのトークンが無いとき、ツール呼び出しへの応答としてエラーコード -32042 と認可用URLを返してきます。
これを受けてブラウザを開き、ユーザーに同意してもらう形になるんですが、これの裏側で使われているのがURLモードのエリシテーションというやつです。
セッションバインディング
3LOでは、「認可フローを開始した人」と「同意して完了させた人」が同一であることを保証する必要があります。
実例で考えてみると理解が進むと思うので少し例を挙げると、こんな感じです。
- 攻撃者が認可フローを開始し、認可URLを正規ユーザーに送り、正規ユーザーが同意する
- 正規ユーザーの権限に紐づくトークンが攻撃者の元に渡る
- 不正アクセスや不正利用に繋がる
- 認可URLが細工されている可能性もある
ということで、認可フローを始めた人と同意した人は一緒でないとだめです。代行申請は許さないということですね。
そこでセッションバインディングを行います。
OAuth 2.0/OIDCの認可フローでは、受け取ったコールバックが自分で開始したものによるものかをアプリ自身で確認できるように、フロー途中の値をアプリのセッションや認可イベントに結び付け、後で一致を検証するという形を取ります。
下記参考記事でも、特定のパラメータ名ではなく、値をどのセッションに紐付け、どこで検証するかという観点で整理されています。
セッションバインディングにおける一般的なフローを簡潔にまとめると次のようになります。
※Sはログイン済みセッション、rは今回のOAuth認可処理のために生成するランダムな値、nはOIDCを使う場合だけ生成するランダムな値です。
いくつか概念を解説しておきます。
stateは、アプリクライアントが認可を始めるときに生成するランダム値です。
アプリクライアントはこれを自分のセッションSと結び付けて保存し、認可リクエストにstate=rとして含めます。
認可サーバーはその値をコールバックするので、アプリクライアントは自分のセッションSに保存したrと戻ってきたrが同じかどうか検証します。
これがstateの役割です。認可の最初と最後をチェックするような形になっていますよね。
続いてnonce。これはOIDCでIDトークンを使う場合の値です。
アプリクライアントが認証リクエストでnonce=nを渡し、認可サーバーがIDトークンのnonceクレームに同じ値を入れて返します。それを受け取ったアプリクライアントはIDトークンの署名や発行者などを検証した上で、nonceがnと一致するかも確認します。
こちらも認可の最初ごと最後をチェックしています。
ではこの2つは何が違うのか。
stateは認可リクエストとコールバックの対応を確認するのに対して、nonceは認証リクエストとIDトークンの対応を確認します。
つまり、OAuthだけでOIDC(IDトークン)を使わない場合、nonce は登場しないのです。
ひとまずそれぞれstateはOAuth、nonceはOIDC、と紐づけておけば良いのではないでしょうか。
ちなみに、AgentCore Identityでも同じようなことを行います。
認可フローを開始したときの利用者のトークンとセッションを結び付けておき、完了時に同じトークンを提示させることで同一性を検証します。詳しくは後述。
2. AWSに関するもの
こちらはAWSで用いられる用語や概念を解説します。
Cognitoユーザープール
皆さんご存知、利用者向けの認証基盤サービスです。
今回の構成におけるCognitoの役割は、IICでの認証結果をJWTに変換することです。
なぜ変換する必要があるのか。IICの認証結果をAgentCore Gatewayにそのまま渡すことができないからです。
AgentCore GatewayはOAuth 2.0のJWTでインバウンド認証をします。一方IICはSAMLなので、そのまま繋げようとするとプロトコルが合いません。
そこでCognitoが間に入り、IICとはSAMLで連携して認証結果を受け取り、MCPクライアントに対してはOAuth 2.0のアクセストークン(JWT)を発行します。
もう少し具体的に整理します。
CognitoにはIICをSAML IdPとして登録します。これは前述した通り認証結果をIICから受け取る形にするためです。

そこで、属性マッピングとメタデータドキュメントというのを設定します。

属性マッピングとはSAML assertion(IICでの認証結果)に含まれるメールアドレス・氏名などの属性情報をCognitoのユーザー属性へ対応付けるを設定します。
ちなみにメタデータドキュメントのエンドポイントURLは、IIC側でSAMLカスタムアプリケーションを作成した際に取得できます。

こうしておくと、IICで認証されたユーザーが初めてログインすると、Cognito側に外部プロバイダー由来のユーザーとして自動的に作成されます。Cognitoへユーザーを個別に1人ずつ登録する必要はないので楽ちんですね。
ここで1つ注意する点としては、こうして作られたCognitoユーザーの sub はCognitoが採番したもので、IIC側のユーザーIDとは別物です。
同じ人間を指す識別子が複数の名前空間に存在するので、どの識別子の話をしているのかは整理しておくと良いと思います。
AgentCore Gateway
AgentCore Gatewayは複数のツールやMCPサーバーを1つのMCPエンドポイントに束ねるサービスです。
AgentCore Gatewayにおける認証はインバウンドとアウトバウンドの2つがあります。
インバウンド認証は文字通り入口の認証で、誰がAgentCore Gatewayへアクセスするかを確認します。
選択肢としてはIAM認証とJWT認証の2通りありますが、今回はCognitoを使ってJWTを検証します。
アウトバウンド認証は出口の認証で、AgentCore Gatewayが接続先にどの資格情報でアクセスするかを扱います。今回はSlackのユーザートークンを使ってSlackへアクセスします。というか基本的にはリソースサーバー(SaaSなどのサービス提供側)で必要な資格方法を用います。
つまり、CognitoのログインができたからといってSlackへアクセスできるわけではありません。インバウンド認証とアウトバウンド認証はそれぞれ完全に別物であることを頭に入れておきましょう。
AgentCore Identityとトークンボールト
AgentCore Identityは、外部サービスのOAuth設定やAPIキー、そして取得したトークンの保管を引き受けるサービスです。今回はAPI KeyではなくOAuthを用います。

OAuthクレデンシャルプロバイダーという設定単位で、どのサービスにどのOAuthクライアントとして繋ぐかを登録します。プロバイダー自体は色んな種類がありますが、自分が繋げたりものが用意されているかは事前に確認しておきましょう。
また、このフローの中で取得されたトークンはトークンボールトというところに保存されます。特に3LOの場合、トークンは利用者単位で分離して保管されます。
つまり、ユーザーAの同意したSlackトークンが、ユーザーBの呼び出しに使われることは無いということです。当たり前ですがセキュアですね。この利用者単位のキーがJWTのissとsubの組です。
より詳しく知りたい方は下記ご参照ください。
詳細な処理フロー
いよいよここからが本題です。インバウンド認証とアウトバウンド認証の2つに分けて、実際に何が起きているのかを一緒に理解していきましょう。
インバウンド認証のフロー
まず利用者がIICからCognitoを経てAgentCore Gatewayを呼べるようになるまでです。
ポイントを順に見ていきましょう。こちらは以前の記事で解説しているので軽くにしておきます。詳しく知りたい方は以下をどうぞ。
まずCognitoとIICの間はSAMLで連携します。CognitoがSP、IICがIdPです。
利用者から見るとMCPクライアント(Claude Code)でMCPの認証をしようとしたら、IICにログインを求められる体験になります。Entra IDとIICを連携しておくと、さらにもう一段SAML連携ができるので、Entra IDだけでMCPへの認証を通すことができるようになります。
次にCognitoとMCPクライアントの間はOAuth 2.0のAuthorization Code grant + PKCEです。
Claude CodeのようなローカルのCLIツールはクライアントシークレットを安全に保持できないので、PKCEでよりセキュアに認可コード交換を行います。
そしてトークン要求時に渡しているresourceについては前回解説していなかったので少し補足します。
リソースサーバーとは
OAuth 2.0の登場人物を復習すると、次の4者です。
| 役割 | 意味 | 今回の構成では |
|---|---|---|
| リソースオーナー | 権限を持つユーザー本人 | チームメンバー |
| クライアント | トークンを使ってAPIを呼ぶアプリ | Claude Code |
| 認可サーバー | トークンを発行する側 | Cognito+IIC |
| リソースサーバー | トークンを受け取り、保護対象のAPIを提供する側 | AgentCore Gateway |
今回の構成ではAgentCore Gateway自身がリソースサーバーにあたります。
リソースサーバーは提示されたトークンを検証し、これは自分宛てに発行されたものか・必要な権限を持っているかを確認してからAPIを提供します。
これを踏まえて、Cognitoのリソースサーバーを今回は追加で設定しています。
これは、このCognitoユーザープールが発行するトークンをどのAPIに対して使うつもりかを登録しておくためのものです。
今回はリソースサーバー識別子としてAgentCore GatewayのURLを登録しています。必要に応じてカスタムスコープを登録することもでき、ここで実行可能な操作の権限を絞ることができます。
CognitoのリソースサーバーにAgentCore GatewayのURLを登録しておくと、発行されるJWTのaudにAgentCore GatewayのURLが入ります。
これを使うと、AgentCore Gatewayは自分宛てのトークンだけを受け入れるので、よそのAPI用に発行されたトークンの使い回しを防げます。
ちなみに、これは/oauth2/authorizeと/oauth2/tokenの両方のリクエストに付ける必要があることに注意が必要です。
なぜリソースサーバーの登録が必要なのかというと、Cognitoの仕様上、Cognitoのアクセストークンにはデフォルトでaud(誰宛てのトークンか)クレームが入らないためです。入るのはclient_id(どのアプリクライアントが取得したか)だけです。
audを入れるには、次に説明するresourceパラメータ(Cognitoではリソースバインディングと呼ばれる機能)を使う必要があり、その前提として宛先のURLをリソースサーバー識別子として登録しておく、という関係になっています。
resourceパラメータとは何か
resourceは、RFC 8707(Resource Indicators for OAuth 2.0)で定義されたパラメータで、これから取得するトークンをどのリソースサーバーで使うつもりかを認可サーバーに事前申告するためのもののようです。
似た概念にscopeとaudがあるので、3つの関係をここで整理しておきます。
-
scope: そのトークンで何ができるか(権利の内容)を表す -
resource: そのトークンをどこで使うか(宛先のリソースサーバー)を表す -
aud: このトークンは誰宛てに発行されたものかを表す- 認可サーバーが
resourceで申告された宛先を、発行するトークンのペイロードに書き込んだクレーム
- 認可サーバーが
つまりresourceとaudは同じ宛先情報を表すのですが立場が対称的なもので、resourceはリクエスト側、audは発行されたトークン側に現れるという関係になっています。
もしaudがないと、AgentCore Gatewayはclient_id(どのクライアントが取得したトークンか)でしか検証できなくなります。
すると、同じCognitoユーザープールを複数のAgentCore Gatewayや別のAPIで共有した場合に、AgentCore Gateway①向けに取得したトークンをAgentCore Gateway②に提示しても通ってしまうというトークンの使い回しを防げません。
こういった脆弱性や穴を作らないため、このJWTがどこで誰に使われるものなのかを情報として含めておくことは非常に重要なんだそうです。
ちなみにこのresource、実は急に出てきた独自概念ではなく、MCPの認可仕様がRFC 8707の実装をMCPクライアントに必須レベルで要求しています。
仕様上、MCPクライアントは認可リクエストとトークンリクエストの両方にresourceを含め、値には接続先MCPサーバーの正規URIを渡すことになっています。
/oauth2/authorizeと/oauth2/tokenの両方に付けるのはこのためです。
まとめると、次の4ステップがセットで機能してはじめて、「このJWTは確かにこのリソース(AgentCore Gateway)向けに発行されたもの」というaudience単位の用途限定を実現できます。
- CognitoのリソースサーバーにAgentCore GatewayのURLを識別子として登録する
- MCPクライアントが
resource=<AgentCore Gateway URL>を付けてトークンを要求する - Cognitoが発行するJWTの
audにそのURLをセットする(リソースバインディング) - AgentCore Gatewayが受け取ったJWTの
audと自分のURL(=allowed audienceに自分のURLを設定したもの)を照合する
ただし、最後の照合のところは複数AgentCore Gatewayを1つのCognitoユーザープールなどで見る場合に有効ですが、単一AgentCore Gatewayの場合そこまで気にしなくても良いのかもしれません。
最後にAgentCore GatewayのJWT検証です。
AgentCore Gatewayには、このOIDC発行者のこのクライアントIDが発行したトークンだけを許可するという設定をしておきます。

AgentCore GatewayはJWTの署名を発行者の公開鍵で検証し、改ざんされていないこと、期限内であること、想定したクライアント経由であることを確認します。
ここまでがインバウンド認証の流れです。この時点で利用者はAgentCore Gatewayのツール一覧を見られますが、まだSlackにはアクセスできません。
アウトバウンド認証のフロー
続いて、3LOでSlackトークンをトークンボールトに入れる出口のフローです。
初回、つまりボールトにまだその利用者のSlackトークンが無い状態でツールを呼ぶと、次のフローが始まります。
こちらが本記事のメインなので、きちんと解説していきます。
シーケンス図が長いので、まず全体を4つのフェーズに分けて俯瞰しておきます。この後の解説もこのフェーズ順に進みます。
- 認可URLの発行
- ツールを呼ぶ→ボールトにトークンが無い→AgentCore Identityが認可URLを発行しセッションと開始時のJWTを結び付ける
- Slack同意とトークン交換
- 利用者がブラウザでAllowを押す→認可コード→Slackユーザートークンの交換は
AWS側が実施(トークンはまだ保存されない)
- 利用者がブラウザでAllowを押す→認可コード→Slackユーザートークンの交換は
- 完了処理(セッションバインディング)
- CLIがCompleteResourceTokenAuth APIを呼ぶ→開始時と同一のJWTを提示できたらトークンボールトへ保存
- 通常利用(2回目以降)
- AgentCore Gatewayがボールトから本人のトークンを取り出してSlackへアクセス
最初のツール呼び出しでAgentCore GatewayはAgentCore Identityのトークンボールトを確認しますが、この段階では保存されているトークンはありません。なのでAgentCore Identityは認可URLを作って返します。
このときAgentCore Gatewayは、インバウンド認証で受け取ったJWTをそのままAgentCore Identityに渡してセッションを開始しています。セッションはこの瞬間の、このトークン文字列と結び付きます。
つまりユーザーのJWTを渡すことで、誰のトークンなのかを結びつけられるようになるというわけです。
その後、利用者がブラウザで認可URLを開き、Slackの同意画面でAllowを押すと、SlackはまずAgentCore Identityのcallbackに認可コードを返します。ここでユーザーが同意をしているところが2LOにはない、3LO特有の操作になります。

そして認可コードをSlackユーザートークンに交換するのですが、ここの処理はAWS側がやってくれます。
交換が終わると、AgentCore Identityは事前に登録しておいたreturn URL(今回はローカルの http://localhost:3000/callback)へ、 session_idというクエリパラメータを付けてブラウザをリダイレクトします。
この時点でSlackユーザートークンはもうAgentCore Identityにあります。ところが、まだトークンボールトに保存はされていません。保存させるために使うのが、CompleteResourceTokenAuthというAPIです。
このAPIでは引数にsession_idとユーザー識別子(userId か userToken)を渡します。
今回のようにAgentCore GatewayがJWTでフローを開始した場合は、userTokenに開始時とバイト単位で同一のアクセストークン文字列を渡す必要があります。
このアクセストークンが同一であると確認されて初めて、トークンボールトにSlackユーザートークンが保存されます。難しい〜〜〜
文章だと分かりづらいので、この開始時と完了時の照合を図にするとこうなります。
AgentCore Identityの中にあるセッションでJWTのチェックを行い、開始時に預けたJWTと完了時に持ってきたJWTがバイト単位で一致したときだけ、トークンボールトへの保存が実行されるイメージです。
つまりセッションバインディングの節で見たstateの照合と発想は同じで、「認可フローの最初と最後で同じものを持っているか」を確認しているわけです。stateの代わりに、開始時のJWT文字列そのものが照合キーになっていると考えると理解しやすいと思います。
ここまで厳密にやる理由は、先に少し話した通り、3LOでは「認可フローを開始した人」と「同意して完了させた人」が同一であることを保証する必要があるからです。
もし同じユーザーなら何でもOKみたいな感じにしてしまうと開始者の確認が甘くなり、細工した認可URLを他人に踏ませる攻撃への耐性が下がります。
そこでトークン文字列を照合することで、開始したプロセスと同じ文脈にいることを強く保証しているらしいです。
逆に言うと、完了処理をする側は開始時のトークンを手元に持っていなければならず、これがアーキテクチャ上の制約になります。今回はClaude Code CLIを用いているので、完了処理までCLI上で行う形を取る必要がありました。
ログイン→AgentCore Gateway呼び出し→完了処理までを1つのプロセスに閉じ込めれば、同一のトークンをメモリ上で持ち回るだけで済むためです。
完了処理が通ると、Slackのトークンがトークンボールトに利用者単位で保存されます。
以降のツール呼び出しでは、AgentCore Gatewayが毎回AgentCore Identity経由でその利用者のトークンを取り出してSlackと接続します。
ちなみに一度トークンボールトに保存完了したあとは、そこまで厳密なチェックは行われません。つまり登録時と同一のユーザー識別子(userToken)が常に必要になるわけではないということです。
利用時はJWTのissとsubが同じであればOKで、ある程度緩いチェックになります。
厳密なチェックが行われるのはトークンボールトに保存するときだけのようです。
あと、一度保存したトークンボールトはAgentCore Identityのリソースを削除しない限り削除されないようです。
冒頭のInvalid or expired sessionの原因調査
ここまででフローの解説は一段落という感じですが、冒頭のエラーについて振り返りしておきます。まず状況を整理するとこんな感じです。
- Slackの同意もAWS側コールバックへの遷移も成功し、
session_idはローカルで受信できている - しかし
CompleteResourceTokenAuthを呼ぶとAccessDeniedException: Invalid or expired sessionのエラーが返ってくる - ユーザー識別子として、Cognitoの
subを渡してもusernameを渡しても同じ結果
これの原因調査として、まずドキュメントを確認しました。
先程のセッションバインディングのページには「開始時にフローを起動するのに使った、元のIdPのOAuthトークンまたはユーザーIDを渡す」とあります。
つまり、↓ここの/oauth2/tokenで取得していたトークンをそのまま渡す必要があるということです。
続いて、どんなフローでどんなAPIがコールされているのかをCloudTrailにて確認しました。
ここでAgentCore Gatewayが3LOを開始するとき、AgentCore Identityに対してGetWorkloadAccessTokenForJWTというAPIを呼んでいました。
これは受け取ったJWTを検証して、AgentCore内部の処理で使う利用者単位の作業用トークン(ワークロードアクセストークン)に引き換えるAPIです。
このAPIに渡っているのが利用者のJWTということは、セッションと結び付いているのはJWTであって、subのような識別子ではないわけです。
つまり照合するものを誤っていたため、先のエラーが出ていたということになります。
(余談:ワークロードアクセストークンに関しては、実は相当前の記事で解説していました。裏側ではAPIが動いていたようです。)
すなわち、3LO開始時にAgentCore Gatewayへ提示したアクセストークンの文字列そのものを、完了時にはuserTokenとして渡す必要がある、ということです。
ちなみに、存在しないデタラメなセッションIDを渡しても、識別子が不一致でも、返ってくるエラーは全く同じ Invalid or expired session でした。
つまりこのエラーメッセージはセッションの照合に失敗したことを指す多義的なもので、メッセージの字面から原因を特定することはできません。
エラーメッセージが多義的かどうかを疑って、わざと壊した入力で挙動を見るのは、切り分けの手として覚えておいて損がないと思います。
参考実装
今回作成したセットアップCLIから一部抜粋します。
// フロー全体で使い回しているアクセストークンを、そのまま完了処理にも渡すことが
// セッションバインディングの要件。userId(subなど)ではダメ。
await client.send(
new CompleteResourceTokenAuthCommand({
sessionUri, // callbackで受けた session_id。urn:ietf:params:oauth:request_uri: で始まる形式
userIdentifier: {
userToken: cognitoAccessToken, // 開始時と同一の文字列
},
})
);
実装する際は下記3点などに注意が必要そうです。
- アクセストークンはメモリ上でだけ管理する
- callbackで受けた
session_idはURLデコードした上で、urn:ietf:params:oauth:request_uri:プレフィックスが付いた完全な形にして渡す - 完了処理を呼ぶには、実行者のAWS資格情報に
bedrock-agentcore:CompleteResourceTokenAuthの許可が必要
まとめ
これでClaude CodeとSaaSや社内APIを一纏めにしつつ、セキュアに使うことができるようになりました。
巷ではMCP is dead等と言われますが、組織で利用する際・クラウド上のエージェントを使う際にAgentCore Gatewayは非常にありがたい存在です。私はこれからも推していきたいと思います。
その際、認証認可周りは避けて通れないのでこれからもしっかりとキャッチアップしていきたいですね。
学ぶ際は、今自分はどの識別子・どのトークンの話をしているのかを理解してから動くことが大事そうです。
今回の構成にはSAML assertion、CognitoのJWT、Slackのユーザートークンという3種類のトークンが登場し、さらにユーザー識別子もIICのIDとCognitoのsubという別の名前空間に分かれていました。
これを雑に整理したまま・理解が浅いまま進めるとかなり難しかったですが、Claudeなどの力も借りて理解を進めていくと徐々にわかってくるようになりました。
引き続きAgentCore Gatewayの使い方を探求していきます!
私自身まだまだ認証認可を勉強中の身なので、間違いなどあればご指摘いただけるとありがたいです。


