Amazon Bedrock AgentCore IdentityのOn-Behalf-Of(OBO)トークン交換機能を実際に試して理解を深めてみた

Amazon Bedrock AgentCore IdentityのOn-Behalf-Of(OBO)トークン交換機能を実際に試して理解を深めてみた

On-Behalf-Of(OBO)トークン交換を実際に試してみました!理解するのが難しいですね・・・!!!!
2026.10.15

こんにちは、Amazon Bedrock AgentCoreが大好きなコンサル部の神野です。

私は恥ずかしながらAgentCore IdentityのOn-Behalf-Of(OBO)トークン交換を気になってはいたものの「結局なんで要るの?」がピンと来ていない状態でした・・・

https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-bedrock-agentcore/

ただ実際に使うかどうか検討する日がついにきて、何者かを整理したくブログにすることにしました!

OBOが要る理由

なんでこの機能が必要なんでしょうね。まずはその意義から整理していきましょう!

Entra IDのトークンには宛先が書いてある

Entra IDのトークンのおさらいです。ユーザーがEntra IDでサインインすると、アクセストークンが発行されます。アプリはこのトークンを受け取って、誰からのリクエストかを確認します。

トークンの中には、だれ(name)、どのアプリ向けか(aud)、何をしてよいか(scp)が書かれています。
Entra IDはトークンを使う先のアプリごとに発行するので、仮にエージェントの認証として使っているならトークンの宛先はエージェントのアプリになります。

ユーザーがEntra IDでサインインすると、name・aud・scpが書かれたアクセストークンが発行される図

受け取ったアプリは、宛先が自分になっているかを確認してから使うべきで、Microsoftのドキュメントでも、Web APIは aud が自分のトークンを受け付けるように、と書かれています。トークンならなんでもいいというわけではないですよね。

https://learn.microsoft.com/ja-jp/entra/identity-platform/access-tokens

ここでEntraで認証したAIエージェントがMicrosoft Graphを使いたいケースを考えてみます。

もらったトークンはエージェント宛て

社内向けのエージェントでは、ユーザーはこのエージェント宛てのトークンを付けて依頼するのですが、このエージェントがMicrosoft Graphを呼び出そうとすると、同じトークンをそのまま渡すと断られます。

エージェント宛てのトークンをそのままGraphへ渡すと401で拒否される図

エージェント宛てのトークンでGraphの /me を呼び出してみるとこんなようなエラーが起きます。

実行結果
{"error":{"code":"InvalidAuthenticationToken","message":"Access token validation failure. Invalid audience.", ...}}
HTTP 401

「このトークンは自分宛てではない」と弾かれていますね。
確かに宛先を確認せずに受け付けると、別のサービス向けに発行されたトークンを使い回せてしまうので、断るのが正しいように感じます。

ならどうするのがいいんでしょうか。

ほかのやり方だと困ること

エージェントはどうやってGraph APIを呼び出せばいいのか思いつくやり方を並べてみます。

エージェント自身の権限、サービスごとの再ログイン、OBOの3つを比べた図

エージェント自身の権限(アプリの権限)で呼ぶと、Graphから見た操作者はエージェントになります。
誰の依頼かがGraphに伝わらないので、ユーザーごとに見せるデータを絞る処理をエージェント側で作り込むことになり、権限もアプリに付くので、付ける権限によっては全員分のデータを読める状態になるのでユーザーごとに制御したい場合は避けたいですよね。

これがユーザーにサービスごとのログインと同意をしてもらう方法(3LO)なら本人の権限で呼べます。ただ同じEntraIDで認証しているのに再同意するのはちょっと気持ち悪いですよね・・・

そこでOBOは、サインイン時のトークンをEntra IDに渡して「この人の代わりにGraphを呼びたい」と頼み、Graph宛てのトークンに交換してもらう機能なのです。その交換の際にAgentCore Identityが活躍します。

MicrosoftのOBOフローのドキュメントでも、ユーザーの身元と権限を、呼び出しの連鎖の先へ引き継ぐための仕組みとして説明されています。

