Amazon Bedrock AgentCore Identity (1) : Agent 呼び出しの認証を試す
Amazon Bedrock AgentCore Identity (2) : Agentから外部サービスへ2LO(M2M)認証でアクセスする
今日はAmazon Bedrock AgentCore Identity 第三回です。最初に記事では、Agentそのもののアクセスへの認証、2回目の記事ではAgentが外部サービスへアクセスを行う際の2LOの認証を試しました。今回は3LOを試します。
前回の記事の転載ですが2LOと3LOの違いをおさらいしておきます。
2LO と 3LO
Outbound で外部サービスのトークンを取得する方法には、大きく2つの OAuth 2.0 フローがあります。AgentCore Identity は、client credentials grant(2LO とも呼ばれる)と、authorization code grant(3LO とも呼ばれる)の両方をネイティブにサポートしています。
2LO(Two-Legged OAuth / M2M: Machine to Machine)
登場人物が2者(エージェントと、アクセス先のサービス)のフローです。ユーザーの同意画面は登場しません。2LO フローでは、エージェントはユーザーの操作を介さずに、リソースサーバーに対して自分自身として直接認証します。
「エージェント自身の権限」で動くのが本質です。たとえば、社内 API を叩く、自社のサービスアカウントとしてデータを取得する、といった特定のユーザーに紐づかないアクセスに向いています。裏側では OAuth の client credentials grant が使われます。
3LO(Three-Legged OAuth / ユーザー委譲)
登場人物が3者(ユーザー、エージェント、外部サービス)のフローです。ここに「ユーザーの同意」というステップが加わります。3LO フローは、外部サービスにあるユーザー固有のデータへアクセスするために、ユーザーの明示的な同意と認可を可能にします。
「エージェントが、ユーザーに代わって、ユーザーのデータにアクセスする」のが本質です。例えば「エージェントが私にかわって Google Drive を読む」などがそれにあたります。流れとしては、エージェントが同意用の URL を生成し、ユーザーがブラウザでその URL を開いて「許可」を押すと、初めてエージェントがそのユーザーのデータにアクセスできるようになります。裏側では OAuth の authorization code grant が使われます。
2LO(M2M) | 3LO(ユーザー委譲) | |
|---|---|---|
登場人物 | エージェント ↔ サービス | ユーザー ↔ エージェント ↔ サービス |
ユーザー同意 | 不要 | 必要(初回のみ、以降はVault活用) |
誰として動くか | エージェント自身 | ユーザーの代理 |
OAuth grant | client credentials | authorization code |
|
|
|
向く用途 | 社内API・サービスアカウント | 個人のDrive/メール/カレンダー |
さっそくやってみる
3LOでのログイン先にGoogle Driveを使います。つまり、Agent がユーザーの代わりに権限を委譲されGoogle Driveを読む、といった構成です。
2LO と 3LO の一番の違いは「ユーザーの同意」が挟まることです。裏側の流れはこうなります。エージェントがツールを呼ぶと、AgentCore が 3LO 認証 URL を生成し、on_auth_url で指定した方法でユーザーに提示します。ユーザーがブラウザでその URL を開いて同意すると、AgentCore Identity が Google のアクセストークンを受け取り、Token Vault に保管します。 そして、AgentCore Identity は取得した Google トークンを、エージェントのワークロード ID とユーザー ID(インバウンドの JWT、つまり第一回で作った Cognito トークン由来)を紐付けのキーとして Token Vault に保管し、トークンが失効するまで再同意を不要にします。
2LO から増える要素は主に3つです。
auth_flow="USER_FEDERATION"— 2LO のM2Mに対応する、3LO の指定- 同意フロー — ユーザーがブラウザで許可を押すステップ(2LO には無かった)
CLI の更新
まず必要なツールの更新を行います
cd ~/agentcore-runtime-quickstart # 第一〜二回のディレクトリ(パスは適宜)
source .venv/bin/activate
pip uninstall bedrock-agentcore-starter-toolkit
npm install -g @aws/agentcoreGoogle Cloud で OAuth クライアントを作成する
https://console.cloud.google.com/
にアクセスを行います。

APIサービス をクリックし、左ペインから ライブラリ をクリックします。
Google Drive API を検索して詳細画面を開き有効化します。

次に左ペインから OAuth同意画面 をクリックします。

開始をクリックして適当名前を入力します。
メールアドレスをドロップダウンから選択して 次へをクリックします。

