
Amazon Bedrock AgentCore Gatewayでユーザー委任型認可(3LO)を使い、GitHub・Slack・GoogleカレンダーをAIエージェントにつないでみた
はじめに
こんにちは、スーパーマーケットが大好きなコンサルティング部の神野(じんの)です。
夏真っ盛りで暑いですよね。暑さを吹き飛ばすのにスーパーで安いアイスを買うのにハマっています。
話は全く変わって、この記事はクラスメソッドの有志による『夏休みの自由研究リレー』第6回のエントリです!単なる「やってみた」ではなく、システムを作り込んだり、ドキュメントにない挙動を検証したり、なぜその設計にしたのかを掘り下げる企画なので、今回はしっかりと取り組んで書きました!
モチベーション的な話
以前、Amazon Bedrock AgentCore Identityでユーザー委任型認可(以下3LO)を実装して、エージェントからGoogle Driveにアクセスする記事を書きました。
前回はエージェント本体こそAgentCore Runtime上で動いていたものの、OAuthのコールバックを受けるサーバーはローカルPC上のFastAPI、Session Bindingに使うトークンも環境変数で手渡しという構成でした。仕組みの理解を優先してローカルも交えていたので、あまり実践的ではありませんでした・・・
前回の記事の最後に、認証フローを全てクラウド上でシームレスに実装したいと書いたので、今回はその宿題回収(夏休みらしく)です!!
URLを1つ渡せばログインから認可まで完結するように、フロントエンドはAmplifyで手っ取り早くホスティングし、今回は欲張って、GitHub・Slack・Googleカレンダーの3サービスを1つのGatewayにつなぎ、AgentCore Gatewayのユーザー委任型でアウトバウンド認証として構成しました!
3LOかつGatewayのケースはとっつきづらいことも多いと思うので、皆様のサンプルになれば嬉しいです。
コードは一式GitHubに公開しています。この記事では説明のため、細部を削っているところもあるので必要に応じてご参照ください!
前回のおさらい(3LOの登場人物)
詳細は前回記事に譲りますが、AgentCore Identityで3LOを実装する際の登場人物を簡単におさらいしておきます。今回はここにGatewayが加わります。
| 用語 | 役割 |
|---|---|
| Credential Provider | 外部サービスへの接続設定。クライアントIDやシークレットを登録する |
| Token Vault | ユーザーのアクセストークンを安全に保管する金庫。ユーザー × ワークロード × Providerの組み合わせでトークンを区別する |
| Session Binding | OAuth完了後、その認可がどのユーザーのものかをToken Vaultに紐付ける処理 |
| Gateway | 既存のAPIやMCPサーバーをエージェント向けのMCPツールに変換する入口。アウトバウンド認証として3LOを構成できる |
3LOのフローでポイントになるのがSession Bindingです。ユーザーが外部サービス側で認可を完了すると、AgentCore Identityからアプリケーションのコールバック画面へ session_id 付きでリダイレクトされます。このsession_idとどのユーザーの認可だったのかを紐付けてあげないと、Token Vaultにトークンが保存されず、エージェントはいつまでもトークンを取得できません。
前回はこの紐付けをローカルのFastAPIサーバーで行っていましたが、今回はAPI Gateway + Lambda + DynamoDBでサーバーレス化します。
今回のアーキテクチャ
全体像を図にすると、こんな構成になりました。

