
Amazon Bedrock AgentCore IdentityのOn-Behalf-Of(OBO)トークン交換機能を実際に試して理解を深めてみた
こんにちは、Amazon Bedrock AgentCoreが大好きなコンサル部の神野です。
私は恥ずかしながらAgentCore IdentityのOn-Behalf-Of(OBO)トークン交換を気になってはいたものの「結局なんで要るの?」がピンと来ていない状態でした・・・
ただ実際に使うかどうか検討する日がついにきて、何者かを整理したくブログにすることにしました!
OBOが要る理由
なんでこの機能が必要なんでしょうね。まずはその意義から整理していきましょう!
Entra IDのトークンには宛先が書いてある
Entra IDのトークンのおさらいです。ユーザーがEntra IDでサインインすると、アクセストークンが発行されます。アプリはこのトークンを受け取って、誰からのリクエストかを確認します。
トークンの中には、だれ(name)、どのアプリ向けか(aud)、何をしてよいか(scp)が書かれています。
Entra IDはトークンを使う先のアプリごとに発行するので、仮にエージェントの認証として使っているならトークンの宛先はエージェントのアプリになります。

受け取ったアプリは、宛先が自分になっているかを確認してから使うべきで、Microsoftのドキュメントでも、Web APIは aud が自分のトークンを受け付けるように、と書かれています。トークンならなんでもいいというわけではないですよね。
ここでEntraで認証したAIエージェントがMicrosoft Graphを使いたいケースを考えてみます。
もらったトークンはエージェント宛て
社内向けのエージェントでは、ユーザーはこのエージェント宛てのトークンを付けて依頼するのですが、このエージェントがMicrosoft Graphを呼び出そうとすると、同じトークンをそのまま渡すと断られます。

エージェント宛てのトークンでGraphの /me を呼び出してみるとこんなようなエラーが起きます。
{"error":{"code":"InvalidAuthenticationToken","message":"Access token validation failure. Invalid audience.", ...}}
HTTP 401
「このトークンは自分宛てではない」と弾かれていますね。
確かに宛先を確認せずに受け付けると、別のサービス向けに発行されたトークンを使い回せてしまうので、断るのが正しいように感じます。
ならどうするのがいいんでしょうか。
ほかのやり方だと困ること
エージェントはどうやってGraph APIを呼び出せばいいのか思いつくやり方を並べてみます。

エージェント自身の権限(アプリの権限)で呼ぶと、Graphから見た操作者はエージェントになります。
誰の依頼かがGraphに伝わらないので、ユーザーごとに見せるデータを絞る処理をエージェント側で作り込むことになり、権限もアプリに付くので、付ける権限によっては全員分のデータを読める状態になるのでユーザーごとに制御したい場合は避けたいですよね。
これがユーザーにサービスごとのログインと同意をしてもらう方法(3LO)なら本人の権限で呼べます。ただ同じEntraIDで認証しているのに再同意するのはちょっと気持ち悪いですよね・・・
そこでOBOは、サインイン時のトークンをEntra IDに渡して「この人の代わりにGraphを呼びたい」と頼み、Graph宛てのトークンに交換してもらう機能なのです。その交換の際にAgentCore Identityが活躍します。
MicrosoftのOBOフローのドキュメントでも、ユーザーの身元と権限を、呼び出しの連鎖の先へ引き継ぐための仕組みとして説明されています。
最初からGraph宛てのトークンを渡せばいいのでは?
ここで「OBOしなくても、ユーザーが最初からGraph宛てのトークンを取ってエージェントに渡せばいいのでは?」と素朴に思いつきました。
ただ、これだとエージェントの入口でトークンを確認する仕組み(インバウンド認証)と噛み合わなくなると思います。AgentCore Runtimeのインバウンド認証は、届いたトークンの発行者や宛先をRuntimeの設定と照合し、合わないものはエージェントのコードに届く前に断ります。今回のRuntimeは、宛先がエージェントのアプリIDのトークンだけを受け付ける設定なので、Graph宛てのトークンは想定していません。