外部 をクリックします。

さらにメールアドレスを指定して 次へをクリックします。

同意を求めるチェックボックスが出てきますので、オンにして作成を行います。
次に OAuthクライアントを作成 をクリックします。
ウェブアプリケーション を指定して適当な名前を付けます。

以下の内容はのち程AgentCore Identity が生成した値を埋め込みますので今は空欄のまま 作成 をクリックします。

クライアントIDとシークレットが画面に表示されますのでコピーしておきます。
コピーしたその2つを環境変数に指定しておきます。
export GOOGLE_CLIENT_ID="ここに Client ID を貼る"
export GOOGLE_CLIENT_SECRET="ここに Client Secret を貼る"
export REGION="us-west-2"AgentCore Identity に Google Credential Provider を登録する
以下を実行します。
aws bedrock-agentcore-control create-oauth2-credential-provider \
--name "google-provider" \
--credential-provider-vendor "GoogleOauth2" \
--oauth2-provider-config-input "{
\"googleOauth2ProviderConfig\": {
\"clientId\": \"${GOOGLE_CLIENT_ID}\",
\"clientSecret\": \"${GOOGLE_CLIENT_SECRET}\"
}
}" \
--region "${REGION}"{
"clientSecretArn": {
"secretArn": "arn:aws:secretsmanager:us-west-2:917561075114:secret:bedrock-agentcore-identity!default/oauth2/google-provider-d234934f-JQZZwN"
},
"clientSecretSource": "MANAGED",
"name": "google-provider",
"credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:917561075114:token-vault/default/oauth2credentialprovider/google-provider",
"callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/b7f6c57b-0b6e-4b7b-9c9c-cbd2a319af8d",
"oauth2ProviderConfigOutput": {
"googleOauth2ProviderConfig": {
"oauthDiscovery": {
"discoveryUrl": "https://accounts.google.com/.well-known/openid-configuration"
},
"clientId": "907319572638-vulpsmuqku0tu9dovl2ea6t0jtp8qgfr.apps.googleusercontent.com"
}
},
"status": "READY"
}callbackUrl を環境変数に登録します。
export GOOGLE_PROVIDER_NAME="google-provider"
export GOOGLE_CALLBACK_URL="https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/b7f6c57b-0b6e-4b7b-9c9c-cbd2a319af8d"
export AWS_REGION=us-west-2値は皆さんごとに異なります。
コールバック URL を Google 側に登録
では先ほどGoogle側で空欄にしておいたコールバックURLを上記の値に置き換えます。 承認済みのリダイレクト URI がそれにあたります。
もう一つテスト用 http://localhost:8090/callback を登録しておきます。

クライアントをクリックして詳細画面に移動します。

ログインに使用するテストユーザーも登録しておきます。
左ペインから OAuth同意画面 をクリックし、さらに左ペインで 対象 をクリックします。

テストユーザーを登録します。