https://learn.microsoft.com/ja-jp/entra/identity-platform/v2-oauth2-on-behalf-of-flow

最初からGraph宛てのトークンを渡せばいいのでは?

ここで「OBOしなくても、ユーザーが最初からGraph宛てのトークンを取ってエージェントに渡せばいいのでは?」と素朴に思いつきました。

ただ、これだとエージェントの入口でトークンを確認する仕組み(インバウンド認証)と噛み合わなくなると思います。AgentCore Runtimeのインバウンド認証は、届いたトークンの発行者や宛先をRuntimeの設定と照合し、合わないものはエージェントのコードに届く前に断ります。今回のRuntimeは、宛先がエージェントのアプリIDのトークンだけを受け付ける設定なので、Graph宛てのトークンは想定していません。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/inbound-jwt-authorizer.html

Graph宛てのトークンでエージェントを呼び出すと、Runtimeのインバウンド認証で拒否される図

実際に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用に別途発行されたトークンを使う形です。

https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization

セキュリティのベストプラクティスでは、この転送を「Token passthrough」として、避けたいパターンに挙げています。アクセス制御の回避や、誰の操作か追えなくなることなどがリスクとして書かれています。

https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices

OBOで交換したトークンなら、上流のAPI(今回はGraph)宛てに発行された別のトークンなので、この考え方にも沿えますね。

仕組みは理解したので、これがAgentCore Identityでどう楽になるかを確認してみます!

AgentCore Identityに任せられること

OBOの交換自体は、Entra IDのトークンエンドポイントに頼めばできますが、自前で書くとこんな処理になります。パラメーターはMicrosoftのOBOフローのドキュメントに沿ったイメージです。ちょっと大変そうですね。

自前で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です。デコレーターを付けるだけですね!

AgentCore Identityを使う場合
@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用のプロバイダーが付けてくれるので実装も簡単です。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/on-behalf-of-token-exchange.html

AgentCore Gatewayなら、コードを書かずに済みます。ターゲットのアウトバウンド認証(ターゲットを呼び出すときの認証)で、grantType を TOKEN_EXCHANGE にするだけです。

Gatewayのターゲット設定(抜粋)
"oauthCredentialProvider": {
  "providerArn": "arn:aws:bedrock-agentcore:...:oauth2credentialprovider/entra-obo-provider",
  "scopes": ["https://graph.microsoft.com/User.Read"],
  "grantType": "TOKEN_EXCHANGE"
}

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-outbound-auth.html

今回の構成

RuntimeとGatewayでOBOをそれぞれ試してみます!
どんなことをやるかサクッとイメージです。

Runtimeパターン

ユーザーはaz CLIでサインインし、取得したトークンを付けてAgentCore Runtime上のエージェントを呼び出します。エージェントがAgentCore IdentityでGraph用のトークンに交換し、そのトークンで /me を呼び出します。

ユーザー、Entra ID、AgentCore Runtime、AgentCore Identity、Microsoft Graphの構成図

Gatewayパターン

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

MCPクライアント、Entra ID、AgentCore Gateway、AgentCore Identity、Microsoft 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を控えておきます。以降のコマンドで使います。

作成したアプリのクライアントIDとテナントIDを控える

控えた値を変数に入れる
APP_ID=<クライアントID>
TENANT_ID=<テナントID>

スコープを公開する

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

スコープの追加でアプリケーションID URIを既定値のまま保存する

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

スコープ名access_as_userを入力し、管理者とユーザーを選んで追加する

Azure CLIを承認する

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

Azure CLIのクライアントIDを入力し、スコープにチェックを入れる

今回は手軽に試すためにaz CLIをクライアントにしましたが、実際のアプリではWebアプリやチャット画面などのフロントエンドがクライアントになります。その場合はフロントエンド用のアプリを別に登録し、MSALなどで api://<エージェントのクライアントID>/access_as_user を指定してサインインします。同意画面を出したくなければ、Azure CLIの代わりにフロントエンドのクライアントIDをここで承認済みにすればOKです。

