diff --git a/.github/skills/entra-agent-id/SKILL.md b/.github/skills/entra-agent-id/SKILL.md index ccf1c7a..5f10519 100644 --- a/.github/skills/entra-agent-id/SKILL.md +++ b/.github/skills/entra-agent-id/SKILL.md @@ -50,7 +50,7 @@ One of: **Agent Identity Developer**, **Agent Identity Administrator**, or **App ## Environment Variables ```bash -AZURE_TENANT_ID= +AZURE_TENANT_ID= AZURE_CLIENT_ID= AZURE_CLIENT_SECRET= ``` @@ -165,6 +165,331 @@ resp.raise_for_status() agent = resp.json() ``` +## Runtime Token Exchange + +Agent Identities authenticate at runtime using a **two-step token exchange** through +the Entra `/oauth2/v2.0/token` endpoint. This is a standard Entra feature — it works +anywhere (Azure, on-premises, local dev), not just inside Azure AI Foundry. + +### How It Works + +``` +Step 1: Blueprint credentials + fmi_path → Parent token (aud: api://AzureADTokenExchange) +Step 2: Parent token as client_assertion → Graph token (aud: https://graph.microsoft.com) +``` + +The `fmi_path` parameter targets a specific Agent Identity, so the resulting Graph token +has `sub` = that Agent Identity's appId — giving each agent instance a distinct audit trail. + +### Step 1: Get Parent Token + +```python +import json +import urllib.parse +import urllib.request + +TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + +def get_parent_token(tenant_id: str, blueprint_app_id: str, + blueprint_secret: str, agent_identity_id: str) -> str: + """Get a parent token scoped to a specific Agent Identity. + + Args: + tenant_id: The Agent Identity's home tenant (NOT the Blueprint's + home tenant if cross-tenant). + blueprint_app_id: The Blueprint's appId. + blueprint_secret: A client secret on the Blueprint. + agent_identity_id: The Agent Identity's appId. + """ + params = { + "grant_type": "client_credentials", + "client_id": blueprint_app_id, + "client_secret": blueprint_secret, + "scope": "api://AzureADTokenExchange/.default", + "fmi_path": agent_identity_id, + } + data = urllib.parse.urlencode(params).encode("utf-8") + req = urllib.request.Request( + TOKEN_URL.format(tenant=tenant_id), data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read())["access_token"] +``` + +The parent token has `aud: api://AzureADTokenExchange` and **cannot** call Graph +directly — it's an intermediate used as `client_assertion` in step 2. + +### Step 2a: Autonomous Exchange (App-Only Permissions) + +```python +def exchange_autonomous(tenant_id: str, agent_identity_id: str, + parent_token: str) -> dict: + """Exchange parent token for an app-only Graph token.""" + params = { + "grant_type": "client_credentials", + "client_id": agent_identity_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": parent_token, + "scope": "https://graph.microsoft.com/.default", + } + data = urllib.parse.urlencode(params).encode("utf-8") + req = urllib.request.Request( + TOKEN_URL.format(tenant=tenant_id), data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) +``` + +The resulting token contains `roles` with application permissions granted to the +Agent Identity SP (via `appRoleAssignments`). + +### Step 2b: OBO Exchange (Delegated Permissions) + +Exchanges the parent token **plus** a user token for a delegated Graph token. +The agent acts on behalf of the user, respecting per-agent delegated permission grants. + +```python +def exchange_obo(tenant_id: str, agent_identity_id: str, + parent_token: str, user_token: str) -> dict: + """Exchange parent + user token for delegated Graph token.""" + params = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "client_id": agent_identity_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": parent_token, + "assertion": user_token, + "requested_token_use": "on_behalf_of", + "scope": "https://graph.microsoft.com/.default", + } + data = urllib.parse.urlencode(params).encode("utf-8") + req = urllib.request.Request( + TOKEN_URL.format(tenant=tenant_id), data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) +``` + +The resulting token contains `scp` with delegated permissions consented for this +specific Agent Identity (via `oauth2PermissionGrants`). + +### Key Rules + +- **Both exchanges MUST use `/.default` scope** — individual scopes like `User.Read Mail.Send` will fail +- **`fmi_path` is NOT the same as RFC 8693 token exchange** — do not use `urn:ietf:params:oauth:grant-type:token-exchange` grant type (returns `AADSTS82001`) +- For option A (WIF/MI), step 1 uses the MI token as a federated credential; for option B (client secret), step 1 uses the secret directly. Both arrive at the same parent token + step 2 exchange pattern. + +## OBO: Blueprint API Configuration + +OBO mode requires the Blueprint application to be configured as an API that users +can acquire tokens for. Without this configuration, the user token can't correctly +target the Blueprint as its audience. + +### Configure the Blueprint + +```python +import uuid + +scope_id = str(uuid.uuid4()) +patch = { + "identifierUris": [f"api://{blueprint_app_id}"], + "api": { + "requestedAccessTokenVersion": 2, + "oauth2PermissionScopes": [{ + "id": scope_id, + "adminConsentDescription": "Allow the app to access the agent API on behalf of the user.", + "adminConsentDisplayName": "Access agent API", + "userConsentDescription": "Allow the app to access the agent API on your behalf.", + "userConsentDisplayName": "Access agent API", + "value": "access_as_user", + "type": "User", + "isEnabled": True, + }], + "preAuthorizedApplications": [{ + "appId": client_app_id, # Your front-end or CLI app's appId + "permissionIds": [scope_id], + }], + }, + "optionalClaims": { + "accessToken": [{ + "name": "idtyp", + "source": None, + "essential": False, + "additionalProperties": ["include_user_token"], + }] + }, +} +resp = requests.patch( + f"{GRAPH}/applications/{blueprint_obj_id}", + headers=headers, + json=patch, +) +resp.raise_for_status() +``` + +All four elements are required: +1. **`identifierUris`** — enables `api://{appId}` audience +2. **`oauth2PermissionScopes`** — defines the `access_as_user` scope +3. **`preAuthorizedApplications`** — authorizes your client app (skips consent prompt) +4. **`optionalClaims`** — emits `idtyp` claim for token validation + +### Acquire the User Token + +The user token must target the **Blueprint** as its audience, NOT Graph: + +```python +from azure.identity import InteractiveBrowserCredential + +credential = InteractiveBrowserCredential( + tenant_id=tenant_id, + client_id=client_app_id, # Your client app, NOT the Blueprint + redirect_uri="http://localhost:8400", +) +# Scope targets Blueprint, not Graph: +user_token = credential.get_token(f"api://{blueprint_app_id}/access_as_user") +``` + +If the user token targets `https://graph.microsoft.com` instead, step 2b fails +with `AADSTS50013: Assertion failed signature validation`. + +## Granting Permissions to Agent Identities + +Agent Identities support both application permissions (for autonomous mode) and +delegated permissions (for OBO mode). Permissions are granted per Agent Identity, +enabling fine-grained scoping. + +### Application Permissions (appRoleAssignments) + +For autonomous mode — grants the Agent Identity permission to call Graph as itself: + +```python +# Find the Microsoft Graph SP in your tenant +graph_sp = requests.get( + f"{GRAPH}/servicePrincipals?$filter=appId eq '00000003-0000-0000-c000-000000000000'", + headers=headers, +).json()["value"][0] +graph_sp_id = graph_sp["id"] + +# Find the appRole ID for User.Read.All +user_read_all_role = next( + r for r in graph_sp["appRoles"] + if r["value"] == "User.Read.All" +) + +# Grant to Agent Identity SP +resp = requests.post( + f"{GRAPH}/servicePrincipals/{agent_sp_id}/appRoleAssignments", + headers=headers, + json={ + "principalId": agent_sp_id, + "resourceId": graph_sp_id, + "appRoleId": user_read_all_role["id"], + }, +) +resp.raise_for_status() +``` + +### Delegated Permissions (oauth2PermissionGrants) + +For OBO mode — programmatic admin consent granting delegated scopes per Agent Identity: + +```python +from datetime import datetime, timedelta, timezone + +expiry = (datetime.now(timezone.utc) + timedelta(days=3650)).strftime( + "%Y-%m-%dT%H:%M:%SZ" +) + +resp = requests.post( + f"{GRAPH}/oauth2PermissionGrants", + headers=headers, + json={ + "clientId": agent_sp_id, # Agent Identity SP object ID + "consentType": "AllPrincipals", + "resourceId": graph_sp_id, # Microsoft Graph SP object ID + "scope": "User.Read Tasks.ReadWrite Mail.Send", # Space-separated + "expiryTime": expiry, # Required by beta API + }, +) +resp.raise_for_status() +``` + +### Per-Agent Scoping Example + +Different agents can have different permission boundaries: + +```python +AGENT_SCOPES = { + "it-helpdesk": "User.Read Tasks.ReadWrite", + "comms-agent": "User.Read Mail.Send Calendars.ReadWrite", + "hr-onboarding": "User.Read User.ReadBasic.All", +} + +for agent_name, scopes in AGENT_SCOPES.items(): + agent_sp_id = agent_identities[agent_name] + grant_delegated_permissions(agent_sp_id, scopes) +``` + +### Permission Notes + +- **`Group.ReadWrite.All` cannot be granted** as a delegated permission to agent identities +- `Tasks.ReadWrite` alone covers Planner task operations (no Group scope needed) +- `expiryTime` is **required** on the beta API for `oauth2PermissionGrants` (not required on v1.0) +- Browser-based admin consent URLs **do not work** for agent identities — use `oauth2PermissionGrants` API for programmatic consent +- Not all Graph scopes are allowed for agent identities — test each scope if you hit errors + +## Cross-Tenant Agent Identity + +Agent Identities can be created and used across tenants. The Blueprint must be +multi-tenant (`signInAudience: AzureADMultipleOrgs`). + +### Setup Requirements + +1. **Blueprint must be multi-tenant:** + ```python + requests.patch( + f"{GRAPH}/applications/{blueprint_obj_id}", + headers=headers, + json={"signInAudience": "AzureADMultipleOrgs"}, + ) + ``` + +2. **BlueprintPrincipal must exist in the target tenant** — provision via admin + consent or API in the foreign tenant. + +3. **Agent Identity is created in the target tenant** — the `POST /servicePrincipals` + call authenticates to the target tenant using the Blueprint's multi-tenant credentials. + +### Critical Tenant Targeting Rule + +When performing the two-step token exchange cross-tenant: + +- **Step 1 (parent token) MUST target the Agent Identity's home tenant**, not the Blueprint's home tenant +- If step 1 targets the Blueprint's home tenant, the parent token has the wrong issuer, and step 2 fails with `AADSTS700211: No matching federated identity record found` + +```python +# CORRECT: Step 1 targets Agent Identity's tenant +parent = get_parent_token( + tenant_id=AGENT_IDENTITY_TENANT, # Where the Agent Identity lives + blueprint_app_id=BLUEPRINT_APP_ID, + blueprint_secret=BLUEPRINT_SECRET, + agent_identity_id=AGENT_IDENTITY_APP_ID, +) + +# WRONG: Step 1 targets Blueprint's tenant — fails with AADSTS700211 +parent = get_parent_token( + tenant_id=BLUEPRINT_TENANT, # Wrong! Parent token issuer won't match + ... +) +``` + +This works because the Blueprint is multi-tenant, so its client_credentials +authenticate successfully against any tenant where its SP exists. The resulting +parent token has the correct issuer (`login.microsoftonline.com/{agent-tenant}/v2.0`) +to match the Agent Identity's federation configuration. + ## API Reference | Operation | Method | Endpoint | OData Type | @@ -175,6 +500,8 @@ agent = resp.json() | List Agent Identities | `GET` | `/servicePrincipals?$filter=...` | — | | Delete Agent Identity | `DELETE` | `/servicePrincipals/{id}` | — | | Delete Blueprint | `DELETE` | `/applications/{id}` | — | +| Grant App Permission | `POST` | `/servicePrincipals/{id}/appRoleAssignments` | — | +| Grant Delegated Permission | `POST` | `/oauth2PermissionGrants` | — | All endpoints use base URL: `https://graph.microsoft.com/beta` @@ -188,6 +515,8 @@ All endpoints use base URL: `https://graph.microsoft.com/beta` | `AgentIdentityBlueprintPrincipal.Create` | Create BlueprintPrincipals | | `AgentIdentity.Create.All` | Create Agent Identities | | `AgentIdentity.ReadWrite.All` | Read/update Agent Identities | +| `AppRoleAssignment.ReadWrite.All` | Grant application permissions to Agent Identities | +| `DelegatedPermissionGrant.ReadWrite.All` | Grant delegated permissions (oauth2PermissionGrants) | There are **18 Agent Identity-specific** Graph application permissions. Discover all: ```bash @@ -230,13 +559,32 @@ requests.delete(f"{GRAPH}/applications/{blueprint_obj_id}", headers=headers) 6. **Set `identifierUris` on Blueprint** before using OAuth2 scoping (`api://{app-id}`) 7. **Never use Azure CLI tokens** for API calls — they contain `Directory.AccessAsUser.All` which is hard-rejected 8. **Check for existing resources** before creating — implement idempotent provisioning +9. **Use `fmi_path` for runtime exchange** — do NOT use RFC 8693 `urn:ietf:params:oauth:grant-type:token-exchange` (returns `AADSTS82001`) +10. **Always use `/.default` scope in token exchanges** — individual scopes like `User.Read` will fail +11. **Target the Agent Identity's home tenant** in step 1 of cross-tenant exchange — the Blueprint's home tenant will produce a mismatched issuer +12. **Grant permissions per Agent Identity** — use `appRoleAssignments` for app permissions and `oauth2PermissionGrants` for delegated; do not grant to the BlueprintPrincipal +13. **Use `oauth2PermissionGrants` API for delegated consent** — browser-based admin consent URLs do not work for agent identities + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| `AADSTS82001` | Used RFC 8693 token-exchange grant type | Use `client_credentials` with `fmi_path` parameter | +| `AADSTS700211: No matching federated identity record` | Step 1 parent token targeted wrong tenant | Step 1 must target the Agent Identity's home tenant | +| `AADSTS50013: Assertion failed signature validation` | OBO user token targets Graph instead of Blueprint | Change user token scope to `api://{blueprint_app_id}/access_as_user` | +| `AADSTS65001: consent_required` | Missing oauth2PermissionGrants or used individual scopes | Use `/.default` scope and verify grants exist | +| `403 Authorization_RequestDenied` | Missing permission grant on Agent Identity | Add via `appRoleAssignments` or `oauth2PermissionGrants` | +| `PropertyNotCompatibleWithAgentIdentity` | Tried to add secret/cert to Agent Identity SP | Credentials belong on the Blueprint only | +| `Agent Blueprint Principal does not exist` | BlueprintPrincipal not created | POST to `/servicePrincipals` with `AgentIdentityBlueprintPrincipal` type | +| `AADSTS650051` on admin consent | SP already exists from partial consent | Grant permissions directly via `appRoleAssignments` API | ## References | File | Contents | |------|----------| -| [references/oauth2-token-flow.md](references/oauth2-token-flow.md) | Production (Managed Identity + WIF) and local dev (client secret) token flows | +| [references/oauth2-token-flow.md](references/oauth2-token-flow.md) | Production (MI + WIF), local dev (client secret), and runtime exchange token flows | | [references/known-limitations.md](references/known-limitations.md) | 29 known issues organized by category (from official preview known-issues page) | +| [references/acceptance-criteria.md](references/acceptance-criteria.md) | Correct/incorrect code patterns for validation | ### External Links diff --git a/.github/skills/entra-agent-id/references/acceptance-criteria.md b/.github/skills/entra-agent-id/references/acceptance-criteria.md index 25e1fcf..7d54594 100644 --- a/.github/skills/entra-agent-id/references/acceptance-criteria.md +++ b/.github/skills/entra-agent-id/references/acceptance-criteria.md @@ -2,7 +2,7 @@ **Skill**: `entra-agent-id` **Purpose**: Create and manage OAuth2-capable AI agent identities via Microsoft Graph beta API -**Focus**: Agent Identity Blueprints, BlueprintPrincipals, Agent Identities, authentication, permissions +**Focus**: Agent Identity Blueprints, BlueprintPrincipals, Agent Identities, authentication, permissions, runtime token exchange --- @@ -226,3 +226,198 @@ ensure_blueprint_principal(blueprint["appId"]) # WRONG — fails on rerun if blueprint already exists with same identifierUris blueprint = create_blueprint(...) # May conflict ``` + +--- + +## 9. Runtime Token Exchange (fmi_path) + +### 9.1 ✅ CORRECT: Two-step exchange with fmi_path and client_assertion + +```python +# Step 1: Parent token via fmi_path +params = { + "grant_type": "client_credentials", + "client_id": BLUEPRINT_APP_ID, + "client_secret": SECRET, + "scope": "api://AzureADTokenExchange/.default", + "fmi_path": AGENT_IDENTITY_APP_ID, +} +# POST to https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token + +# Step 2: Exchange parent for Graph token +params = { + "grant_type": "client_credentials", + "client_id": AGENT_IDENTITY_APP_ID, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": parent_token, + "scope": "https://graph.microsoft.com/.default", +} +``` + +### 9.2 ❌ INCORRECT: RFC 8693 token-exchange grant type + +```python +# WRONG — returns AADSTS82001; fmi_path is NOT RFC 8693 token exchange +params = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": some_token, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", +} +``` + +### 9.3 ❌ INCORRECT: Individual scopes instead of .default + +```python +# WRONG — individual scopes fail; must use /.default +params = { + "scope": "User.Read Mail.Send", # Fails +} +# CORRECT: +params = { + "scope": "https://graph.microsoft.com/.default", +} +``` + +### 9.4 ❌ INCORRECT: Using parent token directly as Graph token + +```python +# WRONG — parent token has aud: api://AzureADTokenExchange, not Graph +# It CANNOT call Graph directly; step 2 exchange is required +requests.get( + "https://graph.microsoft.com/v1.0/users", + headers={"Authorization": f"Bearer {parent_token}"}, +) # 401 +``` + +--- + +## 10. OBO (On-Behalf-Of) Exchange + +### 10.1 ✅ CORRECT: User token targets Blueprint audience + +```python +from azure.identity import InteractiveBrowserCredential + +credential = InteractiveBrowserCredential( + tenant_id=TENANT_ID, + client_id=CLIENT_APP_ID, + redirect_uri="http://localhost:8400", +) +# Scope targets the Blueprint: +user_token = credential.get_token(f"api://{BLUEPRINT_APP_ID}/access_as_user") +``` + +### 10.2 ❌ INCORRECT: User token targets Graph directly + +```python +# WRONG — OBO exchange fails with AADSTS50013: Assertion failed signature validation +user_token = credential.get_token("https://graph.microsoft.com/.default") +``` + +### 10.3 ✅ CORRECT: Blueprint API fully configured for OBO + +```python +patch = { + "identifierUris": [f"api://{blueprint_app_id}"], + "api": { + "requestedAccessTokenVersion": 2, + "oauth2PermissionScopes": [{ ... }], # access_as_user scope + "preAuthorizedApplications": [{ ... }], # client app authorized + }, + "optionalClaims": { + "accessToken": [{"name": "idtyp", ...}], # idtyp with include_user_token + }, +} +``` + +### 10.4 ❌ INCORRECT: Missing Blueprint API configuration + +```python +# WRONG — without identifierUris, oauth2PermissionScopes, preAuthorizedApplications, +# and optionalClaims, OBO exchange fails +# All four elements are required +``` + +### 10.5 ✅ CORRECT: Delegated permissions via oauth2PermissionGrants + +```python +requests.post( + f"{GRAPH}/oauth2PermissionGrants", + headers=headers, + json={ + "clientId": agent_sp_id, + "consentType": "AllPrincipals", + "resourceId": graph_sp_id, + "scope": "User.Read Tasks.ReadWrite", + "expiryTime": "2036-01-01T00:00:00Z", # Required by beta API + }, +) +``` + +### 10.6 ❌ INCORRECT: Browser-based admin consent for agent identities + +```python +# WRONG — browser admin consent URLs do not work for agent identities +# Use the oauth2PermissionGrants API for programmatic consent instead +webbrowser.open( + f"https://login.microsoftonline.com/{tenant}/adminconsent?client_id={agent_id}" +) +``` + +--- + +## 11. Cross-Tenant Exchange + +### 11.1 ✅ CORRECT: Step 1 targets Agent Identity's home tenant + +```python +# Blueprint in Tenant A, Agent Identity in Tenant B +parent = get_parent_token( + tenant_id=TENANT_B, # Agent Identity's home tenant + blueprint_app_id=BLUEPRINT_APP_ID, + blueprint_secret=SECRET, + agent_identity_id=AGENT_ID, +) +``` + +### 11.2 ❌ INCORRECT: Step 1 targets Blueprint's home tenant + +```python +# WRONG — parent token issuer won't match Agent Identity's federation config +# Fails with AADSTS700211: No matching federated identity record found +parent = get_parent_token( + tenant_id=TENANT_A, # Blueprint's tenant — WRONG for cross-tenant! + blueprint_app_id=BLUEPRINT_APP_ID, + blueprint_secret=SECRET, + agent_identity_id=AGENT_ID, +) +``` + +--- + +## 12. Permission Grants + +### 12.1 ✅ CORRECT: Grant app permissions to Agent Identity SP + +```python +requests.post( + f"{GRAPH}/servicePrincipals/{agent_sp_id}/appRoleAssignments", + headers=headers, + json={ + "principalId": agent_sp_id, + "resourceId": graph_sp_id, + "appRoleId": user_read_all_role_id, + }, +) +``` + +### 12.2 ❌ INCORRECT: Grant app permissions to BlueprintPrincipal + +```python +# WRONG — grant to individual agent identities, not the blueprint principal +# Known limitation #21: Cannot grant app permissions to blueprint principals +requests.post( + f"{GRAPH}/servicePrincipals/{blueprint_sp_id}/appRoleAssignments", + ... +) +``` diff --git a/.github/skills/entra-agent-id/references/oauth2-token-flow.md b/.github/skills/entra-agent-id/references/oauth2-token-flow.md index 9532ecc..1b927ca 100644 --- a/.github/skills/entra-agent-id/references/oauth2-token-flow.md +++ b/.github/skills/entra-agent-id/references/oauth2-token-flow.md @@ -2,12 +2,17 @@ Source: [Agent ID Setup Instructions](https://learn.microsoft.com/en-us/entra/agent-id/identity-platform/agent-id-setup-instructions) -Agent Identities authenticate at runtime using credentials configured on the **Blueprint** (not the Agent Identity itself). Two options are available: +Agent Identities authenticate at runtime using credentials configured on the +**Blueprint** (not the Agent Identity itself). Three options are available: | Option | Use case | Credential type | |--------|----------|-----------------| | **Managed Identity + WIF** | Production (Azure-hosted) | Federated Identity Credential | | **Client secret** | Local development / testing | Password credential on Blueprint | +| **OBO (On-Behalf-Of)** | Delegated user access | Client secret or WIF + user token | + +All three options produce a **parent token** in step 1 and then exchange it for a +**Graph-scoped Agent Identity token** in step 2 via the `fmi_path` exchange pattern. --- @@ -92,9 +97,10 @@ claims = jwt.decode( --- -## Option B: Client Secret (Local Development / Testing Only) +## Option B: Client Secret (Local Development / Testing) -For local development where no Managed Identity is available. +For local development where no Managed Identity is available. This option uses +a client secret on the Blueprint to complete the full two-step `fmi_path` exchange. ### 1. Add a Password Credential to the Blueprint @@ -133,17 +139,82 @@ resp = requests.post( secret_text = resp.json()["secretText"] # Save NOW ``` -### 2. Acquire Token Locally +### 2. Get Parent Token (Step 1 of Exchange) + +The parent token uses `client_credentials` with the `fmi_path` parameter to target +a specific Agent Identity: ```python -from azure.identity import ClientSecretCredential +import json +import urllib.parse +import urllib.request + +TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + +params = { + "grant_type": "client_credentials", + "client_id": BLUEPRINT_APP_ID, + "client_secret": SECRET_TEXT, + "scope": "api://AzureADTokenExchange/.default", + "fmi_path": AGENT_IDENTITY_APP_ID, # Target this specific Agent Identity +} +data = urllib.parse.urlencode(params).encode("utf-8") +req = urllib.request.Request( + TOKEN_URL.format(tenant=TENANT_ID), data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, +) +with urllib.request.urlopen(req) as resp: + parent_token = json.loads(resp.read())["access_token"] +``` -credential = ClientSecretCredential( - tenant_id=TENANT_ID, - client_id=BLUEPRINT_APP_ID, # Blueprint's appId - client_secret=SECRET_TEXT, # From step 1 +The parent token has these claims: + +| Claim | Value | +|-------|-------| +| `aud` | `api://AzureADTokenExchange` | +| `iss` | `https://login.microsoftonline.com/{tenant}/v2.0` | +| `sub` | Blueprint's SP object ID | +| `appid` | Blueprint's appId | +| `idtyp` | `app` | + +This token **cannot** call Graph directly — it's an intermediate token. + +### 3. Exchange for Graph Token (Step 2 of Exchange) + +Use the parent token as a `client_assertion` to get a Graph-scoped token: + +```python +params = { + "grant_type": "client_credentials", + "client_id": AGENT_IDENTITY_APP_ID, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": parent_token, + "scope": "https://graph.microsoft.com/.default", +} +data = urllib.parse.urlencode(params).encode("utf-8") +req = urllib.request.Request( + TOKEN_URL.format(tenant=TENANT_ID), data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, +) +with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read()) + +graph_token = result["access_token"] +# This token has sub = Agent Identity's appId +# roles = application permissions granted via appRoleAssignments +``` + +### 4. Use the Graph Token + +```python +import requests + +resp = requests.get( + "https://graph.microsoft.com/v1.0/users?$top=5&$select=displayName,mail", + headers={"Authorization": f"Bearer {graph_token}"}, ) -token = credential.get_token(f"api://{BLUEPRINT_APP_ID}/.default") +for user in resp.json()["value"]: + print(f"{user['displayName']} — {user.get('mail', 'N/A')}") ``` ### Key Rules (Client Secret) @@ -152,4 +223,186 @@ token = credential.get_token(f"api://{BLUEPRINT_APP_ID}/.default") - **Secrets belong on the Blueprint only** — agent identities cannot have password credentials (`PropertyNotCompatibleWithAgentIdentity`). - **NOT for production** — use Managed Identity + WIF in production. - **Respect org policy** — if `endDateTime` exceeds your tenant's credential lifetime policy, reduce it. -- **Use `ClientSecretCredential`**, not `DefaultAzureCredential`. Azure CLI tokens contain `Directory.AccessAsUser.All` which is rejected by Agent ID APIs. +- **Use `fmi_path` parameter** — do NOT use RFC 8693 `urn:ietf:params:oauth:grant-type:token-exchange` (returns `AADSTS82001`). +- **Always use `/.default` scope** in both steps — individual scopes will fail. + +--- + +## Option C: OBO (On-Behalf-Of) — Delegated User Access + +For agents that need to act **on behalf of a user** with delegated permissions. +Combines the parent token exchange with a user assertion to produce a delegated +Graph token scoped to what that specific Agent Identity is allowed to do. + +### Prerequisites + +The Blueprint must be configured as an API (see "Blueprint API Configuration" below). +The Agent Identity must have `oauth2PermissionGrants` for the desired delegated scopes. + +### 1. Get Parent Token (Same as Option B, Step 2) + +```python +parent_token = get_parent_token(TENANT_ID, BLUEPRINT_APP_ID, + SECRET_TEXT, AGENT_IDENTITY_APP_ID) +``` + +### 2. Get User Token (Targets Blueprint, NOT Graph) + +```python +from azure.identity import InteractiveBrowserCredential + +credential = InteractiveBrowserCredential( + tenant_id=TENANT_ID, + client_id=CLIENT_APP_ID, # Your front-end or CLI app, NOT the Blueprint + redirect_uri="http://localhost:8400", +) +# Scope MUST target the Blueprint as audience: +user_token = credential.get_token(f"api://{BLUEPRINT_APP_ID}/access_as_user") +``` + +> **Critical**: The user token audience must be `api://{blueprint_app_id}`, NOT +> `https://graph.microsoft.com`. If it targets Graph, the OBO exchange fails with +> `AADSTS50013: Assertion failed signature validation`. + +### 3. OBO Exchange (Combines Parent + User Tokens) + +```python +params = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "client_id": AGENT_IDENTITY_APP_ID, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": parent_token, + "assertion": user_token.token, + "requested_token_use": "on_behalf_of", + "scope": "https://graph.microsoft.com/.default", +} +data = urllib.parse.urlencode(params).encode("utf-8") +req = urllib.request.Request( + TOKEN_URL.format(tenant=TENANT_ID), data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, +) +with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read()) + +obo_token = result["access_token"] +# This token has: +# sub = Agent Identity's appId +# scp = delegated permissions from oauth2PermissionGrants +``` + +### Blueprint API Configuration + +The Blueprint must be configured as an OAuth2 API for the user token to +correctly target it. Apply this configuration once via `PATCH /applications/{id}`: + +```python +import uuid + +scope_id = str(uuid.uuid4()) +patch = { + "identifierUris": [f"api://{BLUEPRINT_APP_ID}"], + "api": { + "requestedAccessTokenVersion": 2, + "oauth2PermissionScopes": [{ + "id": scope_id, + "adminConsentDescription": "Allow the app to access the agent API on behalf of the user.", + "adminConsentDisplayName": "Access agent API", + "userConsentDescription": "Allow the app to access the agent API on your behalf.", + "userConsentDisplayName": "Access agent API", + "value": "access_as_user", + "type": "User", + "isEnabled": True, + }], + "preAuthorizedApplications": [{ + "appId": CLIENT_APP_ID, + "permissionIds": [scope_id], + }], + }, + "optionalClaims": { + "accessToken": [{ + "name": "idtyp", + "source": None, + "essential": False, + "additionalProperties": ["include_user_token"], + }] + }, +} +requests.patch( + f"{GRAPH}/applications/{blueprint_obj_id}", + headers=headers, + json=patch, +) +``` + +All four elements are required: +1. **`identifierUris`** — enables `api://{appId}` audience +2. **`oauth2PermissionScopes`** — defines a scope users can consent to +3. **`preAuthorizedApplications`** — authorizes your client app (skips consent prompt) +4. **`optionalClaims`** — emits `idtyp` claim for token type validation + +### Delegated Permission Grants + +Each Agent Identity needs `oauth2PermissionGrants` specifying which Graph delegated +permissions it may exercise on behalf of users: + +```python +from datetime import datetime, timedelta, timezone + +expiry = (datetime.now(timezone.utc) + timedelta(days=3650)).strftime( + "%Y-%m-%dT%H:%M:%SZ" +) + +requests.post( + f"{GRAPH}/oauth2PermissionGrants", + headers=headers, + json={ + "clientId": agent_sp_id, # Agent Identity SP object ID + "consentType": "AllPrincipals", + "resourceId": graph_sp_id, # Microsoft Graph SP object ID + "scope": "User.Read Tasks.ReadWrite", # Space-separated scopes + "expiryTime": expiry, # Required by beta API + }, +) +``` + +### Key Rules (OBO) + +- **User token MUST target the Blueprint** (`api://{blueprint_app_id}/access_as_user`), NOT Graph +- **Use `/.default` scope** in the OBO exchange step — individual scopes fail +- **`expiryTime` is required** on beta API `oauth2PermissionGrants` (not required on v1.0) +- **Browser-based admin consent URLs do not work** for agent identities — use `oauth2PermissionGrants` API +- **`Group.ReadWrite.All`** cannot be granted as delegated permission to agent identities + +--- + +## Cross-Tenant Token Exchange + +The `fmi_path` exchange works cross-tenant when the Blueprint is multi-tenant +(`signInAudience: AzureADMultipleOrgs`). The critical rule is: + +> **Step 1 (parent token) MUST target the Agent Identity's home tenant.** + +```python +# Blueprint in Tenant A, Agent Identity in Tenant B + +# CORRECT: Step 1 targets Agent Identity's tenant (Tenant B) +parent = get_parent_token( + tenant_id=TENANT_B, + blueprint_app_id=BLUEPRINT_APP_ID, + blueprint_secret=SECRET, + agent_identity_id=AGENT_ID, +) + +# WRONG: Step 1 targets Blueprint's tenant (Tenant A) — AADSTS700211 +parent = get_parent_token( + tenant_id=TENANT_A, # Wrong tenant! + ... +) +``` + +This works because the Blueprint's multi-tenant SP authenticates to Tenant B, +producing a parent token with issuer `login.microsoftonline.com/{Tenant_B}/v2.0`, +which matches the Agent Identity's federation configuration. If step 1 targets +Tenant A instead, the issuer is wrong and step 2 fails. + +Step 2 also targets the Agent Identity's tenant (Tenant B), using the correctly-issued parent token.