実際にGraph宛てのトークンでRuntimeを呼び出すと、401で断られました。
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Claim 'iss' value mismatch with configuration."}}
HTTP 401
では、Runtime側でGraph宛てのトークンも受け付けるように設定すればいいのかというと、Microsoftのドキュメントを見る限り、おすすめできなさそうです。Graph宛てのトークンは独自形式なので、自分のアプリでは検証できません。また、別のリソース宛てのトークンを受け付けるのは「confused deputy(混乱した代理人)」問題の一例だと書かれています。OBOのドキュメントにも、Graph宛てのトークンはOBOに使えないので受け付けないように、と書かれています。
エージェントは自分宛てのトークンだけを受け付け、Graphを呼ぶときはOBOで交換する、という形がよさそうですね。
MCPサーバーはトークンをそのまま渡さない
ツールをMCPサーバーとして提供する場合も同じ考え方です。MCPの認可仕様(2025-11-25版)では、MCPサーバーは自分宛てに発行されたトークンを受け付け、上流のAPIを呼ぶときは受け取ったトークンをそのまま渡さないことになっています。上流のAPIでは、そのAPI用に別途発行されたトークンを使う形です。
セキュリティのベストプラクティスでは、この転送を「Token passthrough」として、避けたいパターンに挙げています。アクセス制御の回避や、誰の操作か追えなくなることなどがリスクとして書かれています。
OBOで交換したトークンなら、上流のAPI(今回はGraph)宛てに発行された別のトークンなので、この考え方にも沿えますね。
仕組みは理解したので、これがAgentCore Identityでどう楽になるかを確認してみます!
AgentCore Identityに任せられること
OBOの交換自体は、Entra IDのトークンエンドポイントに頼めばできますが、自前で書くとこんな処理になります。パラメーターはMicrosoftのOBOフローのドキュメントに沿ったイメージです。ちょっと大変そうですね。
res = httpx.post(
f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET, # シークレットをどこかで管理して読み込む
"assertion": user_token, # 受け取ったユーザーのトークンをそのまま渡す
"scope": "https://graph.microsoft.com/User.Read",
"requested_token_use": "on_behalf_of",
},
)
graph_token = res.json()["access_token"]
そこでAgentCore Identityを使うと、エージェントのコードは「このプロバイダーでOBOのトークンをちょうだい」と頼めばOKです。デコレーターを付けるだけですね!
@requires_access_token(
provider_name="entra-obo-provider",
auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
scopes=["https://graph.microsoft.com/User.Read"],
)
async def call_graph_me(*, access_token: str) -> dict:
... # access_token は Graph 宛てに交換済み
シークレットはトークン保管庫(Token Vault)に預けたままで、ユーザーのトークンを受け渡すコードもありません。Microsoft独自の requested_token_use などのパラメーターも、Microsoft用のプロバイダーが付けてくれるので実装も簡単です。
AgentCore Gatewayなら、コードを書かずに済みます。ターゲットのアウトバウンド認証(ターゲットを呼び出すときの認証)で、grantType を TOKEN_EXCHANGE にするだけです。
"oauthCredentialProvider": {
"providerArn": "arn:aws:bedrock-agentcore:...:oauth2credentialprovider/entra-obo-provider",
"scopes": ["https://graph.microsoft.com/User.Read"],
"grantType": "TOKEN_EXCHANGE"
}
今回の構成
RuntimeとGatewayでOBOをそれぞれ試してみます!
どんなことをやるかサクッとイメージです。
Runtimeパターン
ユーザーはaz CLIでサインインし、取得したトークンを付けてAgentCore Runtime上のエージェントを呼び出します。エージェントがAgentCore IdentityでGraph用のトークンに交換し、そのトークンで /me を呼び出します。

Gatewayパターン
MCPクライアントがユーザーのトークンを付けてAgentCore Gatewayのツールを呼び出します。Gatewayがインバウンド認証でトークンを確認し、ターゲットを呼び出す前にAgentCore IdentityでGraph用のトークンに交換します。