| コンポーネント | 使用サービス | 役割 |
|---|---|---|
| フロントエンド | AWS Amplify Hosting(React SPA) | チャットUI・OAuthコールバック画面・事前連携設定パネル |
| ユーザー認証 | Amazon Cognito(Amplify Auth) | アプリへのログイン。アクセストークンをRuntime・Gatewayの呼び出しにも利用 |
| エージェント | AgentCore Runtime + Strands Agents | GatewayのMCPツールを呼び出すエージェント |
| ツール基盤 | AgentCore Gateway | 3つのターゲットを中継し、アウトバウンド認証(3LO)でトークンを付与 |
| 会話記憶 | AgentCore Memory | 会話UUIDごとの会話履歴の保持 |
| 認可基盤 | AgentCore Identity | サービスごとのCredential ProviderとToken Vault |
| Session Binding API | API Gateway + Lambda + DynamoDB | 認可フローの記録とSession Bindingの完了 |
Gatewayのターゲットは下記の3つです。
| ターゲット | 接続方式 | 接続先 |
|---|---|---|
| GitHub | MCPサーバーターゲット(静的ツールスキーマ) | GitHub公式リモートMCPサーバー |
| Slack | MCPサーバーターゲット(静的ツールスキーマ) | Slack公式リモートMCPサーバー |
| Googleカレンダー | OpenAPIターゲット | Google Calendar API(REST) |
あれ、AgentCore GatewayにはSlack Web向けの組み込みテンプレートあるよね?と思った人もいると思います。
確かに組み込みテンプレートでコンソールから手軽にターゲットを追加できます。
ただし、執筆時点でGitHubとGoogleカレンダーのテンプレートはなく、Slackテンプレートのアウトバウンド認証もAPIキー方式とドキュメント上は記載があります。
自由研究らしく、今回は3サービスをユーザーごとの3LOでそろえ、GitHub・Slackでは公式MCPサーバーとの接続も検証したかったため、SlackもテンプレートではなくMCPサーバーターゲットを選びました!
処理の流れをシーケンス図にすると下記のようになります。どのサービスでも流れは同じで、初回のツール呼び出しが認可URLの返却(URL elicitation)になり、ユーザーの認可とSession Bindingを挟んで、リトライで処理が再開されます。
ぐえぇぇ・・・となるぐらい長いシーケンスですね・・・
紐解いていきます!
認可はサービスごとに独立しています。GitHubの質問をすればGitHubの認可リンクが、カレンダーの質問をすればGoogleの認可リンクが出て、それぞれ一度認可すればToken Vaultにトークンが保管され、以降は認可なしで即座に回答が返ってきます。
また、エージェントからGatewayへの認証にエンドユーザーのJWTをそのまま引き渡します。Token Vaultのユーザー識別はGatewayのインバウンドJWTをもとに行われるため、ここでエージェント自身のM2Mトークンを使ってしまうと、全ユーザーのトークンが1つのIDに紐付いてしまい、ユーザー委任が難しくなります。
トークンの取得・保管・付与はGatewayとToken Vaultの間で完結するので、エージェントのコードにもフロントエンドにも外部サービスのトークンは登場しません。ここは後半部分の考察でもう少し掘り下げます。
前提
今回の環境は下記のとおりです。
| 項目 | 値 |
|---|---|
| リージョン | us-east-1(バージニア北部) |
| Python | 3.12 |
| Node.js | 24.x |
| パッケージ管理 | uv(Python) / pnpm(Node.js) |
| エージェントフレームワーク | Strands Agents 1.45.0 + MCP Python SDK 1.28.1 |
| AgentCore SDK | bedrock-agentcore 1.15.1以降 |
| モデル | Claude Haiku 4.5 |
| フロントエンド | React(Vite) + AWS Amplify Gen 2 |
| IaC | aws-cdk-lib 2.261.0以降(aws_bedrockagentcoreモジュールを使用) |
今回は検証が目的で、エージェントとツールの接続がメインなのでHaikuを選択しました。
事前準備として、GitHubアカウント、Appをインストールできる権限のあるSlackワークスペース、OAuthクライアントを作成できるGCPプロジェクトが必要です。
構築
全体の流れは下記の5ステップです。エージェントやフロントエンドのコードはリポジトリに譲り、3LOとターゲットまわりに絞って見ていきます!
- 各サービス側でOAuthアプリを作成
- クライアントシークレットをSecrets Managerに登録
- エージェントの実装(3LO認可フック + Memory統合)
- backend.tsでGateway・ターゲット・Memoryを定義
- デプロイとコールバックURL登録
各サービス側でOAuthアプリを作成
GitHub OAuth App
GitHubのSettings → Developer settings → OAuth Appsから作成します。
Authorization callback URLは仮のURLで構いません(手順5で差し替えます)。作成後にClient IDとClient Secretを控えておきます。
Slack App
https://api.slack.com/apps からFrom scratchで作成します。
なお、SlackのMCPサーバー機能を使えるのはMarketplace公開アプリかinternal appです。今回は自ワークスペース向けのinternal appとして作成しています。
- OAuth & PermissionsのUser Token Scopes(Bot Token Scopesではない方)に channels:history / channels:read / search:read.public / users:read を追加
- Bot Token Scopesにも users:read を1つだけ追加してボットユーザーを作成
- アプリ設定のApp AssistantページでMCPサーバーアクセスを有効化
- Basic InformationのClient IDとClient Secretを控える
手順2のボットユーザーは実際には使いません。公式ドキュメント上は必須とはされていないのですが、今回の検証環境(2026年7月時点)ではボットユーザーがないと、認可画面で doesn't have a bot user to install というエラーになりました。同じエラーが出たら手順2を疑ってみてください。
Google OAuthクライアント
GCPコンソールでGoogle Calendar APIを有効化し、OAuth同意画面を設定します。
認証情報からウェブアプリケーション種別でOAuthクライアントを作成し、Client IDとClient Secretを控えます。
クライアントシークレットをSecrets Managerに登録
Credential ProviderはCDKで作成するので、シークレットをコードに直書きしないよう先にSecrets Managerへ登録しておきます。
3サービスとも同じ形式で登録します。
aws secretsmanager create-secret \
--name github-agent/oauth-client-secret \
--secret-string '{"client_secret": "<GitHubのClient Secret>"}' \
--region us-east-1
# slack-agent/oauth-client-secret、google-agent/oauth-client-secret も同様
Credential Providerの設定でこのシークレットをEXTERNALソースとして参照する作りです。
エージェントの実装(3LO認可フック + Memory統合)
エージェントから見ると、GatewayはBearer認証付きMCPサーバーとして振る舞います。Strandsの標準的なMCP統合そのままで、Runtimeに転送されたユーザーのJWTを付けてGatewayに接続するだけです!
gateway = MCPClient(
lambda: streamablehttp_client(
GATEWAY_URL, headers={"Authorization": f"Bearer {bearer_token}"}
)
)
with gateway:
tools = gateway.list_tools_sync()
agent = Agent(
model=MODEL_ID,
tools=tools,
system_prompt=SYSTEM_PROMPT,
hooks=[GatewayAuthHook(event_queue)],
session_manager=session_manager,
agent_id="default",
)
ただ3LOには一工夫、認可待ち周りで必要です。
初回リクエスト時にToken Vaultにユーザーのトークンがない状態でツールを呼ぶと、GatewayはJSON-RPCエラー(コード -32042)を返し、そのdataに認可URLが入っています。アプリケーション側では、この認可URLをフロントエンドへ通知して、認可完了までリトライすることです。
この全ツール横断の後処理にStrandsのHooksを使用し、AfterToolCallEventはツール実行が終わるたびに呼ばれ、event.retry = True で同じツールの再実行を指示するようにします。
class GatewayAuthHook(HookProvider):
def __init__(self, event_queue: asyncio.Queue):
self._event_queue = event_queue
self._notified_providers: set[str] = set()
self._deadlines: dict[str, float] = {}
def register_hooks(self, registry: HookRegistry) -> None:
registry.add_callback(AfterToolCallEvent, self._on_after_tool_call)
async def _on_after_tool_call(self, event: AfterToolCallEvent) -> None:
tool_name = event.tool_use.get("name", "") if event.tool_use else ""
provider = provider_from_tool_name(tool_name)
auth_url = extract_auth_url(event.result)
if auth_url is None:
# 認可待ちだったproviderのツールが成功したら接続完了を通知
if (
provider
and provider in self._notified_providers
and _result_status(event.result) == "success"
):
await self._event_queue.put({
"type": "connection_status",
"provider": provider,
"status": "connected",
})
self._deadlines.pop(provider, None)
return
# providerが特定できない場合も認可自体は進める
key = provider or f"unknown:{tool_name}"
if key not in self._deadlines:
self._deadlines[key] = time.monotonic() + AUTH_DEADLINE_SECONDS
if time.monotonic() > self._deadlines[key]:
# タイムアウト: エラーを通知してリトライをやめる
error_event = {
"type": "error",
"scope": "chat",
"code": "authorization_timeout",
"data": "認可の待機時間を超えました。もう一度お試しください。",
}
if provider:
error_event["provider"] = provider
await self._event_queue.put(error_event)
self._deadlines.pop(key, None)
return
if key not in self._notified_providers:
payload = {
"type": "auth_required",
"auth_url": auth_url,
}
if provider:
payload["provider"] = provider
await self._event_queue.put(payload)
self._notified_providers.add(key)
await asyncio.sleep(AUTH_POLL_INTERVAL)
event.retry = True # 結果を破棄して同じツールを再実行する
ツール名のプレフィックス(githubmcp___ / slackmcp___ / googlecal___)からプロバイダーを識別し、認可URLの通知・接続完了(connection_status: connected)の通知・5分のデッドラインでのタイムアウトをプロバイダー共通で処理しています。サービスごとのOAuth処理をエージェントへ実装する必要はありません。
サービス追加時に個別で設定するのは、接続パネル表示用のprefix対応表、接続確認に使う読み取りツール、システムプロンプトの3つです。
この5秒間隔のポーリングは理想的ではなく、Token Vaultへのトークン保管を通知するプッシュ型のAPIがあれば即座にリトライできるのですが、現時点では存在しないため、認可が完了していても次のポーリングまで最大5秒待つことになります・・・なのでちょっともっさりした体験ですね。
Session Binding API(Lambda + DynamoDB)
前回のFastAPI版と考え方は同じですが、今回はLambda + DynamoDBでサーバーレス化しました!
APIは2つのエンドポイントで構成しています。
// session URIはBearer相当のため、テーブルにはSHA-256ハッシュだけを保存
const flowKeyOf = (value: string) =>
createHash('sha256').update(value).digest('hex');
// POST /auth/pending: 認可URLのrequest_uriをフロー識別子にPENDINGレコードを登録
if (event.rawPath === '/auth/pending') {
const { flow_id: flowId, provider } = JSON.parse(event.body ?? '{}');
if (typeof flowId !== 'string' || flowId.length === 0 || flowId.length > 2048) {
return json(400, { error: 'flow_id is required' });
}
const hashedFlowId = flowKeyOf(flowId);
try {
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
userId,
flowId: hashedFlowId,
...(typeof provider === 'string' ? { provider } : {}),
status: 'PENDING',
createdAt: new Date().toISOString(),
ttl: Math.floor(Date.now() / 1000) + 900, // 15分で失効
},
// COMPLETED済みレコードをPENDINGへ上書きして二重Bindingを許さない
ConditionExpression:
'attribute_not_exists(userId) AND attribute_not_exists(flowId)',
})
);
} catch (e) {
if (!isConditionalFailure(e)) throw e;
// 既存レコードを照合し、期限内のPENDINGなら200、
// 完了済みまたは期限切れなら409を返す(詳細は実装を参照)
}
return json(200, { status: 'ok' });
}
// POST /auth/complete: コールバック後にsession_idとユーザーを紐付け
if (event.rawPath === '/auth/complete') {
const { session_id: sessionId } = JSON.parse(event.body ?? '{}');
// コールバックのsession_idは認可URLのrequest_uriと同じURNなので、
// ハッシュが一致するレコードだけが開始済みフローとして照合できる
const flowId = flowKeyOf(sessionId);
// PENDINGでなければ拒否(ワンタイム遷移)
await ddb.send(
new UpdateCommand({
TableName: TABLE_NAME,
Key: { userId, flowId },
UpdateExpression: 'SET #st = :completed, boundAt = :now',
ConditionExpression: '#st = :pending AND #ttl > :nowEpoch',
ExpressionAttributeNames: { '#st': 'status', '#ttl': 'ttl' },
ExpressionAttributeValues: {
':completed': 'COMPLETED',
':pending': 'PENDING',
':now': new Date().toISOString(),
':nowEpoch': Math.floor(Date.now() / 1000),
},
})
);
// Token Vaultにトークンを紐付ける。失敗時は条件付き更新でPENDINGへロールバック
await agentcore.send(
new CompleteResourceTokenAuthCommand({
sessionUri: sessionId,
userIdentifier: { userToken: rawToken },
})
);
return json(200, { status: 'bound' });
}
フロントエンドが認可リンクを表示する前にPENDINGレコードを登録し、事前登録のない完了要求は受け付けません。状態遷移はConditionExpressionでPENDING → COMPLETEDの一度きりに強制し、登録側にもattribute_not_existsを付けて完了済みフローの上書きを防ぎます。失敗時のロールバックも条件付きで、自分がCOMPLETEDにしたレコードだけをPENDINGへ戻します。放置されたフローはTTLで15分後に自動失効します。
キーは userId + flowId の複合キーで、flowIdは認可URLの request_uri のSHA-256ハッシュです。コールバックの session_id は request_uri と同じURNなので、同じハッシュを計算して一致するレコードだけを完了できます。別フローや第三者の session_id ではレコード自体が存在しないため拒否されます。Bearer相当の値をテーブルに平文で残さないためにハッシュ化しています。
CompleteResourceTokenAuthCommandのuserIdentifierにはユーザーのJWTを渡します。Token Vaultがsession_idで一時保管していたトークンを、そのJWTのユーザーに紐付けてくれます。
backend.tsでGateway・ターゲットを定義
Gateway本体はCognitoをJWTオーソライザーにしたMCPプロトコルの入口です。
本サンプルではSupportedVersionsに 2025-11-25 を指定しています。執筆時点でGatewayがサポートするMCPプロトコルバージョンは 2026-07-28 / 2025-11-25 / 2025-06-18 / 2025-03-26 で(最近新バージョンが出ましたね!!)、3LOの認可URLをクライアントへ返すelicitation機構は 2025-11-25 で導入された仕様です!
GitHub(MCPサーバーターゲット + 静的ツールスキーマ)
GitHub公式リモートMCPサーバーをターゲットにします。
MCPサーバーターゲットはデフォルトだと、作成時にGatewayがMCPサーバーへtools/listを呼び、ツール一覧をカタログとしてキャッシュします(Implicit Synchronization)。
3LOのターゲットではこの呼び出し自体にユーザーのアクセストークンが必要で、デプロイ時点では誰も認可していないため用意できません。その結果、CreateGatewayTargetのレスポンスには管理者向けの認可URLが返り、ターゲットはCREATE_PENDING_AUTH(認可待ち)で止まります。この間は更新・削除・再同期も受け付けられず、CloudFormationのデプロイが完了してもターゲットはREADYになりません。デプロイのたびに管理者がコンソールから承認する運用は、ワンショットでデプロイしようとすると難しいです。
そこで今回はmcpToolSchemaでツール定義を静的に渡す方式を使います。上流へのtools/listを行わず、渡されたスキーマをそのままキャッシュするため、作成時の認可自体が不要になり、デプロイ完了時点でREADYになります。
公式ブログでも、作成・更新時に人の介入ができないケースではこちらの方式が推奨されています(GitHub MCPサーバーを題材にした手順つきです)。
これが正解!というわけではないのですが、今回はこちらの方式をやってみます。
new CfnResource(stack, 'GitHubMcpTarget', {
type: 'AWS::BedrockAgentCore::GatewayTarget',
properties: {
Name: 'githubmcp',
GatewayIdentifier: gateway.ref,
TargetConfiguration: {
Mcp: {
McpServer: {
Endpoint: 'https://api.githubcopilot.com/mcp/',
McpToolSchema: {
InlinePayload: mcpToolsSchema, // github-mcp-tools.jsonの中身
},
},
},
},
CredentialProviderConfigurations: [{
CredentialProviderType: 'OAUTH',
CredentialProvider: {
OauthCredentialProvider: {
ProviderArn: credentialProvider.getAtt('CredentialProviderArn').toString(),
Scopes: ['repo', 'read:user'],
GrantType: 'AUTHORIZATION_CODE',
DefaultReturnUrl: callbackUrl,
},
},
}],
},
});
GrantTypeにAUTHORIZATION_CODEを指定することで3LO(ユーザー委任)になります。Credential Provider側はビルトインのGithubOauth2ベンダーがそのまま使えます。
ツール定義はGitHub MCPサーバーが公開するツール(2026年7月時点で44件)から、読み取り系の6つに絞りました。
静的な構成はデメリットもあって、上流のツール定義変更に自動では追従しません。SynchronizeGatewayTargetsも静的スキーマの設定中は呼べない決まりなので、ツールが更新されたらスクリプトを再実行してスキーマを差し替える運用です。ワンショットデプロイのためにこのメンテナンスコストを受け入れましたが、ツール数が多いならCREATE_PENDING_AUTHを手動解決する方式の方が楽かもしれません(その場合も、変更時の再同期では同様に認可待ちが発生します)。
もう1点、tools/listのレスポンスには認証ユーザーのIDやセマンティック検索の利用可否など、取得時のコンテキストに依存する文言がdescriptionに混ざることがあります。そのまま静的化すると別ユーザーへの誤認や個人情報の露出になるため、descriptionはサニタイズしてから使うことに今回はしました。
Slack(MCPサーバーターゲット + CustomOauth2)
Slack公式リモートMCPサーバー(mcp.slack.com/mcp)も同じ静的ツールスキーマ方式で接続します。ターゲット定義はGitHubとほぼ同じなので割愛して、Slack固有の特徴を紹介します。
Slack MCPサーバーはユーザートークン(xoxp-)でしか呼び出せません。ところがAgentCore IdentityのビルトインSlackOauth2ベンダーは標準の oauth.v2.access エンドポイントを使うため、Token Vaultにはボットトークン(xoxb-)が保存されてしまいます。認可フローは正常に完了するのに、ツール呼び出しだけがAuthorization errorで拒否されるという分かりにくい失敗になりました(切り分けに便利だったvended logsは補足で紹介します)。
原因を切り分けた結果、CustomOauth2ベンダーでSlackのユーザーフロー専用エンドポイントを明示する方式にたどり着きました。
const slackCredentialProvider = new CfnResource(stack, 'SlackCredentialProvider', {
type: 'AWS::BedrockAgentCore::OAuth2CredentialProvider',
properties: {
Name: `slack-user-provider-${suffix}`,
CredentialProviderVendor: 'CustomOauth2',
Oauth2ProviderConfigInput: {
CustomOauth2ProviderConfig: {
ClientId: SLACK_CLIENT_ID,
ClientSecretSource: 'EXTERNAL',
ClientSecretConfig: {
SecretId: SLACK_SECRET_NAME,
JsonKey: 'client_secret',
},
OauthDiscovery: {
AuthorizationServerMetadata: {
Issuer: 'https://slack.com',
AuthorizationEndpoint: 'https://slack.com/oauth/v2_user/authorize',
TokenEndpoint: 'https://slack.com/api/oauth.v2.user.access',
ResponseTypes: ['code'],
},
},
},
},
},
});
oauth.v2.user.access は標準OAuth形式でユーザートークンを返してくれるSlackのMCP向けエンドポイントです。これでToken Vaultに正しくユーザートークンが入り、ツール呼び出しができるようになりました!
Googleカレンダー(OpenAPIターゲット)
Googleカレンダーには一般提供の公式リモートMCPサーバーがないため、GatewayのOpenAPIターゲットを使います。REST APIをOpenAPI定義でそのままMCPツール化する、Gateway本来の主用途ともいえる方式ですね。MCPサーバーを提供していないSaaSでもREST APIさえあれば同じ3LOパターンでつなげて、エージェントからは googlecal___listEvents のようなツールとして呼べます。
OpenAPI定義はCalendar APIのうち読み取り系の3操作(カレンダー一覧・予定一覧・予定詳細)だけを手書きしました。
Discoveryドキュメントから自動生成するツールもあるのですが、Gatewayでは$refによるスキーマ参照が使えず、レスポンスのネストも深すぎてバリデーションに通りませんでした。操作が少ないうちは手書きで問題ないものの、書き込み系まで含めてエンドポイントが増えると定義の維持は面倒になりそうです。
{
"openapi": "3.0.3",
"servers": [{ "url": "https://www.googleapis.com/calendar/v3" }],
"paths": {
"/calendars/{calendarId}/events": {
"get": {
"operationId": "listEvents",
"description": "指定カレンダーの予定一覧を取得する。timeMin/timeMaxで期間を絞る(RFC3339形式)。ユーザー自身の予定は calendarId='primary' を使う。",
...
}
}
}
}
ターゲット定義はTargetConfigurationがOpenApiSchemaになる点と、OAuth設定にCustomParametersを渡している点がポイントです。
new CfnResource(stack, 'GoogleCalendarTarget', {
type: 'AWS::BedrockAgentCore::GatewayTarget',
properties: {
Name: 'googlecal',
GatewayIdentifier: gateway.ref,
TargetConfiguration: {
Mcp: {
OpenApiSchema: {
InlinePayload: googleCalendarSchema, // OpenAPI定義の中身
},
},
},
CredentialProviderConfigurations: [{
CredentialProviderType: 'OAUTH',
CredentialProvider: {
OauthCredentialProvider: {
ProviderArn: googleCredentialProvider.getAtt('CredentialProviderArn').toString(),
Scopes: ['https://www.googleapis.com/auth/calendar.readonly'],
GrantType: 'AUTHORIZATION_CODE',
DefaultReturnUrl: callbackUrl,
// Googleのリフレッシュトークン取得に必須
CustomParameters: {
access_type: 'offline',
prompt: 'consent',
},
},
},
}],
},
});
Googleのアクセストークンは1時間で失効します。Token Vaultがリフレッシュトークンで自動更新してくれるので、その発行を求めるのが上記のCustomParametersです。
Credential Provider側はビルトインのGoogleOauth2ベンダーがそのまま使えました!
デプロイとコールバックURL登録
Client IDはコミットせず環境変数で渡す作りにしているので、sandboxなら下記でデプロイできます。
GITHUB_CLIENT_ID=xxx SLACK_CLIENT_ID=xxx GOOGLE_CLIENT_ID=xxx pnpm ampx sandbox
Amplify Hostingへのデプロイもコード変更は不要です。backend.tsがビルド環境の変数(AWS_BRANCH/AWS_APP_ID)からHosting環境を判定し、コールバックURLを https://<ブランチ>.<アプリID>.amplifyapp.com/callback へ自動で切り替えます。リポジトリをHostingに接続して、コンソールの環境変数に同じ3つのClient IDを設定すればビルドが走ります(ビルド設定はリポジトリのamplify.yml)。SPAのルーティング用に、/callbackをindex.htmlへ書き換えるリダイレクトルールだけはコンソールで追加してください。今回の動作確認はこのHosting環境で行っています。
デプロイが完了すると、amplify_outputs.jsonにコールバックURLが出力されます。Hostingの場合はコンソールの「デプロイされたバックエンドリソース」からダウンロードできます。
{
"custom": {
"githubCallbackUrl": "https://...",
"slackCallbackUrl": "https://...",
"googleCallbackUrl": "https://..."
}
}
それぞれのURLを、GitHubはOAuth AppのAuthorization callback URL、SlackはOAuth & PermissionsのRedirect URLs、GCPはOAuthクライアントの承認済みのリダイレクトURIに設定します。
動作確認
実際に試してみます!AmplifyのURLにアクセスしてサインアップ・ログインし、チャット画面から話しかけていきます。
まずGitHubです。「私のリポジトリを教えて」と送ると、ツール呼び出しの時点で認可リンクが表示されます。リンクを開くとGitHubの認可画面に飛び、承認するとcallbackタブが自動で閉じて、元のチャットでリトライが成功し、エージェントが処理を再開してくれます。