AgentCore プロジェクトの作成
では次に新しくAgentCoreのプロジェクトを1個作成します。
agentcore create --name drive3lo --framework Strands --model-provider Bedrock --memory nonemain.py に 3LO の Drive 読み取りツールを追加
/app/drive3lo/main.py を以下に置き換えます。
from typing import Any
from collections import OrderedDict
from strands import Agent, tool
import asyncio
from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from model.load import load_model
from mcp_client.client import get_streamable_http_mcp_client
# --- 3LO (Google Drive) 用の追加 import ---
from bedrock_agentcore.identity.auth import requires_access_token
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
app = BedrockAgentCoreApp()
log = app.logger
# Define a Streamable HTTP MCP Client
mcp_clients = [get_streamable_http_mcp_client()]
DEFAULT_SYSTEM_PROMPT = """
You are a helpful assistant. Use tools when appropriate.
"""
# Define a collection of tools used by the model
tools = []
_INLINE_FUNCTION_NAMES = set()
# Define a simple function tool
@tool
def add_numbers(a: int, b: int) -> int:
"""Return the sum of two numbers"""
return a+b
tools.append(add_numbers)
# --- ここから 3LO (Google Drive) ツール ---
SCOPES = ["https://www.googleapis.com/auth/drive.metadata.readonly"]
# 自前 callback サーバーの URL(ワークロードID・Google の両方に登録済み)
CALLBACK_URL = "http://localhost:8090/callback"
def _list_drive_files(access_token: str) -> str:
"""取得したアクセストークンで Drive のファイル一覧(先頭10件)を文字列で返す"""
creds = Credentials(token=access_token, scopes=SCOPES)
try:
service = build("drive", "v3", credentials=creds)
results = service.files().list(pageSize=10, fields="files(id, name)").execute()
items = results.get("files", [])
if not items:
return "ファイルが見つかりませんでした。"
lines = [f"- {it['name']} ({it['id']})" for it in items]
return "Google Drive のファイル(先頭10件):\n" + "\n".join(lines)
except HttpError as error:
return f"Drive API エラー: {error}"
def _on_auth_url(url: str):
"""3LO の同意 URL を目立つ形で出す"""
msg = (
"\n" + "=" * 60
+ "\n▼ 次の URL をブラウザで開いて同意してください:\n"
+ url
+ "\n" + "=" * 60 + "\n"
)
print(msg, flush=True)
log.info(f"AUTH_URL: {url}")
@requires_access_token(
provider_name="google-provider", # 登録済み Credential Provider 名
scopes=SCOPES,
auth_flow="USER_FEDERATION", # 3LO(ユーザー委譲)
callback_url=CALLBACK_URL, # 自前 callback サーバー(localhost:8090)
on_auth_url=_on_auth_url, # 同意 URL の提示先
)
async def _get_drive_files_with_token(*, access_token: str) -> str:
return _list_drive_files(access_token)
@tool
async def read_google_drive() -> str:
"""ユーザーの Google Drive のファイル一覧(先頭10件)を取得して返す。"""
try:
return await _get_drive_files_with_token(access_token="")
except Exception as e:
import traceback
log.error(f"DRIVE_TOOL_ERROR: {type(e).__name__}: {e}\n{traceback.format_exc()}")
return f"[DEBUG] 3LO でエラー: {type(e).__name__}: {e}"
tools.append(read_google_drive)
# --- 3LO ツールここまで ---
# Add MCP client to tools if available
for mcp_client in mcp_clients:
if mcp_client:
tools.append(mcp_client)
def _make_conversation_manager():
return NullConversationManager()
# Reuses one Agent per session_id so each session keeps its own in-process
# conversation history (best-effort; resets on cold start). The cache is bounded
# to 128 sessions with LRU eviction (least-recently-used is dropped and its
# history reset) so a single process serving many sessions cannot leak history
# between them or grow without limit. For durable history, attach a session manager.
def agent_factory():
cache = OrderedDict()
def get_or_create_agent(session_id):
if session_id in cache:
cache.move_to_end(session_id)
return cache[session_id]
if len(cache) >= 128:
cache.popitem(last=False)
cache[session_id] = Agent(
model=load_model(),
system_prompt=DEFAULT_SYSTEM_PROMPT,
tools=tools,
conversation_manager=_make_conversation_manager(),
hooks=[
],
)
return cache[session_id]
return get_or_create_agent
get_or_create_agent = agent_factory()
def strip_trailing_tool_use(messages: Any) -> list[dict]:
"""Strip toolUse blocks from the tail until the last message has none."""
if not isinstance(messages, list):
raise ValueError("messages must be a list")
messages = list(messages)
while messages:
last = messages[-1]
if not isinstance(last, dict):
raise ValueError("each message must be an object")
original_content = last.get("content", [])
if not isinstance(original_content, list) or not all(isinstance(block, dict) for block in original_content):
raise ValueError("each message content value must be a list of content blocks")
content = [block for block in original_content if "toolUse" not in block]
if len(content) == len(original_content):
break
if content:
messages[-1] = {**last, "content": content}
break
messages.pop()
return messages
def _extract_prompt(payload: dict):
"""Accept validated harness messages, tool results, or a plain prompt string."""
if not isinstance(payload, dict):
raise ValueError("payload must be a JSON object")
if "messages" in payload:
return strip_trailing_tool_use(payload["messages"])
if "tool_results" in payload:
tool_results = payload["tool_results"]
if not isinstance(tool_results, list) or not all(
isinstance(tool_result, dict) and isinstance(tool_result.get("toolUseId"), str)
for tool_result in tool_results
):
raise ValueError("tool_results must contain objects with a toolUseId string")
return [{"role": "user", "content": [{"toolResult": {
"toolUseId": tr["toolUseId"],
"status": tr.get("status", "success"),
"content": tr.get("content", []),
}} for tr in tool_results]}]
prompt = payload.get("prompt", "")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
return prompt
def _has_inline_function_call(messages) -> bool:
"""Return True if messages contains an assistant toolUse for an inline function tool."""
if not _INLINE_FUNCTION_NAMES or not isinstance(messages, list):
return False
for msg in messages:
if msg.get("role") == "assistant":
for block in msg.get("content", []):
if isinstance(block, dict) and block.get("toolUse", {}).get("name") in _INLINE_FUNCTION_NAMES:
return True
return False
def _is_inline_function_call(event: dict) -> bool:
"""Check if a contentBlockStart event is for an inline function tool."""
if not _INLINE_FUNCTION_NAMES:
return False
cbs = event.get("contentBlockStart", {})
start = cbs.get("start", {})
tool_use = start.get("toolUse") if isinstance(start, dict) else None
return tool_use is not None and tool_use.get("name") in _INLINE_FUNCTION_NAMES
@app.entrypoint
async def invoke(payload, context):
log.info("Invoking Agent.....")
session_id = getattr(context, 'session_id', 'default-session')
agent = get_or_create_agent(session_id)
prompt = _extract_prompt(payload)
async for event in agent.stream_async(
prompt,
):
if not isinstance(event, dict) or "event" not in event:
continue
cbs = event["event"].get("contentBlockStart")
if cbs is not None and not cbs.get("start"):
continue
yield event
if __name__ == "__main__":
app.run()次に app/drive3l/pyproject.tomlを以下に置き換えます。
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "drive3lo"
version = "0.1.0"
description = "AgentCore Runtime Application using Strands SDK"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"aws-opentelemetry-distro",
"bedrock-agentcore >= 1.9.1",
"botocore[crt] >= 1.35.0",
"mcp ~= 1.24.0",
"strands-agents >= 1.15.0",
"google-api-python-client >= 2.100.0",
"google-auth >= 2.23.0",
]
[tool.hatch.build.targets.wheel]
packages = ["."]保存したら依存を反映します。
cd app/drive3lo
uv sync
cd ../..CallBack用サーバの起動
Google Drive から戻ってくる認証トークンを受け取るサーバを起動します。場所はプロジェクトルートの drive3lo/ にします。
"""
3LO 用ローカル callback サーバー。
Google からの ?session_id=... を受けて CompleteResourceTokenAuth を呼び、
セッションバインディングを完了する。
user_id は app/drive3lo/.agentcore.json から自動で読み込む。
"""
import json
from pathlib import Path
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
import boto3
REGION = "us-west-2"
PORT = 8090
# .agentcore.json から user_id を自動読み込み(絶対パスで固定)
AGENTCORE_JSON = Path.home() / "agentcore-runtime-quickstart" / "drive3lo" / "app" / "drive3lo" / ".agentcore.json"
try:
_cfg = json.loads(AGENTCORE_JSON.read_text())
USER_ID = _cfg["user_id"]
WORKLOAD = _cfg.get("workload_identity_name", "?")
print(f"Loaded from {AGENTCORE_JSON}\n user_id={USER_ID} workload={WORKLOAD}")
except Exception as e:
raise SystemExit(
f"[callback] .agentcore.json を読めません: {e}\n"
f" 先に setup_identity.py を実行して .agentcore.json を生成してください。"
)
agentcore = boto3.client("bedrock-agentcore", region_name=REGION)
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body, ctype="application/json"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.end_headers()
self.wfile.write(body.encode() if isinstance(body, str) else body)
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path != "/callback":
self._send(404, '{"error":"not found"}')
return
qs = parse_qs(parsed.query)
session_id = qs.get("session_id", [None])[0]
if not session_id:
self._send(400, '{"error":"missing session_id"}')
return
try:
print(f"[callback] complete: session={session_id[:30]}... user_id={USER_ID}", flush=True)
agentcore.complete_resource_token_auth(
sessionUri=session_id,
userIdentifier={"userId": USER_ID},
)
except Exception as e:
print(f"[callback] complete failed: {e}", flush=True)
self._send(500, json.dumps({"error": f"complete failed: {e}"}))
return
print(f"[callback] session bound OK: {session_id[:40]}...", flush=True)
self._send(
200,
"<html><body><h2>認証が完了しました。ターミナルに戻ってください。</h2></body></html>",
ctype="text/html",
)
def log_message(self, *args):
pass
if __name__ == "__main__":
print(f"Callback server on http://localhost:{PORT}/callback (user_id={USER_ID})")
HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()テスト実行
まずは初期登録を行います。
"""
3LO の下準備スクリプト。
- ワークロード ID と user_id を生成し app/drive3lo/.agentcore.json に保存
- そのワークロード ID に、戻り先URL(callbackサーバー & Credential Provider)を登録
これを最初に一度実行すれば、あとは本番の3ターミナル実行だけで動く。
"""
import json
import os
import uuid
from pathlib import Path
import boto3
REGION = os.environ.get("AWS_REGION", "us-west-2")
LOCAL_CALLBACK = "http://localhost:8090/callback"
GOOGLE_CALLBACK_URL = os.environ["GOOGLE_CALLBACK_URL"] # env.sh で export 済み
CONFIG_PATH = Path("app/drive3lo/.agentcore.json")
cp = boto3.client("bedrock-agentcore-control", region_name=REGION)
# 既に .agentcore.json があれば再利用、無ければ新規作成
if CONFIG_PATH.exists():
cfg = json.loads(CONFIG_PATH.read_text())
workload = cfg["workload_identity_name"]
user_id = cfg["user_id"]
print(f"既存の設定を利用: workload={workload} user_id={user_id}")
else:
workload = cp.create_workload_identity(name=f"drive3lo-{uuid.uuid4().hex[:8]}")["name"]
user_id = uuid.uuid4().hex[:8]
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(
{"workload_identity_name": workload, "user_id": user_id}, indent=2
))
print(f"作成: workload={workload} user_id={user_id}")
# 戻り先URLを登録(callbackサーバー & Credential Provider の両方)
cp.update_workload_identity(
name=workload,
allowedResourceOauth2ReturnUrls=[LOCAL_CALLBACK, GOOGLE_CALLBACK_URL],
)
print("戻り先URLを登録しました:")
print(f" - {LOCAL_CALLBACK}")
print(f" - {GOOGLE_CALLBACK_URL}")
print("\n下準備完了。次は3ターミナルで本番実行してください。")python setup_identity.pyで実行します。
既存の設定を利用: workload=drive3lo-7f8409c6 user_id=bd0c5684
戻り先URLを登録しました:
- http://localhost:8090/callback
- https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/b7f6c57b-0b6e-4b7b-9c9c-cbd2a319af8d
下準備完了。次は3ターミナルで本番実行してください。これから3つのターミナルを使います。
- Googleからのトークンの受け取り
- AgentCore の起動
- AgentCore の呼び出し
では実行してみます。まずターミナル1
cd ~/agentcore-runtime-quickstart/drive3lo
python callback_server.py次にターミナル2
cd ~/agentcore-runtime-quickstart/drive3lo
export AWS_REGION=us-west-2
export AWS_DEFAULT_REGION=us-west-2
agentcore dev --logs最後にターミナル3
cd ~/agentcore-runtime-quickstart/drive3lo
agentcore dev "Google Drive のファイルを読んで"ターミナル2に以下の様に同意用URLが表示されます。
→ ============================================================
→ ▼ 次の URL をブラウザで開いて同意してください:
→ https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3AZmZmYjQ2NTAtZmVlYy00MjM3LWJiMmUtNmZkMmVkNjAxNjAy
→ ============================================================ブラウザでこちらを開きGoogleにログインします。ログインが完了するとターミナル3には以下の様な表示がなされてログインが成功していることがわかります。
Google Driveのファイル一覧を取得します。Google Driveのファイル一覧(先頭10件)を取得しました:
1. **亀田さん依頼管理シート** - Googleスプレッドシート
2. **2026年8月業務サマリー** - Googleドキュメント
3. **ASK for JP market** - Googleドキュメント
4. **August - Invoice.pdf** - PDFファイル
5. **Invoice.xlsx** - Excelファイル
6. **亀田さんとお仕事** - Googleドキュメント
7. **IoTLT 登壇者** - Googleスプレッドシート
8. **書籍制作進行予定表_ローカルLLM構築入門** - Googleスプレッドシート
9. **August - Invoice.pdf** - PDFファイル(重複?)
10. **2026年8月.pdf** - PDFファイル
業務管理シート、請求書、書籍制作関連の資料などが含まれているようですね。特定のファイルについて詳しく知りたい場合は、お知らせください!