さっそくやってみましょう!
やってみる
前提
Entra IDのアプリ登録とGraph権限への管理者同意ができるアカウントが必要です。
| 項目 | バージョン |
|---|---|
| Azure CLI | 2.85.0 |
| AWS CLI | 2.37.13 |
| AgentCore CLI | 0.30.0 |
| Node.js | 26.7.0 |
| uv | 0.9.26 |
| Python(Runtime) | 3.14 |
| bedrock-agentcore / strands-agents / httpx | 1.24.1 / 1.59.0 / 0.28.1 |
Entra IDにエージェント用アプリを作る
Azureポータルの「アプリの登録」から作っていきます。設定するのは次の5つです。
- アプリを登録する
- スコープ
access_as_userを公開する(ユーザーがエージェントを使うための権限) - Azure CLIを承認済みのクライアントにする(同意画面なしでトークンを取得するため)
- Microsoft Graphの
User.Read(委任)に管理者の同意を与える(OBOで交換する先の権限) - アクセストークンのバージョンを2にして、クライアントシークレットを作る
アプリを登録する
「アプリの登録」で「新規登録」を押します。

名前を入力し、アカウントの種類は「シングルテナントのみ」のまま登録します。今回は agentcore-obo-agent-portal としました。

作成されたら、概要画面のクライアントIDとテナントIDを控えておきます。以降のコマンドで使います。

APP_ID=<クライアントID>
TENANT_ID=<テナントID>
スコープを公開する
「APIの公開」で「Scopeの追加」を押すと、先にアプリケーションID URIの設定を求められます。既定の api://<クライアントID> のまま保存します。

スコープ名に access_as_user を入力し、「同意できるのはだれですか?」で「管理者とユーザー」を選びます。表示名と説明は分かりやすいものを入れておきます。

Azure CLIを承認する
同じ「APIの公開」画面の「クライアントアプリケーションの追加」で、Azure CLIのクライアントID 04b07795-8ddb-461a-bbee-02f9e1bf7b46 を入力し、スコープにチェックを入れて追加します。これで、az CLIから同意画面なしでこのアプリ宛てのトークンを取れます。

今回は手軽に試すためにaz CLIをクライアントにしましたが、実際のアプリではWebアプリやチャット画面などのフロントエンドがクライアントになります。その場合はフロントエンド用のアプリを別に登録し、MSALなどで api://<エージェントのクライアントID>/access_as_user を指定してサインインします。同意画面を出したくなければ、Azure CLIの代わりにフロントエンドのクライアントIDをここで承認済みにすればOKです。
フロントエンドはこのトークンをエージェントに渡すだけで、Graph宛てへの交換はエージェント側(OBO)で行います。MicrosoftのOBOのドキュメントでも、SPAは中間のAPIにトークンを渡してOBOしてもらう形が案内されています。
スコープの公開や承認済みクライアントの設定は、Microsoftのクイックスタートにまとまっています。
Graphの権限に同意する
「APIのアクセス許可」を開くと、新規登録したアプリにはMicrosoft Graphの User.Read(委任)が最初から入っていました。「管理者の同意を与えます」を押して、状態が付与済みになればOKです。OBOでGraph宛てのトークンをもらうときに、この権限が使われます。

同意を事前に済ませておくのは、OBOの交換がエージェントとEntra IDの間で行われ、その場でユーザーに同意画面を出せないためです。
「組織全員ぶんの同意って押して大丈夫?」と少し不安になりますが、ここで認めるのは「このアプリが、サインインしたユーザーの代わりに User.Read(本人のプロフィールの読み取り)を使ってよい」という委任の権限で、アプリが代理でできるのは本人ができる範囲までで、ほかの人のデータを読めるようになるわけではありません。
トークンのバージョンを2にする
「マニフェスト」で requestedAccessTokenVersion を 2 に変えて保存します。AgentCoreのドキュメントにあるEntra ID向けの設定手順に合わせています。

クライアントシークレットを作る
「証明書とシークレット」で「新しいクライアントシークレット」を押し、説明を入力して追加します。追加すると値が一度だけ表示されるので控えておきます。AgentCore IdentityがOBOの交換で使います。