フロントエンドはこのトークンをエージェントに渡すだけで、Graph宛てへの交換はエージェント側(OBO)で行います。MicrosoftのOBOのドキュメントでも、SPAは中間のAPIにトークンを渡してOBOしてもらう形が案内されています。

スコープの公開や承認済みクライアントの設定は、Microsoftのクイックスタートにまとまっています。

https://learn.microsoft.com/ja-jp/entra/identity-platform/quickstart-configure-app-expose-web-apis

Graphの権限に同意する

「APIのアクセス許可」を開くと、新規登録したアプリにはMicrosoft Graphの User.Read(委任)が最初から入っていました。「管理者の同意を与えます」を押して、状態が付与済みになればOKです。OBOでGraph宛てのトークンをもらうときに、この権限が使われます。

User.Readに管理者の同意を与え、状態が付与済みになる

同意を事前に済ませておくのは、OBOの交換がエージェントとEntra IDの間で行われ、その場でユーザーに同意画面を出せないためです。

「組織全員ぶんの同意って押して大丈夫?」と少し不安になりますが、ここで認めるのは「このアプリが、サインインしたユーザーの代わりに User.Read(本人のプロフィールの読み取り)を使ってよい」という委任の権限で、アプリが代理でできるのは本人ができる範囲までで、ほかの人のデータを読めるようになるわけではありません。

https://learn.microsoft.com/ja-jp/entra/identity-platform/permissions-consent-overview

トークンのバージョンを2にする

「マニフェスト」で requestedAccessTokenVersion を 2 に変えて保存します。AgentCoreのドキュメントにあるEntra ID向けの設定手順に合わせています。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-idp-microsoft.html

マニフェストのrequestedAccessTokenVersionを2に変更して保存する

クライアントシークレットを作る

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

クライアントシークレットの説明を入力して追加する

ポータルでの作業はここまでです。同じ設定はAzure CLIでも作れます。

Azure CLIで作る場合(setup_entra.sh)
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のペイロードを表示する)
decode.py
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へそのまま送った結果です。

そのまま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 を指定します。

app/obo_agent/main.py(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:
    ...  # access_token は Graph 宛てに交換済み

先述したようにユーザーのトークンを関数に渡す処理はありません!

RuntimeがJWTの検証後に、ユーザー情報を含むワークロード用のトークンをエージェントへ渡し、SDKがそれを使って交換を依頼します。

app/obo_agent/main.py(全体)
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
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を呼び出す
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宛てのトークンでRuntimeを呼び出す
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で交換します。

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": "神野 雄大"
}

並べるとこうなります。

交換前後でnameは同じまま、audがエージェントのアプリからGraphに変わることを示す図

宛先(aud)はGraphに変わり、権限(scp)はGraphの User.Read になりました。一方で名前(name)は本人のままで、どのアプリが代理で呼び出しているか(appid)も入っています。Graphから見ると「agentcore-obo-agent-portalが神野さんの代わりに呼び出している」ことが分かる状態ですね。

Gatewayパターンを試す

次は、交換をGatewayに任せるパターンです。GraphのAPIをOpenAPIのツールとしてGatewayに登録し、ターゲットのアウトバウンド認証をOBOにします。

Gatewayのロールを作る

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を作成
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
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の設定です。

OBOでGraphを呼ぶターゲットを追加
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のツールを呼び出す
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"}}}'
実行結果(tools/callの抜粋)
{"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の実行ロールに手で足したインラインポリシーは、スタックを削除する前に外しておきます。

Gatewayを削除
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
Runtimeとプロバイダーを削除
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
Entra IDのアプリを削除
az ad app delete --id "${APP_ID}"

おわりに

実際に触ってみてOBOの存在意義を少しつかめたのではないでしょうか!Entraをインバウンド認証で使って、かつ外向きにもそのトークンを使いたい際に有用な印象ですね!

OBOに出せるのは、Entra IDがエージェント宛てに発行したトークンです。サインインをCognitoなど別のIdPでしている構成なら、3LOでユーザーにMicrosoftへの同意をもらう形が候補になります。

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事