Slackも同じ流れです。認可リンクからワークスペースを選んで承認すると、メッセージの検索結果が返ってきました。

Googleカレンダーも同様です。テストモードなので未確認アプリの警告が出ますが、続行で通過できます。

承認するとOpenAPIターゲット経由でCalendar APIが呼ばれ、予定が返ってきました。右側の連携設定パネルでは3サービスすべてが「連携済み」になっています。

一度認可すればToken Vaultにトークンが保管されるので、以降は認可リンクなしでMCP Serverと連携して回答を実施してくれます!
本番運用する場合のセキュリティ面の考察
Gatewayに3LOを任せたことで、外部サービストークンの登場が減りました。取得・保管・付与がGatewayとToken Vaultの間で完結し、エージェントのコードやフロントエンドにトークンは現れなくなったのは嬉しいポイントかなと思います!
また、Gatewayを経由するので、Policyで認可制御して使用できるツールを選択させたり、ガードレールを適用したりなど集約するメリットも存在します。
Session Bindingは、前回の課題だった正規ユーザーの検証を、CognitoのJWT認証とDynamoDBの状態管理に切り出しました!request_uri をハッシュ化したflowIdでフローを一意に照合するため、本人が開始していない session_id では完了できません。なおコールバック画面はアクセスで自動完了する実装なので、本番では完了前の確認ボタンを挟んだりするのも良いかもしれませんね。
今回使用したツールはすべて読み取り専用にしました。システムプロンプトの指示だけでは書き込みの安全境界として不十分なため、送信系ツール(slack_send_message など)はOAuthスコープ(chat:write)ごと外しています。いずれはSlackへの投稿やカレンダー登録といった書き込み系ツールも試したいと考えていて、その際は本当に使用するもののみ、また実行前のユーザー確認やガードレールとセットで検証したいなと思っています。
そのほか、デモ用途ゆえに緩い設定も存在します。(ご愛嬌ということで・・・)
Session Binding APIのCORSは * 、GatewayのIdentity関連IAMは Resource: * 、ExceptionLevelはDEBUG(詳細なエラーをクライアントへ返す設定)のままです。会話UUIDとユーザーの対応もサーバー側では未検証なので、仮に本番化するなら見直したいポイントですね。
おわりに
前回ローカルで妥協していたSession Bindingを含めて、3LOのフロー全体をサーバーレスで動かし、さらに3つの外部サービスを1つのGatewayに集約できました!
公式MCPサーバーがあればMCPサーバーターゲット、なければOpenAPIターゲット、という使い分けもできて、サンプルとして作っていても面白かったです!個人的な夏の宿題もなんとかできてスッキリです!
粗々なところもありますが、少しでも参考になれば嬉しいです!
ぜひFBや機能追加などありましたらIssue気軽に言っていただけますと!
以上、『夏休みの自由研究リレー』の第6回のエントリ『Amazon Bedrock AgentCore Gatewayでユーザー委任型認可(3LO)を使い、GitHub・Slack・GoogleカレンダーをAIエージェントにつないでみた』でした。
次回はなゆたさんの「Chronos-2 × Snowflake」に関するエントリの予定です。お楽しみに!!
補足
@requires_access_token方式との比較
エージェント内で直接トークンを扱う前回の方式と、Gatewayのアウトバウンド認証に任せる今回の方式の比較です。
| 観点 | @requires_access_token方式 | Gatewayアウトバウンド認証方式 |
|---|---|---|
| トークンの見える範囲 | エージェントのコードに渡る | GatewayとToken Vaultで完結 |
| 外部APIの呼び出し | エージェントが直接実装 | 既存のMCPサーバーやOpenAPIスキーマをGatewayがツール化 |
| 認可完了の待ち受け | SDKがポーリングを内蔵 | フックでリトライ |
| 会話記憶 | 自前実装 | AgentCore Memory + Session Manager |
| 複数サービスへの展開 | サービスごとにツール実装 | 認可処理は共通。ツール選定・接続確認・UI metadataは追加 |
エージェント1つで完結する小規模な構成なら前回の方式がシンプルですが、ツールを複数のエージェントから使い回したい場合や、トークンをアプリケーションコードから隔離したい場合はGateway方式が向いていると思いました!
Google公式のWorkspace MCPサーバーについて
実はGoogleも公式のリモートMCPサーバーを公開し始めています(Developer Preview)。Calendar・Gmail・Drive・Chat・Docs・Sheets・Slides・Peopleに、それぞれ専用のエンドポイント(例えばCalendarは calendarmcp.googleapis.com/mcp/v1)が用意されています。認証にはOAuth 2.0のウェブアプリケーション型クライアントを使用します。
AgentCore GatewayのMCPサーバーターゲットとして接続できる構成に見えますが、今回は未検証です。組織のAPI controlsやOAuthポリシーによっては、管理者側の設定が必要になる可能性があります。接続できない場合は、管理者ポリシーとOAuthログを確認してください。
別のサービスを追加する場合(Gmailの例)
新しいサービスも連携したい!といった場合の手順も残しておきます。考え方は、公式MCPサーバーの有無で2つに分かれます。
公式MCPサーバーがある場合は、GitHubやSlackと同じMCPサーバーターゲット方式です。Gmailなら、Google公式のWorkspace MCPサーバー(Developer Preview)に専用エンドポイントがあります。接続を試す場合は、scripts/fetch_mcp_tools.py のSERVERSに対象サーバーのエントリを追加して静的スキーマを生成し、ターゲットを追加します。
OAuthクライアントやCredential Providerの構成はGoogleカレンダーと同じです。
公式MCPサーバーがない場合は、Googleカレンダーと同じOpenAPIターゲット方式です。使いたい操作だけをOpenAPI定義に手書きしてターゲットを追加します。Gmail APIのようにREST APIであればこの方式でつなげます。
どちらの方式でも、変更箇所は下記の5つです。
- サービス側でOAuthアプリを作成し、Client SecretをJSON形式でSecrets Managerに登録
- backend.tsにCredential Providerとターゲットを追加し、発行されたコールバックURLをサービス側に登録
- gateway_auth.pyのTOOL_PREFIX_TO_PROVIDERにprefix対応を、connections.pyのPROVIDER_PROBESに接続確認用の読み取り専用ツールを追加
- main.pyのシステムプロンプトに、新しいサービスのツールの使い方を追記(スキルに切り出すのも手)
- フロントエンドのtypes/runtime.tsのProviderIdとconnectionState.tsの表示情報に新サービスを追加
認可フック自体はprovider共通の仕組みなので変更不要で、追加は設定とメタデータだけで済みます。
GoogleカレンダーとGmailのように、同じアカウントのサービスを増やす場合はscopeの違いに注意してください。必要なscopeを追加した場合は既存トークンでは権限が足りず、再認可が必要になります。
また、ツール数はつい増やしたくなりますが、descriptionがモデルのツール選択精度に直結するのでユースケースに応じて検討しましょう。
Amplifyを選んだ理由
今回Amplify Gen 2を選んだのは、Cognito認証・ホスティング・CDKバックエンドを1つのプロジェクトで管理できるからです。特にCognito User PoolとUser Pool Clientの定義がAmplify Authの数行で済むのは便利です。
一方で、AgentCoreのリソース定義はAmplifyとは本来関係がなく、backend.tsに相乗りさせている形となります。Amplifyのスタック分割ルール(authスタックとfunctionスタック等が分かれる)に起因する循環参照を避けるため、AgentCoreリソースの配置先スタックを工夫する必要がありました。
開発体験の面でも、sandbox環境はGateway・Runtime・CodeBuildを含む全リソースを作るので初回デプロイに5分以上かかり、再度デプロイする時もぼちぼち時間がかかるのでここは悩ましいですね。
本番で運用するなら、Amplifyに乗せずにCDK単体でインフラを管理し、フロントエンドはCloudFront + S3で配信する方が見通しは良いと思います。今回はデモ用途なので、認証からホスティングまでワンショットで揃う利便性を優先しました!
sandbox環境での開発について
backend.tsはsandboxでもGateway・Runtimeを含む全リソースを作成可能です。リソース名に環境ごとの接尾辞を付けて衝突を避け、コールバック先はローカル開発サーバーに向けているため、ローカルのフロントエンドから3LOフローを動かせます。GitHub OAuth AppだけはコールバックURLが1つしか登録できないので、sandboxとHostingの両方で3LOを通す場合は開発用のOAuth Appを使い分けてください。
デバッグにはvended logsが便利
Slackのボットトークン問題の切り分けで便利だったのが、Gatewayのログ配信(vended logs)です。ExceptionLevelをDEBUGにするとエラーの詳細がクライアントへ返り、コンソールでvended logsを有効化するとGatewayとターゲット間のリクエスト・レスポンスがCloudWatch Logsに記録されます。おかげでSlack MCPサーバーのエラーレスポンスから、ボットトークンが送られていると気づけました。Gatewayとターゲット間はブラックボックスになりがちなので、詰まったらまず有効化するのが良いなと感じました!
リソースの削除
Amplifyアプリを削除すると、Hosting・Cognito・Session Binding APIに加え、CDK管理のGateway・ターゲット・Credential Provider・Memory・Runtimeもまとめて削除されます。Secrets Managerのシークレット3つと各サービスのOAuthアプリは手動で削除してください。
参考
以前にも大変参考にしたのですが、このブログで仕組みを理解させていただきました・・・!!
本当にありがとうございます!