ポータルでの作業はここまでです。同じ設定はAzure CLIでも作れます。
Azure CLIで作る場合(setup_entra.sh)
set -e
TENANT_ID=$(az account show --query tenantId -o tsv)
APP_ID=$(az ad app create --display-name agentcore-obo-agent \
--sign-in-audience AzureADMyOrg --query appId -o tsv)
OBJ_ID=$(az ad app show --id "${APP_ID}" --query id -o tsv)
SCOPE_ID=$(uuidgen | tr A-Z a-z)
# スコープ access_as_user を公開し、アクセストークンのバージョンを 2 にする
az ad app update --id "${APP_ID}" --identifier-uris "api://${APP_ID}"
az rest --method PATCH \
--url "https://graph.microsoft.com/v1.0/applications/${OBJ_ID}" \
--headers Content-Type=application/json --body "{
\"api\": {
\"requestedAccessTokenVersion\": 2,
\"oauth2PermissionScopes\": [{
\"id\": \"${SCOPE_ID}\", \"value\": \"access_as_user\", \"type\": \"User\", \"isEnabled\": true,
\"adminConsentDisplayName\": \"Access agent as user\",
\"adminConsentDescription\": \"Access agent as user\"
}]
}
}"
# Azure CLI(04b07795-...)を事前承認する
az rest --method PATCH \
--url "https://graph.microsoft.com/v1.0/applications/${OBJ_ID}" \
--headers Content-Type=application/json --body "{
\"api\": { \"preAuthorizedApplications\": [{
\"appId\": \"04b07795-8ddb-461a-bbee-02f9e1bf7b46\",
\"delegatedPermissionIds\": [\"${SCOPE_ID}\"]
}] }
}"
# Microsoft Graph の User.Read(委任)を追加して、テナント全体に同意する
az ad app permission add --id "${APP_ID}" \
--api 00000003-0000-0000-c000-000000000000 \
--api-permissions e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope
az ad sp create --id "${APP_ID}"
az ad app permission grant --id "${APP_ID}" \
--api 00000003-0000-0000-c000-000000000000 --scope User.Read
echo "TENANT_ID=${TENANT_ID}" > .env.ids
echo "APP_ID=${APP_ID}" >> .env.ids
bash setup_entra.sh
source .env.ids
クライアントシークレットは、次の節でプロバイダーを作るときに az ad app credential reset で発行できます。
ユーザーのトークンを取ってみる
az CLIで、エージェント用アプリ宛てのトークンを取得します。
USER_TOKEN=$(az account get-access-token \
--scope "api://${APP_ID}/access_as_user" --query accessToken -o tsv)
中身(ペイロード)をデコードすると、宛先がエージェントのアプリIDになっています。
decode.py(JWTのペイロードを表示する)
import sys, base64, json
keys = sys.argv[1:]
p = sys.stdin.read().strip().split(".")[1]
p += "=" * (-len(p) % 4)
c = json.loads(base64.urlsafe_b64decode(p))
print(json.dumps({k: c.get(k) for k in keys} if keys else c, ensure_ascii=False, indent=1))
echo "${USER_TOKEN}" | uv run decode.py aud iss scp azp name
{
"aud": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"iss": "https://login.microsoftonline.com/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy/v2.0",
"scp": "access_as_user",
"azp": "04b07795-8ddb-461a-bbee-02f9e1bf7b46",
"name": "神野 雄大"
}
冒頭の401は、このトークンをGraphへそのまま送った結果です。
curl -s -w "\nHTTP %{http_code}\n" https://graph.microsoft.com/v1.0/me \
-H "Authorization: Bearer ${USER_TOKEN}"
続いてAgentCore Identityの設定進めていきましょう!
AgentCore IdentityにMicrosoft用プロバイダーを登録する
控えたクライアントシークレットを、AgentCore IdentityのOAuthプロバイダーに登録します。
read -s SECRET # ポータルで控えたシークレットの値を貼り付ける
aws bedrock-agentcore-control create-oauth2-credential-provider \
--name entra-obo-provider \
--credential-provider-vendor MicrosoftOauth2 \
--oauth2-provider-config-input "{\"microsoftOauth2ProviderConfig\":{
\"clientId\":\"${APP_ID}\",\"clientSecret\":\"${SECRET}\",\"tenantId\":\"${TENANT_ID}\"}}"
unset SECRET
Azure CLIでシークレットを発行する場合
SECRET=$(az ad app credential reset --id "${APP_ID}" \
--display-name agentcore-obo --years 1 --query password -o tsv)
この後に上の create-oauth2-credential-provider を実行します。
OBO用の設定項目(onBehalfOfTokenExchangeConfig)はカスタムプロバイダー向けで、Microsoft用プロバイダーには項目自体がないため、クライアントID、シークレット、テナントIDを指定します。
ここから先は、RuntimeパターンとGatewayパターンどちらも試してみます!
Runtimeパターンを試す
エージェントを作る
AgentCore CLIでプロジェクトを作り、Entra IDのトークンで受け付けるエージェントを追加します。インバウンド認証の条件は、discovery URLと宛先(エージェントのアプリID)です。
agentcore create --project-name OboDemo --no-agent --skip-git
cd OboDemo
agentcore add agent --name obo_agent --type create --language Python \
--framework Strands --model-provider Bedrock --memory none \
--authorizer-type CUSTOM_JWT \
--discovery-url "https://login.microsoftonline.com/${TENANT_ID}/v2.0/.well-known/openid-configuration" \
--allowed-audience "${APP_ID}"
生成された app/obo_agent/main.py は、Graphを呼び出すツールを持つ形に書き換えます。OBOの設定は requires_access_token で ON_BEHALF_OF_TOKEN_EXCHANGE を指定します。
@requires_access_token(
provider_name="entra-obo-provider",
auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
scopes=["https://graph.microsoft.com/User.Read"],
)
async def call_graph_me(*, access_token: str) -> dict:
... # access_token は Graph 宛てに交換済み
先述したようにユーザーのトークンを関数に渡す処理はありません!
RuntimeがJWTの検証後に、ユーザー情報を含むワークロード用のトークンをエージェントへ渡し、SDKがそれを使って交換を依頼します。
app/obo_agent/main.py(全体)
import httpx
from strands import Agent, tool
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from bedrock_agentcore.identity.auth import requires_access_token
app = BedrockAgentCoreApp()
# ユーザーのトークンをGraph用に交換(OBO)してから呼び出す
@requires_access_token(
provider_name="entra-obo-provider",
auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
scopes=["https://graph.microsoft.com/User.Read"],
)
async def call_graph_me(*, access_token: str) -> dict:
async with httpx.AsyncClient() as client:
res = await client.get(
"https://graph.microsoft.com/v1.0/me",
params={"$select": "displayName,userPrincipalName"},
headers={"Authorization": f"Bearer {access_token}"},
)
return {"status": res.status_code, "body": res.json()}
@tool
async def get_my_profile() -> dict:
"""サインイン中のユーザー本人のプロフィールをMicrosoft Graphから取得する"""
return await call_graph_me()
@app.entrypoint
async def invoke(payload, context):
# 会話履歴が他のユーザーと混ざらないよう、リクエストごとにAgentを作る
agent = Agent(
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
system_prompt="あなたは社内アシスタントです。ユーザー本人の情報はツールで確認してください。",
tools=[get_my_profile],
)
result = await agent.invoke_async(payload.get("prompt", ""))
return {"result": str(result)}
if __name__ == "__main__":
app.run()
生成されたテンプレートにあったMCPクライアントなどは使わないので、mcp_client/・skills/・model/ は削除しました。pyproject.toml の依存関係も合わせて整理しています。
app/obo_agent/pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "obo_agent"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"aws-opentelemetry-distro ~= 0.18.0",
"bedrock-agentcore >= 1.24.1",
"strands-agents >= 1.59.0",
"httpx >= 0.28.0",
]
[tool.hatch.build.targets.wheel]
packages = ["."]
cd app/obo_agent && uv lock && cd ../..
デプロイして権限を足す
デプロイ先のアカウントとリージョンを書いて、デプロイします。
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "[{\"name\":\"default\",\"account\":\"${ACCOUNT_ID}\",\"region\":\"us-east-1\"}]" \
> agentcore/aws-targets.json
agentcore deploy -y
CLIが作るRuntimeの実行ロールには、今回CLIの外で作ったプロバイダーを使う権限が入っていませんでした。トークンの取得と、プロバイダーのシークレットの読み取りを許可します。
ROLE_NAME=$(aws cloudformation describe-stacks --stack-name AgentCore-OboDemo-default \
--query "Stacks[0].Outputs[?contains(OutputKey,'RoleArn')].OutputValue" --output text | cut -d/ -f2)
SECRET_ARN=$(aws bedrock-agentcore-control get-oauth2-credential-provider \
--name entra-obo-provider --query clientSecretArn.secretArn --output text)
BASE="arn:aws:bedrock-agentcore:us-east-1:${ACCOUNT_ID}"
aws iam put-role-policy --role-name "${ROLE_NAME}" --policy-name entra-obo --policy-document "{
\"Version\": \"2012-10-17\",
\"Statement\": [
{\"Effect\": \"Allow\", \"Action\": \"bedrock-agentcore:GetResourceOauth2Token\",
\"Resource\": [
\"${BASE}:token-vault/default\",
\"${BASE}:token-vault/default/oauth2credentialprovider/entra-obo-provider\",
\"${BASE}:workload-identity-directory/default\",
\"${BASE}:workload-identity-directory/default/workload-identity/*\"]},
{\"Effect\": \"Allow\", \"Action\": \"secretsmanager:GetSecretValue\", \"Resource\": \"${SECRET_ARN}\"}
]
}"
呼び出してみる
az CLIで取ったユーザーのトークンを付けて、Runtimeを呼び出します。質問は「私は誰ですか?」です。
RUNTIME_ARN=$(aws cloudformation describe-stacks --stack-name AgentCore-OboDemo-default \
--query "Stacks[0].Outputs[?contains(OutputKey,'RuntimeArn')].OutputValue" --output text)
ENCODED=$(uv run python -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "${RUNTIME_ARN}")
USER_TOKEN=$(az account get-access-token \
--scope "api://${APP_ID}/access_as_user" --query accessToken -o tsv)
curl -s -X POST \
"https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${ENCODED}/invocations?qualifier=DEFAULT" \
-H "Authorization: Bearer ${USER_TOKEN}" \
-H "Content-Type: application/json" \
-H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: $(uuidgen)-$(uuidgen)" \
-d '{"prompt": "私は誰ですか?"}'
{"result": "あなたは**神野 雄大**さんです。\n\nメールアドレスは `user@example.onmicrosoft.com` として登録されています。\n\n何かお手伝いできることはございますか?\n"}
Graphから本人の名前が返ってきました! 冒頭で401になったのと同じユーザーのトークンから、エージェントがGraphを本人として呼び出せていますね。
前の節の「Graph宛てのトークンを渡したらどうなるか」も、ここで試しています。az CLIでGraph宛てのトークンを取り、同じように呼び出しました。
GRAPH_TOKEN=$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv)
curl -s -w "\nHTTP %{http_code}\n" -X POST \
"https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${ENCODED}/invocations?qualifier=DEFAULT" \
-H "Authorization: Bearer ${GRAPH_TOKEN}" \
-H "Content-Type: application/json" \
-H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: $(uuidgen)-$(uuidgen)" \
-d '{"prompt": "私は誰ですか?"}'
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Claim 'iss' value mismatch with configuration."}}
HTTP 401
こちらも予想した通り、エラーになっていますね!
交換前後のトークンを比べる
交換でトークンの何が変わったのかも見ておきます。Runtimeの中でSDKがしていることを、CLIで同じように実行しました。ユーザーのトークンからワークロード用のトークンを作り、OBOで交換します。
aws bedrock-agentcore-control create-workload-identity --name obo-local-test
WAT=$(aws bedrock-agentcore get-workload-access-token-for-jwt \
--workload-name obo-local-test --user-token "${USER_TOKEN}" \
--query workloadAccessToken --output text)
OBO_TOKEN=$(aws bedrock-agentcore get-resource-oauth2-token \
--resource-credential-provider-name entra-obo-provider \
--oauth2-flow ON_BEHALF_OF_TOKEN_EXCHANGE \
--scopes "https://graph.microsoft.com/User.Read" \
--workload-identity-token "${WAT}" --query accessToken --output text)
echo "${OBO_TOKEN}" | uv run decode.py aud scp appid app_displayname name
{
"aud": "https://graph.microsoft.com",
"scp": "User.Read profile openid email",
"appid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"app_displayname": "agentcore-obo-agent-portal",
"name": "神野 雄大"
}
並べるとこうなります。

