1
0
mirror of synced 2026-05-22 14:43:35 +00:00

Performing On-behalf of Token (OBO) token exchange in AgentCore Gateway (#1435)

* OBO token exchange

* Update README.md

* Update token_callback_server.py

* Update token_callback_server.py
This commit is contained in:
Dhawalkumar Patel
2026-05-02 12:23:52 -07:00
committed by GitHub
parent 43b7a14354
commit de8e80bd1d
5 changed files with 1054 additions and 0 deletions
@@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
.ipynb_checkpoints/
.bedrock_agentcore.yaml
.venv/
@@ -0,0 +1,85 @@
# OBO Token Exchange with AgentCore Gateway + Microsoft Graph API
## Overview
This tutorial demonstrates how to use **Amazon Bedrock AgentCore Gateway** with **On-Behalf-Of (OBO) token exchange** to expose Microsoft Graph API endpoints as MCP tools. The agent code contains zero token handling logic — the Gateway handles the OBO exchange transparently at the infrastructure level.
OBO token exchange enables agents to access protected resources on behalf of authenticated users without triggering additional consent flows. The Gateway exchanges the inbound user's access token for a new, scoped access token that targets a downstream resource server (Microsoft Graph), using the JWT Authorization Grant (RFC 7523).
### How It Works
1. User authenticates directly with **Microsoft Entra ID** and gets an access token scoped to the app (`api://<client-id>/access_as_user`)
2. User passes that Entra ID token to the **AgentCore Gateway** as the bearer
3. Gateway validates the token using Entra ID's OIDC discovery URL (inbound auth)
4. Gateway performs **OBO token exchange** — swaps the app-scoped Entra ID token for a Microsoft Graph token using JWT Authorization Grant (RFC 7523)
5. A **Strands agent** connects to the Gateway MCP URL, discovers tools, and invokes them
### Architecture
```mermaid
sequenceDiagram
participant User
participant Entra as Microsoft Entra ID
participant Agent as Strands Agent
participant GW as AgentCore Gateway
participant Graph as Microsoft Graph API
User->>Entra: 1. Sign in (browser)
Entra-->>User: 2. Access token (aud: api://app, scp: access_as_user)
User->>Agent: 3. Run agent with bearer token
Agent->>GW: 4. MCP tool call (e.g. getMyProfile)
Note over GW: Validate JWT via Entra ID OIDC
GW->>Entra: 5. OBO exchange (jwt-bearer grant, RFC 7523)
Entra-->>GW: 6. Graph token (aud: graph.microsoft.com)
GW->>Graph: 7. GET /me with Graph token
Graph-->>GW: 8. {displayName: "John Doe"}
GW-->>Agent: 9. MCP tool response
Agent-->>User: 10. "Your name is John Doe"
```
### Key Concepts
**Why `CustomOauth2` (not `MicrosoftOAuth2`)?** The `onBehalfOfTokenExchangeConfig` parameter is only available inside `customOauth2ProviderConfig`, not the built-in Microsoft provider.
**Why `requested_token_use: on_behalf_of` in customParameters?** Entra ID's token endpoint requires this parameter to perform the OBO exchange. Without it, the exchange fails silently.
**Why v1.0 discovery URL?** Entra ID issues v1.0 access tokens by default (issuer: `sts.windows.net`). The Gateway's inbound auth discovery URL must match the token version.
### Tutorial Details
| Information | Details |
|:---------------------|:-------------------------------------------------------------------------|
| Tutorial type | Interactive |
| AgentCore components | AgentCore Gateway, AgentCore Identity |
| Agentic Framework | Strands Agents |
| LLM model | Anthropic Claude Haiku 4.5 |
| Tutorial components | AgentCore Gateway with OBO Token Exchange, Microsoft Graph API |
| Tutorial vertical | Cross-vertical |
| Example complexity | Medium |
| SDK used | boto3, strands-agents, mcp |
| Credential Provider | CustomOauth2 with JWT_AUTHORIZATION_GRANT OBO config |
| Inbound Auth | Entra ID OIDC (CUSTOM_JWT) |
| Gateway Target | OpenAPI Schema (Microsoft Graph API) |
## Prerequisites
- Python 3.10+
- AWS credentials configured
- A Microsoft 365 **work or school account** with access to Microsoft Entra ID
> ⚠️ **Personal Microsoft accounts** (`@outlook.com`, `@hotmail.com`, `@live.com`) will not work for the calendar/email endpoints. The OBO exchange itself works, but Microsoft Graph calendar and mail endpoints require an Exchange Online mailbox (work/school accounts only). The `/me` profile endpoint works with all account types.
## Tutorial
- [OBO Token Exchange with AgentCore Gateway + Microsoft Graph API](obo_token_exchange_microsoft.ipynb)
## Reference Documentation
- [OBO Token Exchange](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/on-behalf-of-token-exchange.html)
- [Gateway Outbound Auth](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-outbound-auth.html)
- [Microsoft Entra ID OBO Flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow)
@@ -0,0 +1,751 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "overview",
"metadata": {},
"source": [
"# OBO Token Exchange with AgentCore Gateway + Microsoft Graph API\n",
"\n",
"This tutorial shows how to use **AgentCore Gateway** to expose the Microsoft Graph API as MCP tools, with **OBO (On-Behalf-Of) token exchange** handling authentication transparently. The agent code is just a few lines — no token handling, no session binding, no Runtime deployment.\n",
"\n",
"### How It Works\n",
"\n",
"1. User authenticates directly with **Microsoft Entra ID** and gets an access token scoped to the app (`api://<client-id>/access_as_user`)\n",
"2. User passes that Entra ID token to the **AgentCore Gateway** as the bearer\n",
"3. Gateway validates the token using Entra ID's OIDC discovery URL (inbound auth)\n",
"4. Gateway performs **OBO token exchange** — swaps the app-scoped Entra ID token for a Microsoft Graph token using JWT Authorization Grant (RFC 7523)\n",
"5. A **Strands agent** connects to the Gateway MCP URL, discovers tools, and invokes them\n",
"\n",
"**Docs:** [OBO Token Exchange](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/on-behalf-of-token-exchange.html) · [Gateway Outbound Auth](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-outbound-auth.html)"
]
},
{
"cell_type": "markdown",
"id": "prerequisites",
"metadata": {},
"source": [
"## Prerequisites\n",
"\n",
"- Python 3.10+\n",
"- AWS credentials configured\n",
"- Amazon Bedrock AgentCore SDK\n",
"- A Microsoft 365 **work or school account** with access to Microsoft Entra ID\n",
"\n",
"> ⚠️ **Personal Microsoft accounts (`@outlook.com`, `@hotmail.com`, `@live.com`) will not work for the calendar/email demo.** The OBO token exchange itself works with personal accounts, but Microsoft Graph calendar and mail endpoints require an Exchange Online mailbox, which is only available with work/school accounts. Get a free sandbox at the [Microsoft 365 Developer Program](https://developer.microsoft.com/en-us/microsoft-365/dev-program)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "install-deps",
"metadata": {},
"outputs": [],
"source": [
"!pip3 install -U -r requirements.txt --quiet"
]
},
{
"cell_type": "markdown",
"id": "step1-header",
"metadata": {},
"source": [
"---\n",
"## Step 1: Register Application in Microsoft Entra ID\n",
"\n",
"### 1.1 Create App Registration\n",
"1. Go to [entra.microsoft.com](https://entra.microsoft.com) → **Identity** → **Applications** → **App registrations**\n",
"2. Click **+ New registration**\n",
"3. Name: `AgentCore-OBO-Tutorial`, Single tenant\n",
"4. Click **Register**\n",
"\n",
"### 1.2 Record IDs\n",
"From the **Overview** page, copy:\n",
"- **Application (client) ID** → `MICROSOFT_CLIENT_ID`\n",
"- **Directory (tenant) ID** → `MICROSOFT_TENANT_ID`\n",
"\n",
"### 1.3 Create Client Secret\n",
"1. **Certificates & secrets** → **+ New client secret**\n",
"2. Copy the **Value** immediately → `MICROSOFT_CLIENT_SECRET`\n",
"\n",
"### 1.4 Configure API Permissions\n",
"1. **API permissions** → **+ Add a permission** → **Microsoft Graph** → **Delegated permissions**\n",
"2. Add: `Calendars.Read`, `Mail.Read`, `User.Read`\n",
"\n",
"### 1.5 Expose an API (Required for OBO)\n",
"1. **Expose an API** → Set **Application ID URI** (accept default `api://<client-id>`)\n",
"2. **+ Add a scope**: name `access_as_user`, consent by Admins and users, Enabled\n",
"\n",
"### 1.6 Configure Authentication (Redirect URI)\n",
"1. **Authentication** → **+ Add a platform** → **Web**\n",
"2. Add redirect URI: `http://localhost:9090/oauth2/callback`\n",
"3. Click **Configure**\n",
"\n",
"> This redirect URI is used by the token callback server to receive the authorization code.\n",
"\n",
"### 1.7 Grant Admin Consent\n",
"1. **API permissions** → **Grant admin consent for [tenant]** → **Yes**\n",
"\n",
"> ✅ All permissions should show green checkmarks.\n",
"\n",
"### Important: Token Version\n",
"By default, Entra ID issues **v1.0 access tokens** (issuer: `https://sts.windows.net/{tenant}/`). The Gateway discovery URL in this tutorial uses the v1.0 endpoint to match. If you need v2.0 tokens, set `accessTokenAcceptedVersion: 2` in the app manifest and use the v2.0 discovery URL instead."
]
},
{
"cell_type": "markdown",
"id": "step2-header",
"metadata": {},
"source": [
"---\n",
"## Step 2: Create the OBO Credential Provider\n",
"\n",
"Fill in your Entra ID credentials in the `.env` file below, then create the credential provider.\n",
"\n",
"**Important:** The `onBehalfOfTokenExchangeConfig` is only available inside `customOauth2ProviderConfig`, so we use `CustomOauth2` as the vendor (not the built-in `MicrosoftOAuth2` provider)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "write-env",
"metadata": {},
"outputs": [],
"source": [
"%%writefile .env\n",
"MICROSOFT_CLIENT_ID=\"\" # Application (client) ID from Entra ID\n",
"MICROSOFT_CLIENT_SECRET=\"\" # Client secret value from Entra ID\n",
"MICROSOFT_TENANT_ID=\"\" # Directory (tenant) ID from Entra ID"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "setup-clients",
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import json\n",
"import time\n",
"import urllib.parse\n",
"\n",
"import boto3\n",
"from boto3.session import Session\n",
"from dotenv import dotenv_values\n",
"\n",
"# Load .env and set environment variables\n",
"env = dotenv_values('.env')\n",
"os.environ.update(env)\n",
"\n",
"boto_session = Session()\n",
"region = boto_session.region_name\n",
"print(f\"Region: {region}\")\n",
"\n",
"identity_client = boto_session.client(\"bedrock-agentcore-control\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "create-provider",
"metadata": {},
"outputs": [],
"source": [
"tenant_id = os.environ[\"MICROSOFT_TENANT_ID\"]\n",
"client_id = os.environ[\"MICROSOFT_CLIENT_ID\"]\n",
"\n",
"# Create Custom OAuth2 credential provider with OBO token exchange\n",
"# Note: onBehalfOfTokenExchangeConfig is only available inside customOauth2ProviderConfig\n",
"microsoft_provider = identity_client.create_oauth2_credential_provider(\n",
" name=\"microsoft-obo-provider-v3\",\n",
" credentialProviderVendor=\"CustomOauth2\",\n",
" oauth2ProviderConfigInput={\n",
" \"customOauth2ProviderConfig\": {\n",
" \"oauthDiscovery\": {\n",
" \"discoveryUrl\": f\"https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration\"\n",
" },\n",
" \"clientId\": os.environ[\"MICROSOFT_CLIENT_ID\"],\n",
" \"clientSecret\": os.environ[\"MICROSOFT_CLIENT_SECRET\"],\n",
" \"clientAuthenticationMethod\": \"CLIENT_SECRET_POST\",\n",
" \"onBehalfOfTokenExchangeConfig\": {\n",
" \"grantType\": \"JWT_AUTHORIZATION_GRANT\"\n",
" }\n",
" }\n",
" }\n",
")\n",
"print(\"Credential provider created \\u2713\")\n",
"print(f\"ARN: {microsoft_provider['credentialProviderArn']}\")"
]
},
{
"cell_type": "markdown",
"id": "step3-header",
"metadata": {},
"source": [
"---\n",
"## Step 3: Create the AgentCore Gateway\n",
"\n",
"The Gateway \"MCPfies\" Microsoft Graph API:\n",
"- **Inbound auth**: Entra ID OIDC JWT authorizer (CUSTOM_JWT) — validates tokens issued directly by Entra ID\n",
"- **Target**: OpenAPI schema exposing calendar, email, and profile endpoints as MCP tools\n",
"- **Outbound auth**: OBO token exchange via the credential provider\n",
"\n",
"**Key insight**: We use Entra ID's **v1.0** OIDC discovery URL for inbound auth. Entra ID issues v1.0 access tokens by default (issuer: `sts.windows.net`), so the discovery URL must match. Using the v2.0 URL would cause a 403 because the signing keys and issuer don't match.\n",
"\n",
"First, we create an IAM role for the Gateway, then create the Gateway itself."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "create-iam-role",
"metadata": {},
"outputs": [],
"source": [
"iam_client = boto3.client(\"iam\")\n",
"account_id = boto3.client(\"sts\").get_caller_identity()[\"Account\"]\n",
"\n",
"# Use the shared utils to create the Gateway IAM role\n",
"# This creates a role with bedrock-agentcore:*, secretsmanager:GetSecretValue, etc.\n",
"# matching the pattern used by other AgentCore Gateway tutorials\n",
"import sys\n",
"gw_utils_dir = os.path.abspath(os.path.join(os.getcwd(), '..', '..', '02-AgentCore-gateway'))\n",
"sys.path.insert(0, gw_utils_dir)\n",
"try:\n",
" from utils import create_agentcore_gateway_role\n",
" gateway_role_name = f'agentcore-microsoft-obo-gateway-role'\n",
" gateway_iam_role = create_agentcore_gateway_role('microsoft-obo-gateway')\n",
" gateway_role_arn = gateway_iam_role['Role']['Arn']\n",
" print(f'Gateway role: {gateway_role_arn}')\n",
"except ImportError:\n",
" print('Shared utils not found, creating role inline...')\n",
" gateway_role_name = 'agentcore-obo-gateway-role'\n",
" assume_role_policy = {\n",
" 'Version': '2012-10-17',\n",
" 'Statement': [{\n",
" 'Effect': 'Allow',\n",
" 'Principal': {'Service': 'bedrock-agentcore.amazonaws.com'},\n",
" 'Action': 'sts:AssumeRole',\n",
" 'Condition': {\n",
" 'StringEquals': {'aws:SourceAccount': account_id},\n",
" 'ArnLike': {'aws:SourceArn': f'arn:aws:bedrock-agentcore:{region}:{account_id}:*'}\n",
" }\n",
" }]\n",
" }\n",
" try:\n",
" role_response = iam_client.create_role(RoleName=gateway_role_name, AssumeRolePolicyDocument=json.dumps(assume_role_policy))\n",
" gateway_role_arn = role_response['Role']['Arn']\n",
" except iam_client.exceptions.EntityAlreadyExistsException:\n",
" gateway_role_arn = f'arn:aws:iam::{account_id}:role/{gateway_role_name}'\n",
" gateway_policy = {\n",
" 'Version': '2012-10-17',\n",
" 'Statement': [\n",
" {'Sid': 'AgentCoreAccess', 'Effect': 'Allow', 'Action': ['bedrock-agentcore:*'], 'Resource': '*'},\n",
" {'Sid': 'SecretsManagerAccess', 'Effect': 'Allow', 'Action': ['secretsmanager:GetSecretValue'], 'Resource': [f'arn:aws:secretsmanager:{region}:{account_id}:secret:*bedrock-agentcore*']}\n",
" ]\n",
" }\n",
" iam_client.put_role_policy(RoleName=gateway_role_name, PolicyName='GatewayOBOPolicy', PolicyDocument=json.dumps(gateway_policy))\n",
" print(f'Gateway role: {gateway_role_arn}')\n",
"\n",
"print('Waiting for IAM propagation (10s)...')\n",
"time.sleep(10)\n",
"print('Done \\u2713')"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "create-gateway",
"metadata": {},
"outputs": [],
"source": [
"agentcore_control_client = boto3.client(\"bedrock-agentcore-control\", region_name=region)\n",
"\n",
"# Entra ID v1.0 OIDC discovery URL for inbound auth\n",
"# IMPORTANT: Entra ID issues v1.0 access tokens by default (issuer: sts.windows.net).\n",
"# The discovery URL MUST match the token version — use v1.0, not v2.0.\n",
"discovery_url = f\"https://login.microsoftonline.com/{tenant_id}/.well-known/openid-configuration\"\n",
"\n",
"# Create Gateway with Entra ID JWT authorizer\n",
"# allowedAudience uses the Application ID URI (api://<client-id>) since the\n",
"# Entra ID access token will have this as the \"aud\" claim\n",
"gateway_response = agentcore_control_client.create_gateway(\n",
" name=\"microsoft-obo-gateway-v3\",\n",
" roleArn=gateway_role_arn,\n",
" protocolType=\"MCP\",\n",
" authorizerType=\"CUSTOM_JWT\",\n",
" authorizerConfiguration={\n",
" \"customJWTAuthorizer\": {\n",
" \"discoveryUrl\": discovery_url,\n",
" \"allowedAudience\": [f\"api://{client_id}\"],\n",
" }\n",
" },\n",
" exceptionLevel=\"DEBUG\" # Show detailed errors for debugging\n",
")\n",
"gateway_id = gateway_response[\"gatewayId\"]\n",
"print(f\"Gateway created \\u2713 ID: {gateway_id}\")\n",
"print(f\"Inbound auth: Entra ID OIDC (tenant: {tenant_id})\")\n",
"print(f\"Allowed audience: api://{client_id}\")"
]
},
{
"cell_type": "markdown",
"id": "step3b-header",
"metadata": {},
"source": [
"### Step 3b: Wait for Gateway, then Add Microsoft Graph Target\n",
"\n",
"The Gateway must be in `READY` status before adding targets. Then we define the OpenAPI schema for Microsoft Graph endpoints — the Gateway exposes these as MCP tools.\n",
"\n",
"**Important notes:**\n",
"- Use `top` and `select` as parameter names (not `$top`/`$select`) — the `$` character breaks Bedrock's tool schema validation\n",
"- The `customParameters` with `requested_token_use: on_behalf_of` is **required** for Entra ID's OBO endpoint"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "create-target",
"metadata": {},
"outputs": [],
"source": [
"# Wait for Gateway to become READY before adding targets\n",
"end_statuses = [\"READY\", \"FAILED\", \"CREATE_FAILED\", \"UPDATE_FAILED\", \"DELETE_FAILED\"]\n",
"status = \"CREATING\"\n",
"print(\"Waiting for Gateway to become READY...\")\n",
"while status not in end_statuses:\n",
" time.sleep(10)\n",
" gw = agentcore_control_client.get_gateway(gatewayIdentifier=gateway_id)\n",
" status = gw[\"status\"]\n",
" print(f\" Gateway status: {status}\")\n",
"print(f\"Gateway is {status} \\u2713\" if status == \"READY\" else f\"\\u274c Gateway: {status}\")\n",
"\n",
"# Define the OpenAPI spec for Microsoft Graph endpoints\n",
"openapi_spec = json.dumps({\n",
" \"openapi\": \"3.0.0\",\n",
" \"info\": {\"title\": \"Microsoft Graph Calendar API\", \"version\": \"1.0\"},\n",
" \"servers\": [{\"url\": \"https://graph.microsoft.com/v1.0\"}],\n",
" \"paths\": {\n",
" \"/me/calendarview\": {\n",
" \"get\": {\n",
" \"operationId\": \"listCalendarEvents\",\n",
" \"summary\": \"List calendar events for the current user within a date range\",\n",
" \"parameters\": [\n",
" {\"name\": \"startDateTime\", \"in\": \"query\", \"required\": True, \"schema\": {\"type\": \"string\"}, \"description\": \"Start date/time in ISO 8601 format (e.g. 2025-01-01T00:00:00Z)\"},\n",
" {\"name\": \"endDateTime\", \"in\": \"query\", \"required\": True, \"schema\": {\"type\": \"string\"}, \"description\": \"End date/time in ISO 8601 format (e.g. 2025-01-02T00:00:00Z)\"}\n",
" ],\n",
" \"responses\": {\"200\": {\"description\": \"Calendar events\"}}\n",
" }\n",
" },\n",
" \"/me/messages\": {\n",
" \"get\": {\n",
" \"operationId\": \"listUserMails\",\n",
" \"summary\": \"List recent email messages for the current user\",\n",
" \"parameters\": [\n",
" {\"name\": \"top\", \"in\": \"query\", \"required\": False, \"schema\": {\"type\": \"integer\"}, \"description\": \"Number of messages to return\"},\n",
" {\"name\": \"select\", \"in\": \"query\", \"required\": False, \"schema\": {\"type\": \"string\"}, \"description\": \"Comma-separated list of fields to return\"}\n",
" ],\n",
" \"responses\": {\"200\": {\"description\": \"Email messages\"}}\n",
" }\n",
" },\n",
" \"/me\": {\n",
" \"get\": {\n",
" \"operationId\": \"getMyProfile\",\n",
" \"summary\": \"Get the current user profile information\",\n",
" \"responses\": {\"200\": {\"description\": \"User profile\"}}\n",
" }\n",
" }\n",
" }\n",
"})\n",
"\n",
"# Create the Gateway target with OBO outbound auth\n",
"target_response = agentcore_control_client.create_gateway_target(\n",
" gatewayIdentifier=gateway_id,\n",
" name=\"microsoft-graph-obo\",\n",
" description=\"Microsoft Graph API with OBO token exchange\",\n",
" targetConfiguration={\n",
" \"mcp\": {\n",
" \"openApiSchema\": {\n",
" \"inlinePayload\": openapi_spec\n",
" }\n",
" }\n",
" },\n",
" credentialProviderConfigurations=[{\n",
" \"credentialProviderType\": \"OAUTH\",\n",
" \"credentialProvider\": {\n",
" \"oauthCredentialProvider\": {\n",
" \"providerArn\": microsoft_provider[\"credentialProviderArn\"],\n",
" \"scopes\": [\"https://graph.microsoft.com/.default\"],\n",
" \"grantType\": \"TOKEN_EXCHANGE\",\n",
" \"customParameters\": {\n",
" \"requested_token_use\": \"on_behalf_of\"\n",
" }\n",
" }\n",
" }\n",
" }]\n",
")\n",
"target_id = target_response[\"targetId\"]\n",
"print(f\"Target created \\u2713 ID: {target_id}\")\n",
"print(f\"Target name: {target_response['name']}\")"
]
},
{
"cell_type": "markdown",
"id": "step3c-header",
"metadata": {},
"source": [
"### Step 3c: Wait for Target\n",
"\n",
"Wait for the Target to reach `READY` status."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "poll-gateway",
"metadata": {},
"outputs": [],
"source": [
"# Wait for Target to become READY\n",
"target_end_statuses = [\"READY\", \"FAILED\", \"UPDATE_UNSUCCESSFUL\", \"SYNCHRONIZE_UNSUCCESSFUL\"]\n",
"target_status = \"CREATING\"\n",
"\n",
"print(\"Waiting for Target to become READY...\")\n",
"while target_status not in target_end_statuses:\n",
" time.sleep(10)\n",
" tgt = agentcore_control_client.get_gateway_target(\n",
" gatewayIdentifier=gateway_id,\n",
" targetId=target_id\n",
" )\n",
" target_status = tgt[\"status\"]\n",
" print(f\" Target status: {target_status}\")\n",
"\n",
"if target_status == \"READY\":\n",
" print(f\"\\nTarget is READY \\u2713\")\n",
"else:\n",
" print(f\"\\n\\u274c Target ended in status: {target_status}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "get-mcp-url",
"metadata": {},
"outputs": [],
"source": [
"# Get the Gateway MCP URL\n",
"gateway_details = agentcore_control_client.get_gateway(gatewayIdentifier=gateway_id)\n",
"gateway_mcp_url = gateway_details.get(\"gatewayUrl\", \"\")\n",
"print(f\"Gateway MCP URL: {gateway_mcp_url}\")"
]
},
{
"cell_type": "markdown",
"id": "step4-header",
"metadata": {},
"source": [
"---\n",
"## Step 4: Get an Entra ID Access Token\n",
"\n",
"This cell starts an environment-aware callback server, opens the Entra ID login page in your browser, and automatically captures the token when you sign in. No copy-paste needed.\n",
"\n",
"### Environment-Aware OAuth2 Callback Server\n",
"\n",
"The `token_callback_server.py` automatically adapts to different execution environments:\n",
"\n",
"**Local Development:**\n",
"- External Callback URL: `http://localhost:9090/oauth2/callback` (browser-accessible)\n",
"- Internal Communication: `http://localhost:9090` (notebook ↔ server)\n",
"- Server Binding: `127.0.0.1` (localhost only, secure)\n",
"\n",
"**SageMaker Workshop Studio:**\n",
"- External Callback URL: `https://<domain>.studio.<region>.sagemaker.aws/proxy/9090/oauth2/callback` (browser-accessible via proxy)\n",
"- Internal Communication: `http://localhost:9090` (notebook ↔ server in same container)\n",
"- Server Binding: `0.0.0.0` (allows SageMaker proxy to reach server)\n",
"\n",
"The server detects the environment by checking for `/opt/ml/metadata/resource-metadata.json` and configures itself accordingly.\n",
"\n",
"> ⚠️ **Register the callback URL with Entra ID:** The redirect URI in your Entra ID app registration (Step 1.6) must match the callback URL for your environment. For local development use `http://localhost:9090/oauth2/callback`. For SageMaker, use the proxy URL printed by the cell below."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "print-authorize-url",
"metadata": {},
"outputs": [],
"source": [
"import subprocess\n",
"import webbrowser\n",
"import base64\n",
"import sys\n",
"from token_callback_server import (\n",
" is_server_running, get_callback_url, wait_for_server_ready,\n",
" wait_for_token, get_callback_base_url\n",
")\n",
"\n",
"bearer_token = None\n",
"\n",
"# Start the token callback server if not already running\n",
"if is_server_running():\n",
" print('Token callback server already running, skipping start...')\n",
"else:\n",
" server_cmd = [\n",
" sys.executable, 'token_callback_server.py',\n",
" tenant_id, client_id, os.environ['MICROSOFT_CLIENT_SECRET']\n",
" ]\n",
" server_process = subprocess.Popen(server_cmd)\n",
" if not wait_for_server_ready():\n",
" print('\\u274c Failed to start token callback server')\n",
" else:\n",
" print('Token callback server started \\u2713')\n",
"\n",
"# Build authorize URL using the environment-aware callback URL\n",
"callback_url = get_callback_url()\n",
"authorize_url = (\n",
" f'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize?'\n",
" f'client_id={client_id}&response_type=code&'\n",
" f'redirect_uri={urllib.parse.quote(callback_url)}&'\n",
" f'scope={urllib.parse.quote(f\"api://{client_id}/access_as_user openid profile email\")}'\n",
")\n",
"\n",
"print(f'Callback URL: {callback_url}')\n",
"print('Opening browser for Microsoft sign-in...')\n",
"webbrowser.open(authorize_url)\n",
"print('Waiting for sign-in (up to 2 minutes)...')\n",
"\n",
"bearer_token = wait_for_token(timeout=120)\n",
"\n",
"if bearer_token:\n",
" payload = bearer_token.split('.')[1]\n",
" payload += '=' * (4 - len(payload) % 4)\n",
" claims = json.loads(base64.urlsafe_b64decode(payload))\n",
" print(f'\\n\\u2705 Token captured for: {claims.get(\"name\", \"unknown\")}')\n",
" print(f' aud: {claims[\"aud\"]}')\n",
" print(f' scp: {claims.get(\"scp\", \"N/A\")}')\n",
" print(f' iss: {claims[\"iss\"]}')\n",
" print(f' ver: {claims[\"ver\"]}')\n",
"else:\n",
" print('\\u274c Timed out. Run this cell again.')"
]
},
{
"cell_type": "markdown",
"id": "step5-header",
"metadata": {},
"source": [
"---\n",
"## Step 5: Run the Agent\n",
"\n",
"This is the entire agent — just a few lines. The `MCPClient` connects to the Gateway, discovers the MCP tools (profile, calendar, email), and the Strands Agent uses them to answer the user's question.\n",
"\n",
"**No `@requires_access_token`, no custom tools, no callback server, no Docker.**\n",
"\n",
"> 💡 The `getMyProfile` tool works with all account types. The `listCalendarEvents` and `listUserMails` tools require a work/school account with an Exchange Online mailbox."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "run-agent",
"metadata": {},
"outputs": [],
"source": [
"from strands import Agent\n",
"from strands.tools.mcp import MCPClient\n",
"from mcp.client.streamable_http import streamablehttp_client\n",
"\n",
"# Connect to the AgentCore Gateway as an MCP server\n",
"mcp_client = MCPClient(\n",
" lambda: streamablehttp_client(\n",
" gateway_mcp_url,\n",
" headers={\"Authorization\": f\"Bearer {bearer_token}\"}\n",
" )\n",
")\n",
"\n",
"with mcp_client:\n",
" # The Gateway provides MCP tools: listCalendarEvents, listUserMails, getMyProfile\n",
" tools = mcp_client.list_tools_sync()\n",
" print(f\"Discovered {len(tools)} MCP tools from Gateway:\")\n",
" for t in tools:\n",
" print(f\" - {t.tool_name}\")\n",
"\n",
" agent = Agent(\n",
" model=\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\n",
" tools=tools,\n",
" system_prompt=\"You are a Microsoft 365 assistant. Use the available tools to help users with their Microsoft profile, Outlook calendar, and email.\"\n",
" )\n",
"\n",
" response = agent(\"What is my Microsoft profile information?\")\n",
" print(response)"
]
},
{
"cell_type": "markdown",
"id": "step5b-header",
"metadata": {},
"source": [
"### Step 5b: Compare User Token vs OBO Token\n",
"\n",
"The OBO exchange transforms the token in two critical ways:\n",
"\n",
"| | User Token (before OBO) | OBO Token (after OBO) |\n",
"|---|---|---|\n",
"| **Audience (`aud`)** | Your app: `api://<client-id>` | Microsoft Graph: `https://graph.microsoft.com` |\n",
"| **Scopes (`scp`)** | `access_as_user` | `Calendars.Read Mail.Read User.Read` |\n",
"| **Identity** | User's identity | Same user — preserved through delegation |\n",
"\n",
"The OBO token also carries a **delegation chain** via the `xms_st` claim, which contains the original user's `sub` from the inbound token. This is how Microsoft Graph knows the request comes from an app acting **on behalf of** a specific user, not the app itself.\n",
"\n",
"Let's decode both tokens and see the difference:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "compare-tokens",
"metadata": {},
"outputs": [],
"source": [
"import requests as req\n",
"\n",
"# Perform the OBO exchange manually to see the resulting token\n",
"obo_resp = req.post(\n",
" f'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token',\n",
" data={\n",
" 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n",
" 'client_id': client_id,\n",
" 'client_secret': os.environ['MICROSOFT_CLIENT_SECRET'],\n",
" 'assertion': bearer_token,\n",
" 'scope': 'https://graph.microsoft.com/Calendars.Read https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read',\n",
" 'requested_token_use': 'on_behalf_of',\n",
" }\n",
")\n",
"\n",
"def decode_jwt(token):\n",
" payload = token.split('.')[1]\n",
" payload += '=' * (4 - len(payload) % 4)\n",
" return json.loads(base64.urlsafe_b64decode(payload))\n",
"\n",
"obo_token = obo_resp.json().get('access_token', '')\n",
"if not obo_token:\n",
" print(f'OBO exchange failed: {obo_resp.json()}')\n",
"else:\n",
" user_claims = decode_jwt(bearer_token)\n",
" obo_claims = decode_jwt(obo_token)\n",
"\n",
" print(f'{\"CLAIM\":<20} {\"USER TOKEN (app-scoped)\":<40} {\"OBO TOKEN (Graph-scoped)\"}')\n",
" print('=' * 100)\n",
" for f in ['aud', 'iss', 'ver', 'scp', 'appid', 'name', 'email', 'idp', 'oid']:\n",
" uv = str(user_claims.get(f, '\\u2014'))[:38]\n",
" ov = str(obo_claims.get(f, '\\u2014'))[:38]\n",
" changed = ' \\u2190 CHANGED' if uv != ov else ''\n",
" print(f'{f:<20} {uv:<40} {ov}{changed}')\n",
"\n",
" # Show the delegation claim\n",
" xms_st = obo_claims.get('xms_st', {})\n",
" print(f'\\n\\u2705 OBO delegation chain:')\n",
" print(f' xms_st.sub (original user): {xms_st.get(\"sub\", \"N/A\")}')\n",
" print(f' appid (acting app): {obo_claims.get(\"appid\", \"N/A\")}')\n",
" print(f' app_displayname: {obo_claims.get(\"app_displayname\", \"N/A\")}')"
]
},
{
"cell_type": "markdown",
"id": "cleanup-header",
"metadata": {},
"source": [
"---\n",
"## Cleanup (Optional)\n",
"\n",
"Uncomment and run to delete resources.\n",
"\n",
"**Also delete manually:**\n",
"- Microsoft Entra ID app registration from the Entra portal\n",
"- IAM role (`agentcore-obo-gateway-role`) from IAM Console"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cleanup",
"metadata": {},
"outputs": [],
"source": [
"# # Delete the Gateway target\n",
"# agentcore_control_client.delete_gateway_target(\n",
"# gatewayIdentifier=gateway_id,\n",
"# targetId=target_id\n",
"# )\n",
"# print(\"Gateway target deleted \\u2713\")\n",
"\n",
"# # Delete the Gateway\n",
"# agentcore_control_client.delete_gateway(gatewayIdentifier=gateway_id)\n",
"# print(\"Gateway deleted \\u2713\")\n",
"\n",
"# # Delete the credential provider\n",
"# identity_client.delete_oauth2_credential_provider(name=\"microsoft-obo-provider\")\n",
"# print(\"Credential provider deleted \\u2713\")\n",
"\n",
"# # Delete the IAM role\n",
"# iam_client.delete_role_policy(RoleName=gateway_role_name, PolicyName=\"GatewayOBOPolicy\")\n",
"# iam_client.delete_role(RoleName=gateway_role_name)\n",
"# print(\"IAM role deleted \\u2713\")"
]
},
{
"cell_type": "markdown",
"id": "congratulations",
"metadata": {},
"source": [
"## Congratulations! 🎉\n",
"\n",
"You built a Strands agent that accesses Microsoft Graph API through AgentCore Gateway with OBO token exchange — using **Entra ID directly** for end-to-end authentication. The agent code has **zero token handling logic** — the Gateway handles everything transparently.\n",
"\n",
"### What you accomplished:\n",
"- Registered an app in **Microsoft Entra ID** with OBO-compatible scopes and exposed an API\n",
"- Created a **Custom OAuth2 credential provider** with `JWT_AUTHORIZATION_GRANT` OBO config\n",
"- Set up an **AgentCore Gateway** with Entra ID v1.0 OIDC inbound auth and OpenAPI schema target\n",
"- Configured the target with `TOKEN_EXCHANGE` grant type and `requested_token_use: on_behalf_of` custom parameter\n",
"- Authenticated directly with **Entra ID** to get an app-scoped access token\n",
"- Connected a **Strands agent** to the Gateway MCP URL and discovered tools automatically\n",
"- Queried your **Microsoft profile** through the agent — all auth handled by the Gateway via OBO\n",
"\n",
"### Key learnings:\n",
"- The Gateway's **inbound auth discovery URL must match the token version** (v1.0 by default for Entra ID)\n",
"- Do **not** use `allowedClients` in the Gateway authorizer config for Entra ID v1.0 tokens\n",
"- The `customParameters` with `requested_token_use: on_behalf_of` is **required** for Entra ID's OBO endpoint\n",
"- The IAM role uses the same `bedrock-agentcore:*` pattern as other Gateway tutorials (via shared `utils.create_agentcore_gateway_role`)\n",
"- Personal accounts work for `/me` profile; calendar/email endpoints require a work/school account with Exchange Online\n",
"\n",
"### Next steps:\n",
"- Use a **work/school account** to test calendar and email tools\n",
"- Add more Microsoft Graph endpoints to the OpenAPI schema (e.g., OneDrive, Teams)\n",
"- Try different agent prompts: \"What meetings do I have this week?\", \"List my recent emails\"\n",
"- Explore the [OBO Token Exchange docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/on-behalf-of-token-exchange.html) and [Gateway Outbound Auth docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-outbound-auth.html)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.14.4"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,9 @@
strands-agents
strands-agents-tools
bedrock-agentcore
boto3
botocore
python-dotenv
requests
uvicorn
fastapi
@@ -0,0 +1,203 @@
"""
Environment-aware OAuth2 token callback server for Entra ID.
Captures the authorization code from Entra ID and exchanges it for tokens.
Automatically detects local vs SageMaker Workshop Studio environments.
Usage:
python3 token_callback_server.py <tenant-id> <client-id> <client-secret>
"""
import time
import json
import argparse
import logging
import socket
import uvicorn
import requests
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
PORT = 9090
CALLBACK_ENDPOINT = "/oauth2/callback"
PING_ENDPOINT = "/ping"
TOKEN_ENDPOINT = "/token"
logger = logging.getLogger(__name__)
# Global token storage
_captured_token = None
def _is_workshop_studio() -> bool:
try:
with open("/opt/ml/metadata/resource-metadata.json", "r") as f:
json.load(f)
return True
except (FileNotFoundError, json.JSONDecodeError):
return False
def get_callback_base_url() -> str:
"""Get the browser-accessible base URL (environment-aware)."""
if not _is_workshop_studio():
return f"http://localhost:{PORT}"
try:
import boto3
with open("/opt/ml/metadata/resource-metadata.json", "r") as f:
data = json.load(f)
client = boto3.client("sagemaker")
resp = client.describe_space(
DomainId=data["DomainId"], SpaceName=data["SpaceName"]
)
return resp["Url"] + f"/proxy/{PORT}"
except Exception:
return f"http://localhost:{PORT}"
def get_callback_url() -> str:
return f"{get_callback_base_url()}{CALLBACK_ENDPOINT}"
def is_server_running() -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(("127.0.0.1", PORT)) == 0
def get_captured_token() -> str:
"""Retrieve the captured token from a running server."""
try:
r = requests.get(f"http://localhost:{PORT}{TOKEN_ENDPOINT}", timeout=2)
if r.status_code == 200:
return r.json().get("access_token", "")
except Exception:
pass
return ""
def wait_for_token(timeout=120) -> str:
"""Poll the server until a token is captured."""
start = time.time()
while time.time() - start < timeout:
token = get_captured_token()
if token:
return token
time.sleep(2)
return ""
def wait_for_server_ready(timeout=30) -> bool:
start = time.time()
while time.time() - start < timeout:
try:
r = requests.get(f"http://localhost:{PORT}{PING_ENDPOINT}", timeout=2)
if r.status_code == 200:
return True
except Exception:
pass
time.sleep(1)
return False
class TokenCallbackServer:
def __init__(self, tenant_id: str, client_id: str, client_secret: str):
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self.app = FastAPI()
self._setup_routes()
def _setup_routes(self):
@self.app.get(PING_ENDPOINT)
async def ping():
return {"status": "ok"}
@self.app.get(TOKEN_ENDPOINT)
async def get_token():
return {"access_token": _captured_token or ""}
@self.app.get(CALLBACK_ENDPOINT)
async def callback(
code: str = None, error: str = None, error_description: str = None
):
global _captured_token
if error:
from html import escape
return HTMLResponse(
f"<h2>Error: {escape(error)}</h2><p>{escape(error_description or '')}</p>",
status_code=400,
)
if not code:
raise HTTPException(status_code=400, detail="Missing code parameter")
redirect_uri = get_callback_url()
token_url = (
f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
)
r = requests.post(
token_url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": f"api://{self.client_id}/access_as_user openid profile email",
},
)
tokens = r.json()
if "error" in tokens:
from html import escape
return HTMLResponse(
f"<h2>Token exchange error: {escape(tokens['error'])}</h2>"
f"<p>{escape(tokens.get('error_description', ''))}</p>",
status_code=400,
)
_captured_token = tokens.get("access_token", "")
print(f"\n{'=' * 60}")
print("TOKEN RECEIVED")
print(f"{'=' * 60}")
print(f"\nFULL ACCESS TOKEN:\n{_captured_token}")
print(f"\n{'=' * 60}")
return HTMLResponse(
"<h2>✅ Token captured! Return to the notebook.</h2>", status_code=200
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("tenant_id")
parser.add_argument("client_id")
parser.add_argument("client_secret")
args = parser.parse_args()
server = TokenCallbackServer(args.tenant_id, args.client_id, args.client_secret)
host = "0.0.0.0" if _is_workshop_studio() else "127.0.0.1"
callback_url = get_callback_url()
authorize_url = (
f"https://login.microsoftonline.com/{args.tenant_id}/oauth2/v2.0/authorize?"
f"client_id={args.client_id}&response_type=code&"
f"redirect_uri={__import__('urllib.parse', fromlist=['quote']).quote(callback_url)}&"
f"scope={__import__('urllib.parse', fromlist=['quote']).quote(f'api://{args.client_id}/access_as_user openid profile email')}"
)
print(f"\n🚀 Token callback server on {host}:{PORT}")
print(f"📋 Callback URL: {callback_url}")
print("\n📋 Open this URL in your browser to sign in:\n")
print(authorize_url)
print("\nWaiting for sign-in...\n")
uvicorn.run(server.app, host=host, port=PORT, log_level="warning")
if __name__ == "__main__":
main()