宛先(aud)はGraphに変わり、権限(scp)はGraphの User.Read になりました。一方で名前(name)は本人のままで、どのアプリが代理で呼び出しているか(appid)も入っています。Graphから見ると「agentcore-obo-agent-portalが神野さんの代わりに呼び出している」ことが分かる状態ですね。
Gatewayパターンを試す
次は、交換をGatewayに任せるパターンです。GraphのAPIをOpenAPIのツールとしてGatewayに登録し、ターゲットのアウトバウンド認証をOBOにします。
Gatewayのロールを作る
Gatewayがトークンの交換とシークレットの読み取りをするためのサービスロールを作ります。
Gatewayのサービスロールを作成
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
BASE="arn:aws:bedrock-agentcore:us-east-1:${ACCOUNT_ID}"
SECRET_ARN=$(aws bedrock-agentcore-control get-oauth2-credential-provider \
--name entra-obo-provider --query clientSecretArn.secretArn --output text)
aws iam create-role --role-name obo-gateway-role --assume-role-policy-document "{
\"Version\": \"2012-10-17\",
\"Statement\": [{\"Effect\": \"Allow\",
\"Principal\": {\"Service\": \"bedrock-agentcore.amazonaws.com\"},
\"Action\": \"sts:AssumeRole\",
\"Condition\": {\"StringEquals\": {\"aws:SourceAccount\": \"${ACCOUNT_ID}\"}}}]
}"
aws iam put-role-policy --role-name obo-gateway-role --policy-name obo-gateway --policy-document "{
\"Version\": \"2012-10-17\",
\"Statement\": [
{\"Effect\": \"Allow\",
\"Action\": [\"bedrock-agentcore:GetWorkloadAccessToken\", \"bedrock-agentcore:GetWorkloadAccessTokenForJWT\"],
\"Resource\": [\"${BASE}:workload-identity-directory/default\",
\"${BASE}:workload-identity-directory/default/workload-identity/obo-gateway-*\"]},
{\"Effect\": \"Allow\", \"Action\": \"bedrock-agentcore:GetResourceOauth2Token\",
\"Resource\": [\"${BASE}:token-vault/default\",
\"${BASE}:token-vault/default/oauth2credentialprovider/entra-obo-provider\",
\"${BASE}:workload-identity-directory/default\",
\"${BASE}:workload-identity-directory/default/workload-identity/obo-gateway-*\"]},
{\"Effect\": \"Allow\", \"Action\": \"secretsmanager:GetSecretValue\", \"Resource\": \"${SECRET_ARN}\"}
]
}"
Gatewayとターゲットを作る
インバウンド認証は、Runtimeと同じEntra IDのdiscovery URLと宛先(エージェントのアプリID)にします。
GATEWAY_ID=$(aws bedrock-agentcore-control create-gateway --name obo-gateway \
--role-arn "arn:aws:iam::${ACCOUNT_ID}:role/obo-gateway-role" \
--protocol-type MCP --authorizer-type CUSTOM_JWT \
--authorizer-configuration "{\"customJWTAuthorizer\":{
\"discoveryUrl\":\"https://login.microsoftonline.com/${TENANT_ID}/v2.0/.well-known/openid-configuration\",
\"allowedAudience\":[\"${APP_ID}\"]}}" \
--query gatewayId --output text)
ターゲットは、Graphの /me を定義したOpenAPIスキーマです。
graph-me.json
{
"openapi": "3.0.1",
"info": {"title": "Microsoft Graph (me)", "version": "1.0"},
"servers": [{"url": "https://graph.microsoft.com/v1.0"}],
"paths": {
"/me": {
"get": {
"operationId": "getMyProfile",
"description": "サインイン中のユーザー本人のプロフィールを取得する",
"parameters": [{"name": "$select", "in": "query", "required": false, "schema": {"type": "string"}}],
"responses": {"200": {"description": "ユーザーのプロフィール",
"content": {"application/json": {"schema": {"type": "object"}}}}}
}
}
}
}
ターゲットのアウトバウンド認証で、プロバイダーと grantType: TOKEN_EXCHANGE を指定するのがOBOの設定です。
PROVIDER_ARN=$(aws bedrock-agentcore-control get-oauth2-credential-provider \
--name entra-obo-provider --query credentialProviderArn --output text)
SCHEMA=$(uv run python -c "import json;print(json.dumps(open('graph-me.json').read()))")
aws bedrock-agentcore-control create-gateway-target --gateway-identifier "${GATEWAY_ID}" --name graph \
--target-configuration "{\"mcp\":{\"openApiSchema\":{\"inlinePayload\":${SCHEMA}}}}" \
--credential-provider-configurations "[{
\"credentialProviderType\": \"OAUTH\",
\"credentialProvider\": {\"oauthCredentialProvider\": {
\"providerArn\": \"${PROVIDER_ARN}\",
\"scopes\": [\"https://graph.microsoft.com/User.Read\"],
\"grantType\": \"TOKEN_EXCHANGE\"}}}]"
Gatewayを呼び出してみる
作成できたので、MCPクライアントの代わりにcurlで、ユーザーのトークンを付けて tools/list と tools/call を呼び出します。
GATEWAY_URL=$(aws bedrock-agentcore-control get-gateway --gateway-identifier "${GATEWAY_ID}" \
--query gatewayUrl --output text)
curl -s -X POST "${GATEWAY_URL}" -H "Authorization: Bearer ${USER_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -s -X POST "${GATEWAY_URL}" -H "Authorization: Bearer ${USER_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"graph___getMyProfile","arguments":{"$select":"displayName,userPrincipalName"}}}'
{"jsonrpc":"2.0","id":2,"result":{"isError":false,"content":[{"type":"text","text":"{...\"displayName\":\"神野 雄大\",\"userPrincipalName\":\"user@example.onmicrosoft.com\"}"}]}}
ツール名は graph___getMyProfile で、Graphから本人の名前が返ってきました! トークン交換のコードを書いていません!
Gatewayがユーザーのトークンを確認し、ターゲットを呼び出す前にGraph宛てのトークンへ交換してくれています。
もちろんGraph宛てのトークンでGatewayを呼び出した場合も、入口で断られました。
{"jsonrpc":"2.0","id":0,"error":{"code":-32002,"message":"insufficient_scope - The request requires higher privileges than provided by the access token."}}
HTTP 403
後片付け
Gateway、Runtime、プロバイダー、Entra IDのアプリを削除します。Runtimeの実行ロールに手で足したインラインポリシーは、スタックを削除する前に外しておきます。
TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets --gateway-identifier "${GATEWAY_ID}" \
--query "items[0].targetId" --output text)
aws bedrock-agentcore-control delete-gateway-target --gateway-identifier "${GATEWAY_ID}" --target-id "${TARGET_ID}"
aws bedrock-agentcore-control delete-gateway --gateway-identifier "${GATEWAY_ID}"
aws iam delete-role-policy --role-name obo-gateway-role --policy-name obo-gateway
aws iam delete-role --role-name obo-gateway-role
aws iam delete-role-policy --role-name "${ROLE_NAME}" --policy-name entra-obo
aws cloudformation delete-stack --stack-name AgentCore-OboDemo-default
aws bedrock-agentcore-control delete-oauth2-credential-provider --name entra-obo-provider
aws bedrock-agentcore-control delete-workload-identity --name obo-local-test
az ad app delete --id "${APP_ID}"
おわりに
実際に触ってみてOBOの存在意義を少しつかめたのではないでしょうか!Entraをインバウンド認証で使って、かつ外向きにもそのトークンを使いたい際に有用な印象ですね!
OBOに出せるのは、Entra IDがエージェント宛てに発行したトークンです。サインインをCognitoなど別のIdPでしている構成なら、3LOでユーザーにMicrosoftへの同意をもらう形が候補になります。
本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!











