From 72a571070938fe8d9eb944c567f038620b4d682b Mon Sep 17 00:00:00 2001 From: Arturo Lucatero <13911090+ArLucaID@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:46:17 -0700 Subject: [PATCH 1/3] feat: Add runtime token exchange, OBO, cross-tenant, and permission granting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 6 gaps in the entra-agent-id skill: 1. Runtime token exchange — Added fmi_path two-step exchange pattern (parent token via client_credentials + fmi_path, then client_assertion exchange for Graph-scoped Agent Identity token). Covers both autonomous (app-only) and OBO (delegated) modes with complete code samples. 2. fmi_path parameter — Documented the fmi_path parameter on the standard /oauth2/v2.0/token endpoint that targets a specific Agent Identity. Clarified this is NOT RFC 8693 token exchange (which returns AADSTS82001). 3. OBO/delegated flow — Added Blueprint API configuration (identifierUris, oauth2PermissionScopes, preAuthorizedApplications, optionalClaims), user token audience requirements, and complete OBO exchange code. 4. Cross-tenant guidance — Documented that fmi_path works cross-tenant when the Blueprint is multi-tenant. Critical rule: step 1 must target the Agent Identity's home tenant, not the Blueprint's (AADSTS700211 otherwise). 5. Permission granting — Added appRoleAssignments for autonomous mode and oauth2PermissionGrants for OBO mode, with per-agent scoping examples. 6. oauth2-token-flow.md completion — Option B now shows the full two-step exchange (was incomplete — stopped at Blueprint token). Added Option C for OBO flow. Added cross-tenant exchange section. Updated acceptance-criteria.md with sections 9-12 covering runtime exchange, OBO, cross-tenant, and permission grant patterns (correct/incorrect examples). Added Troubleshooting table with 8 common errors and fixes. Tested patterns verified against live Entra endpoints (same-tenant autonomous, cross-tenant fmi_path exchange, OBO with delegated permissions). --- .github/skills/entra-agent-id/SKILL.md | 352 +++++++++++++++++- .../references/acceptance-criteria.md | 197 +++++++++- .../references/oauth2-token-flow.md | 275 +++++++++++++- 3 files changed, 810 insertions(+), 14 deletions(-) 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. From b68f0ce43f170e0c2ae704d73a68d4572df73310 Mon Sep 17 00:00:00 2001 From: Arturo Lucatero <13911090+ArLucaID@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:54:24 -0700 Subject: [PATCH 2/3] Add credential-free-dev skill Core skill for eliminating secrets from Azure apps using managed identities, workload identity federation, and Azure Identity SDK. - SKILL.md (155 lines) with principles, credential type table, RBAC roles, WIF patterns - references/migration-patterns.md: before/after code for 7 services, 4 languages - references/acceptance-criteria.md: correct/incorrect patterns - 6 test scenarios, 100% pass rate - Symlinks in python/dotnet/typescript/java/rust entra categories --- .github/skills/credential-free-dev/SKILL.md | 155 +++++++++ .../references/acceptance-criteria.md | 176 ++++++++++ .../references/migration-patterns.md | 301 ++++++++++++++++++ README.md | 13 +- skills/dotnet/entra/credential-free-dev | 1 + skills/java/entra/credential-free-dev | 1 + skills/python/entra/credential-free-dev | 1 + skills/rust/entra/credential-free-dev | 1 + skills/typescript/entra/credential-free-dev | 1 + .../credential-free-dev/scenarios.yaml | 215 +++++++++++++ 10 files changed, 859 insertions(+), 6 deletions(-) create mode 100644 .github/skills/credential-free-dev/SKILL.md create mode 100644 .github/skills/credential-free-dev/references/acceptance-criteria.md create mode 100644 .github/skills/credential-free-dev/references/migration-patterns.md create mode 100644 skills/dotnet/entra/credential-free-dev create mode 100644 skills/java/entra/credential-free-dev create mode 100644 skills/python/entra/credential-free-dev create mode 100644 skills/rust/entra/credential-free-dev create mode 100644 skills/typescript/entra/credential-free-dev create mode 100644 tests/scenarios/credential-free-dev/scenarios.yaml diff --git a/.github/skills/credential-free-dev/SKILL.md b/.github/skills/credential-free-dev/SKILL.md new file mode 100644 index 0000000..3b2155a --- /dev/null +++ b/.github/skills/credential-free-dev/SKILL.md @@ -0,0 +1,155 @@ +--- +name: credential-free-dev +description: | + Eliminate secrets and credentials from Azure applications using managed identities, + workload identity federation, and the Azure Identity SDK. Covers migration patterns + from connection strings and keys to credential-free authentication across Azure services. + Triggers: "credential-free", "managed identity", "workload identity federation", + "remove secrets", "connection string to managed identity", "DefaultAzureCredential", + "passwordless", "keyless authentication", "eliminate keys". +--- + +# Azure Credential-Free Development + +Eliminate secrets and credentials from Azure applications using managed identities, workload identity federation, and the Azure Identity SDK. + +## Before Implementation + +Search `microsoft-docs` MCP for current patterns: +- Query: "credential-free development Azure managed identity" +- Query: "DefaultAzureCredential [target service] [language]" +- Verify: RBAC role names match current documentation + +## Core Principles + +1. **No secrets in code, config, or environment variables.** Use managed identities for Azure-hosted workloads. Use workload identity federation for non-Azure or CI/CD workloads. +2. **DefaultAzureCredential is the standard entry point.** Unified credential chain that works locally (developer credentials) and in production (managed identity) without code changes. +3. **System-assigned managed identity** for single-purpose resources. **User-assigned managed identity** when multiple resources share a credential or when you need pre-provisioned RBAC. +4. **Connection strings with keys are legacy.** Every Azure service that supports Entra auth should use it. If a service requires a key, treat it as a gap to escalate, not a pattern to accept. +5. **Least privilege always.** Grant the narrowest RBAC role that works. `Storage Blob Data Reader`, not `Storage Account Key Operator`. `db_datareader`, not `db_owner`. + +## When to Use Each Credential Type + +| Scenario | Use This | Why | +|---|---|---| +| App on Azure (App Service, Container Apps, Functions, VMs, AKS) | **Managed Identity** (system or user-assigned) | Zero secrets, platform-managed rotation, Entra RBAC | +| CI/CD pipeline (GitHub Actions, Azure DevOps) | **Workload Identity Federation** | No secrets stored. OIDC token exchange with Entra. | +| App on AWS/GCP calling Azure | **Workload Identity Federation** | Cross-cloud trust without shared secrets | +| Local development | **Azure CLI / VS credentials** (via DefaultAzureCredential) | Developer's own identity, no shared dev secrets | +| Service-to-service (no Azure hosting) | **App registration + certificate** | When MI/WIF aren't possible. Certificates over secrets. | +| Legacy app that can't change auth code | **Key Vault references** (stepping stone) | Secrets in Key Vault, not in config. Not the end state. | + +## DefaultAzureCredential + +### Credential Chain (in order) + +1. Environment variables (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`) +2. Workload Identity (Kubernetes, federated token) +3. Managed Identity (system-assigned, then user-assigned) +4. Azure CLI (`az login`) +5. Azure PowerShell (`Connect-AzAccount`) +6. Azure Developer CLI (`azd auth login`) +7. Interactive browser (if enabled) + +In production on Azure, step 3 (Managed Identity) fires. Steps 4-7 are for development. + +### SDK Packages + +| Language | Package | Install | +|----------|---------|---------| +| Python | `azure-identity` | `pip install azure-identity` | +| .NET | `Azure.Identity` | `dotnet add package Azure.Identity` | +| Java | `azure-identity` | Maven: `com.azure:azure-identity` | +| TypeScript | `@azure/identity` | `npm install @azure/identity` | +| Go | `azidentity` | `go get github.com/Azure/azure-sdk-for-go/sdk/azidentity` | + +### Authentication Pattern (All Languages) + +```python +# Python +from azure.identity import DefaultAzureCredential +credential = DefaultAzureCredential() +client = ServiceClient(endpoint, credential=credential) +``` + +```csharp +// C# +var credential = new DefaultAzureCredential(); +var client = new ServiceClient(new Uri(endpoint), credential); +``` + +```java +// Java +TokenCredential credential = new DefaultAzureCredentialBuilder().build(); +ServiceClient client = new ServiceClientBuilder() + .endpoint(endpoint) + .credential(credential) + .buildClient(); +``` + +```typescript +// TypeScript +import { DefaultAzureCredential } from "@azure/identity"; +const credential = new DefaultAzureCredential(); +const client = new ServiceClient(endpoint, credential); +``` + +## Migration Pattern: Keys to Credential-Free + +Every migration follows the same 4 steps: + +1. **Enable managed identity** on your compute resource +2. **Assign the right RBAC role** on the target resource +3. **Replace** connection string / key with endpoint URL + `DefaultAzureCredential` +4. **Remove** the key/secret from all config + +### Quick Reference: Service RBAC Roles + +| Service | Read Role | Write Role | +|---------|-----------|------------| +| Azure Storage (Blob) | `Storage Blob Data Reader` | `Storage Blob Data Contributor` | +| Azure SQL Database | `db_datareader` (SQL role) | `db_datawriter` (SQL role) | +| Azure Cosmos DB | `Cosmos DB Built-in Data Reader` | `Cosmos DB Built-in Data Contributor` | +| Azure Service Bus | `Azure Service Bus Data Receiver` | `Azure Service Bus Data Sender` | +| Azure Event Hubs | `Azure Event Hubs Data Receiver` | `Azure Event Hubs Data Sender` | +| Azure Key Vault | `Key Vault Secrets User` | `Key Vault Secrets Officer` | +| Azure App Configuration | `App Configuration Data Reader` | `App Configuration Data Owner` | + +> **Detailed migration code for each service:** See [references/migration-patterns.md](references/migration-patterns.md) + +## Workload Identity Federation (CI/CD and Cross-Cloud) + +When managed identity isn't available (GitHub Actions, external clouds, on-prem): + +1. Create an app registration in Entra +2. Add a federated identity credential (OIDC issuer + subject) +3. External workload exchanges its native token for an Entra access token +4. No secrets stored, rotated, or transmitted + +### GitHub Actions Example + +```yaml +- uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} +``` + +No `AZURE_CLIENT_SECRET` needed. The GitHub OIDC token is exchanged directly. + +## Common Pitfalls + +1. **Forgetting RBAC roles.** Enabling MI is step 1. Assigning the right role on the target resource is step 2. Most "MI doesn't work" issues are missing role assignments. +2. **Overly broad roles.** `Contributor` on a resource group when you need `Storage Blob Data Reader` on one account. +3. **Not testing locally.** `DefaultAzureCredential` falls through to Azure CLI. Make sure `az login` is done with the right subscription. +4. **Mixing key and MI auth.** Some SDKs behave differently when both a connection string and a credential are provided. Pick one. +5. **Assuming all services support MI.** Most do. Some legacy or partner services don't yet. Check the [services that support managed identities](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/managed-identities-status) list. +6. **Not handling token refresh.** `DefaultAzureCredential` handles refresh automatically. If caching tokens manually, respect expiry. +7. **System-assigned when user-assigned is better.** Multiple resources needing same permissions = user-assigned MI to avoid duplicating RBAC assignments. + +## Reference Files + +| File | Contents | +|------|----------| +| [references/migration-patterns.md](references/migration-patterns.md) | Detailed before/after code for SQL, Storage, Cosmos DB, Service Bus, Event Hubs, Key Vault | diff --git a/.github/skills/credential-free-dev/references/acceptance-criteria.md b/.github/skills/credential-free-dev/references/acceptance-criteria.md new file mode 100644 index 0000000..4a148eb --- /dev/null +++ b/.github/skills/credential-free-dev/references/acceptance-criteria.md @@ -0,0 +1,176 @@ +# Acceptance Criteria: credential-free-dev + +**Packages**: `azure-identity` (Python), `Azure.Identity` (.NET), `@azure/identity` (TypeScript), `com.azure:azure-identity` (Java) +**Purpose**: Skill testing acceptance criteria for credential-free development patterns + +--- + +## 1. Authentication Patterns + +### 1.1 DefaultAzureCredential Usage + +#### ✅ CORRECT: DefaultAzureCredential with endpoint URL +```python +from azure.identity import DefaultAzureCredential +from azure.storage.blob import BlobServiceClient + +credential = DefaultAzureCredential() +client = BlobServiceClient( + account_url="https://mystorageaccount.blob.core.windows.net", + credential=credential +) +``` + +#### ❌ INCORRECT: Hardcoded key in code +```python +from azure.storage.blob import BlobServiceClient +client = BlobServiceClient.from_connection_string( + "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=abc123..." +) +``` + +#### ❌ INCORRECT: Hardcoded client secret +```python +from azure.identity import ClientSecretCredential +credential = ClientSecretCredential( + tenant_id="...", + client_id="...", + client_secret="hardcoded-secret-value" # Never hardcode +) +``` + +### 1.2 Environment Variables for Credentials + +#### ✅ CORRECT: Read from environment +```python +import os +from azure.identity import ClientSecretCredential + +credential = ClientSecretCredential( + tenant_id=os.environ["AZURE_TENANT_ID"], + client_id=os.environ["AZURE_CLIENT_ID"], + client_secret=os.environ["AZURE_CLIENT_SECRET"], +) +``` + +#### ❌ INCORRECT: Inline secrets +```python +credential = ClientSecretCredential( + tenant_id="12345-abcde", + client_id="67890-fghij", + client_secret="super-secret-value", +) +``` + +--- + +## 2. Service Client Initialization + +### 2.1 Storage Blob + +#### ✅ CORRECT: Endpoint URL + credential +```python +client = BlobServiceClient( + account_url="https://myaccount.blob.core.windows.net", + credential=DefaultAzureCredential() +) +``` + +#### ❌ INCORRECT: Connection string with key +```python +client = BlobServiceClient.from_connection_string("...AccountKey=...") +``` + +### 2.2 Service Bus + +#### ✅ CORRECT: Namespace + credential +```python +client = ServiceBusClient( + fully_qualified_namespace="my-namespace.servicebus.windows.net", + credential=DefaultAzureCredential() +) +``` + +#### ❌ INCORRECT: Connection string with SAS +```python +client = ServiceBusClient.from_connection_string("...SharedAccessKey=...") +``` + +### 2.3 Cosmos DB + +#### ✅ CORRECT: URL + credential +```python +client = CosmosClient( + "https://myaccount.documents.azure.com:443/", + credential=DefaultAzureCredential() +) +``` + +#### ❌ INCORRECT: URL + primary key +```python +client = CosmosClient("https://myaccount.documents.azure.com:443/", "primary-key-here") +``` + +--- + +## 3. RBAC Role Guidance + +### 3.1 Least Privilege + +#### ✅ CORRECT: Narrow role scoped to resource +``` +Assign "Storage Blob Data Reader" on the specific storage account +``` + +#### ❌ INCORRECT: Overly broad role +``` +Assign "Contributor" on the resource group +``` + +#### ❌ INCORRECT: Key operator instead of data role +``` +Assign "Storage Account Key Operator Service Role" — this grants key access, not data access +``` + +--- + +## 4. Managed Identity Selection + +### 4.1 System vs User-Assigned + +#### ✅ CORRECT: System-assigned for single-purpose +``` +Single App Service accessing one storage account → system-assigned MI +``` + +#### ✅ CORRECT: User-assigned for shared permissions +``` +Three Container Apps needing same Cosmos DB access → one user-assigned MI, one RBAC assignment +``` + +#### ❌ INCORRECT: System-assigned with duplicated RBAC +``` +Three Container Apps each with system-assigned MI, each needing identical RBAC → 3x role assignments to maintain +``` + +--- + +## 5. Workload Identity Federation + +### 5.1 GitHub Actions + +#### ✅ CORRECT: OIDC login without secret +```yaml +- uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} +``` + +#### ❌ INCORRECT: Client secret in GitHub Actions +```yaml +- uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} # Contains client_secret — use WIF instead +``` diff --git a/.github/skills/credential-free-dev/references/migration-patterns.md b/.github/skills/credential-free-dev/references/migration-patterns.md new file mode 100644 index 0000000..1026f59 --- /dev/null +++ b/.github/skills/credential-free-dev/references/migration-patterns.md @@ -0,0 +1,301 @@ +# Migration Patterns: Keys to Credential-Free + +Detailed before/after code for migrating Azure services from connection strings and keys to credential-free authentication. + +## Azure SQL Database + +```python +# BEFORE (connection string with password) +conn_str = "Server=myserver.database.windows.net;Database=mydb;User Id=myuser;Password=mypassword;" + +# AFTER (managed identity with Azure Identity SDK) +from azure.identity import DefaultAzureCredential +import pyodbc + +credential = DefaultAzureCredential() +token = credential.get_token("https://database.windows.net/.default") +conn = pyodbc.connect( + "Driver={ODBC Driver 18 for SQL Server};" + "Server=myserver.database.windows.net;" + "Database=mydb;", + attrs_before={1256: token.token.encode("UTF-16-LE")} +) +``` + +### Steps + +1. Enable system-assigned MI on your App Service / Container App +2. Create a contained database user mapped to the MI: + ```sql + CREATE USER [my-app-name] FROM EXTERNAL PROVIDER; + ``` +3. Grant appropriate roles: + ```sql + ALTER ROLE db_datareader ADD MEMBER [my-app-name]; + ``` +4. Remove the password from your connection string +5. Use `DefaultAzureCredential` in code to acquire tokens + +--- + +## Azure Storage (Blob) + +```python +# BEFORE (account key) +from azure.storage.blob import BlobServiceClient +client = BlobServiceClient.from_connection_string( + "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=..." +) + +# AFTER (managed identity) +from azure.identity import DefaultAzureCredential +from azure.storage.blob import BlobServiceClient +client = BlobServiceClient( + account_url="https://mystorageaccount.blob.core.windows.net", + credential=DefaultAzureCredential() +) +``` + +### Steps + +1. Enable MI on your compute resource +2. Assign `Storage Blob Data Reader` (or `Contributor`/`Owner`) role to the MI on the storage account +3. Replace connection string with account URL + `DefaultAzureCredential` +4. Remove the storage account key from all config + +--- + +## Azure Cosmos DB + +```python +# BEFORE (primary key) +from azure.cosmos import CosmosClient +client = CosmosClient( + "https://myaccount.documents.azure.com:443/", + "primary-key-here" +) + +# AFTER (managed identity via Entra RBAC) +from azure.identity import DefaultAzureCredential +from azure.cosmos import CosmosClient +client = CosmosClient( + "https://myaccount.documents.azure.com:443/", + credential=DefaultAzureCredential() +) +``` + +### Steps + +1. Enable Entra auth on Cosmos DB account (may need to disable key-based auth) +2. Assign `Cosmos DB Built-in Data Reader` (or `Contributor`) role +3. Replace key with `DefaultAzureCredential` + +--- + +## Azure Service Bus + +```python +# BEFORE (connection string with SAS key) +from azure.servicebus import ServiceBusClient +client = ServiceBusClient.from_connection_string( + "Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..." +) + +# AFTER (managed identity) +from azure.identity import DefaultAzureCredential +from azure.servicebus import ServiceBusClient +client = ServiceBusClient( + fully_qualified_namespace="my-namespace.servicebus.windows.net", + credential=DefaultAzureCredential() +) +``` + +### Steps + +1. Enable MI on your compute resource +2. Assign `Azure Service Bus Data Sender` and/or `Azure Service Bus Data Receiver` role +3. Replace connection string with namespace + `DefaultAzureCredential` + +--- + +## Azure Event Hubs + +```python +# BEFORE (connection string with SAS key) +from azure.eventhub import EventHubProducerClient +client = EventHubProducerClient.from_connection_string( + "Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...", + eventhub_name="my-hub" +) + +# AFTER (managed identity) +from azure.identity import DefaultAzureCredential +from azure.eventhub import EventHubProducerClient +client = EventHubProducerClient( + fully_qualified_namespace="my-namespace.servicebus.windows.net", + eventhub_name="my-hub", + credential=DefaultAzureCredential() +) +``` + +### Steps + +1. Enable MI on your compute resource +2. Assign `Azure Event Hubs Data Sender` and/or `Azure Event Hubs Data Receiver` role +3. Replace connection string with namespace + `DefaultAzureCredential` + +--- + +## Azure Key Vault + +```python +# Key Vault already requires Entra auth — but ensure you're using MI, not client secret + +# CORRECT +from azure.identity import DefaultAzureCredential +from azure.keyvault.secrets import SecretClient +client = SecretClient( + vault_url="https://my-vault.vault.azure.net", + credential=DefaultAzureCredential() +) +``` + +### Steps + +1. Enable MI on your compute resource +2. Assign `Key Vault Secrets User` (for reading secrets) or `Key Vault Secrets Officer` (for read/write) +3. Use vault URL + `DefaultAzureCredential` + +--- + +## Azure App Configuration + +```python +# BEFORE (connection string) +from azure.appconfiguration import AzureAppConfigurationClient +client = AzureAppConfigurationClient.from_connection_string( + "Endpoint=https://myconfig.azconfig.io;Id=...;Secret=..." +) + +# AFTER (managed identity) +from azure.identity import DefaultAzureCredential +from azure.appconfiguration import AzureAppConfigurationClient +client = AzureAppConfigurationClient( + base_url="https://myconfig.azconfig.io", + credential=DefaultAzureCredential() +) +``` + +### Steps + +1. Enable MI on your compute resource +2. Assign `App Configuration Data Reader` role +3. Replace connection string with endpoint URL + `DefaultAzureCredential` + +--- + +## .NET Examples + +### Azure Storage (Blob) + +```csharp +// BEFORE +var client = new BlobServiceClient("DefaultEndpointsProtocol=https;AccountName=...;AccountKey=..."); + +// AFTER +var client = new BlobServiceClient( + new Uri("https://mystorageaccount.blob.core.windows.net"), + new DefaultAzureCredential()); +``` + +### Azure Service Bus + +```csharp +// BEFORE +var client = new ServiceBusClient("Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..."); + +// AFTER +var client = new ServiceBusClient( + "my-namespace.servicebus.windows.net", + new DefaultAzureCredential()); +``` + +### Azure Cosmos DB + +```csharp +// BEFORE +var client = new CosmosClient("https://myaccount.documents.azure.com:443/", "primary-key-here"); + +// AFTER +var client = new CosmosClient( + "https://myaccount.documents.azure.com:443/", + new DefaultAzureCredential()); +``` + +--- + +## TypeScript Examples + +### Azure Storage (Blob) + +```typescript +// BEFORE +const client = BlobServiceClient.fromConnectionString("DefaultEndpointsProtocol=https;..."); + +// AFTER +import { DefaultAzureCredential } from "@azure/identity"; +import { BlobServiceClient } from "@azure/storage-blob"; +const client = new BlobServiceClient( + "https://mystorageaccount.blob.core.windows.net", + new DefaultAzureCredential() +); +``` + +### Azure Service Bus + +```typescript +// BEFORE +const client = new ServiceBusClient("Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..."); + +// AFTER +import { DefaultAzureCredential } from "@azure/identity"; +import { ServiceBusClient } from "@azure/service-bus"; +const client = new ServiceBusClient( + "my-namespace.servicebus.windows.net", + new DefaultAzureCredential() +); +``` + +--- + +## Java Examples + +### Azure Storage (Blob) + +```java +// BEFORE +BlobServiceClient client = new BlobServiceClientBuilder() + .connectionString("DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...") + .buildClient(); + +// AFTER +TokenCredential credential = new DefaultAzureCredentialBuilder().build(); +BlobServiceClient client = new BlobServiceClientBuilder() + .endpoint("https://mystorageaccount.blob.core.windows.net") + .credential(credential) + .buildClient(); +``` + +### Azure Service Bus + +```java +// BEFORE +ServiceBusClientBuilder builder = new ServiceBusClientBuilder() + .connectionString("Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..."); + +// AFTER +TokenCredential credential = new DefaultAzureCredentialBuilder().build(); +ServiceBusClientBuilder builder = new ServiceBusClientBuilder() + .fullyQualifiedNamespace("my-namespace.servicebus.windows.net") + .credential(credential); +``` diff --git a/README.md b/README.md index 13181bf..690a669 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Skills, custom agents, AGENTS.md templates, and MCP configurations for AI coding > **Blog post:** [Context-Driven Development: Agent Skills for Microsoft Foundry and Azure](https://devblogs.microsoft.com/all-things-azure/context-driven-development-agent-skills-for-microsoft-foundry-and-azure/) -> **🔍 Skill Explorer:** [Browse all 132 skills with 1-click install](https://microsoft.github.io/skills/) +> **🔍 Skill Explorer:** [Browse all 133 skills with 1-click install](https://microsoft.github.io/skills/) ## Quick Start @@ -70,11 +70,11 @@ Coding agents like [Copilot CLI](https://github.com/features/copilot/cli) and [G ## Skill Catalog -> 132 skills in `.github/skills/` — flat structure with language suffixes for automatic discovery +> 133 skills in `.github/skills/` — flat structure with language suffixes for automatic discovery | Language | Count | Suffix | |----------|-------|--------| -| [Core](#core) | 9 | — | +| [Core](#core) | 10 | — | | [Python](#python) | 41 | `-py` | | [.NET](#net) | 28 | `-dotnet` | | [TypeScript](#typescript) | 25 | `-ts` | @@ -85,12 +85,13 @@ Coding agents like [Copilot CLI](https://github.com/features/copilot/cli) and [G ### Core -> 9 skills — tooling, infrastructure, language-agnostic +> 10 skills — tooling, infrastructure, language-agnostic | Skill | Description | |-------|-------------| | [cloud-solution-architect](.github/skills/cloud-solution-architect/) | Design well-architected Azure cloud systems. Architecture styles, 44 design patterns, technology choices, mission-critical design, WAF pillars. | | [copilot-sdk](.github/skills/copilot-sdk/) | Build applications powered by GitHub Copilot using the Copilot SDK. Session management, custom tools, streaming, hooks, MCP servers, BYOK, deployment patterns. | +| [credential-free-dev](.github/skills/credential-free-dev/) | Eliminate secrets from Azure apps using managed identities, workload identity federation, and Azure Identity SDK. Migration patterns, RBAC roles, DefaultAzureCredential. | | [entra-agent-id](.github/skills/entra-agent-id/) | Microsoft Entra Agent ID (preview) — create OAuth2-capable AI agent identities via Microsoft Graph beta API. Blueprints, BlueprintPrincipals, permissions, WIF. | | [frontend-design-review](.github/skills/frontend-design-review/) | Review and create distinctive frontend interfaces. Design system compliance, quality pillars, accessibility, and creative aesthetics. | | [github-issue-creator](.github/skills/github-issue-creator/) | Convert raw notes, error logs, or screenshots into structured GitHub issues. | @@ -631,11 +632,11 @@ pnpm test ### Test Coverage Summary -**128 skills with 1158 test scenarios** — all skills have acceptance criteria and test scenarios. +**129 skills with 1164 test scenarios** — all skills have acceptance criteria and test scenarios. | Language | Skills | Scenarios | Top Skills by Scenarios | |----------|--------|-----------|-------------------------| -| Core | 7 | 72 | `copilot-sdk` (11), `podcast-generation` (8), `skill-creator` (8) | +| Core | 8 | 78 | `copilot-sdk` (11), `podcast-generation` (8), `skill-creator` (8) | | Python | 41 | 331 | `azure-ai-projects-py` (12), `pydantic-models-py` (12), `azure-ai-translation-text-py` (11) | | .NET | 29 | 290 | `azure-resource-manager-sql-dotnet` (14), `azure-resource-manager-redis-dotnet` (14), `azure-servicebus-dotnet` (13) | | TypeScript | 25 | 270 | `azure-storage-blob-ts` (17), `azure-servicebus-ts` (14), `aspire-ts` (13) | diff --git a/skills/dotnet/entra/credential-free-dev b/skills/dotnet/entra/credential-free-dev new file mode 100644 index 0000000..9b65ee7 --- /dev/null +++ b/skills/dotnet/entra/credential-free-dev @@ -0,0 +1 @@ +../../../.github/skills/credential-free-dev \ No newline at end of file diff --git a/skills/java/entra/credential-free-dev b/skills/java/entra/credential-free-dev new file mode 100644 index 0000000..9b65ee7 --- /dev/null +++ b/skills/java/entra/credential-free-dev @@ -0,0 +1 @@ +../../../.github/skills/credential-free-dev \ No newline at end of file diff --git a/skills/python/entra/credential-free-dev b/skills/python/entra/credential-free-dev new file mode 100644 index 0000000..9b65ee7 --- /dev/null +++ b/skills/python/entra/credential-free-dev @@ -0,0 +1 @@ +../../../.github/skills/credential-free-dev \ No newline at end of file diff --git a/skills/rust/entra/credential-free-dev b/skills/rust/entra/credential-free-dev new file mode 100644 index 0000000..9b65ee7 --- /dev/null +++ b/skills/rust/entra/credential-free-dev @@ -0,0 +1 @@ +../../../.github/skills/credential-free-dev \ No newline at end of file diff --git a/skills/typescript/entra/credential-free-dev b/skills/typescript/entra/credential-free-dev new file mode 100644 index 0000000..9b65ee7 --- /dev/null +++ b/skills/typescript/entra/credential-free-dev @@ -0,0 +1 @@ +../../../.github/skills/credential-free-dev \ No newline at end of file diff --git a/tests/scenarios/credential-free-dev/scenarios.yaml b/tests/scenarios/credential-free-dev/scenarios.yaml new file mode 100644 index 0000000..13e56a4 --- /dev/null +++ b/tests/scenarios/credential-free-dev/scenarios.yaml @@ -0,0 +1,215 @@ +# Test scenarios for credential-free-dev skill evaluation +# Each scenario tests credential-free development patterns across Azure services + +config: + model: gpt-4 + max_tokens: 2000 + temperature: 0.3 + +scenarios: + - name: storage_blob_credential_free + prompt: | + Create a Python script that reads blobs from Azure Storage. + Use credential-free authentication with managed identity. + expected_patterns: + - "DefaultAzureCredential" + - "BlobServiceClient" + - "azure.identity" + - "blob.core.windows.net" + forbidden_patterns: + - "AccountKey=" + - "from_connection_string" + - "SharedAccessKey" + - "api_key" + tags: + - storage + - python + - basic + mock_response: | + from azure.identity import DefaultAzureCredential + from azure.storage.blob import BlobServiceClient + + credential = DefaultAzureCredential() + client = BlobServiceClient( + account_url="https://mystorageaccount.blob.core.windows.net", + credential=credential + ) + + container = client.get_container_client("my-container") + for blob in container.list_blobs(): + print(blob.name) + + - name: cosmos_db_credential_free + prompt: | + Connect to Azure Cosmos DB from a Python app using managed identity + instead of the primary key. Show the migration from key-based auth. + expected_patterns: + - "DefaultAzureCredential" + - "CosmosClient" + - "azure.identity" + - "documents.azure.com" + forbidden_patterns: + - "primary-key" + - "master_key" + - "AccountKey=" + tags: + - cosmos + - python + - migration + mock_response: | + from azure.identity import DefaultAzureCredential + from azure.cosmos import CosmosClient + + # Credential-free: use DefaultAzureCredential instead of primary key + credential = DefaultAzureCredential() + client = CosmosClient( + "https://myaccount.documents.azure.com:443/", + credential=credential + ) + + database = client.get_database_client("my-database") + container = database.get_container_client("my-container") + + # Query items + items = container.query_items( + query="SELECT * FROM c WHERE c.category = @category", + parameters=[{"name": "@category", "value": "example"}], + enable_cross_partition_query=True + ) + for item in items: + print(item) + + - name: service_bus_credential_free + prompt: | + Migrate an Azure Service Bus sender from connection string to + managed identity authentication in Python. + expected_patterns: + - "DefaultAzureCredential" + - "ServiceBusClient" + - "fully_qualified_namespace" + - "servicebus.windows.net" + forbidden_patterns: + - "from_connection_string" + - "SharedAccessKey" + - "SharedAccessKeyName" + tags: + - servicebus + - python + - migration + mock_response: | + from azure.identity import DefaultAzureCredential + from azure.servicebus import ServiceBusClient, ServiceBusMessage + + credential = DefaultAzureCredential() + client = ServiceBusClient( + fully_qualified_namespace="my-namespace.servicebus.windows.net", + credential=credential + ) + + with client: + sender = client.get_queue_sender(queue_name="my-queue") + with sender: + message = ServiceBusMessage("Hello, credential-free!") + sender.send_messages(message) + + - name: github_actions_workload_identity + prompt: | + Set up a GitHub Actions workflow to deploy to Azure using workload + identity federation instead of storing a client secret. + expected_patterns: + - "azure/login@v2" + - "client-id" + - "tenant-id" + - "subscription-id" + forbidden_patterns: + - "AZURE_CLIENT_SECRET" + - "AZURE_CREDENTIALS" + - "client_secret" + - "creds:" + tags: + - github-actions + - wif + - cicd + mock_response: | + name: Deploy to Azure + on: + push: + branches: [main] + + permissions: + id-token: write + contents: read + + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Deploy + run: az webapp deploy --name my-app --src-path ./app.zip + + - name: dotnet_storage_credential_free + prompt: | + Show how to connect to Azure Blob Storage from a .NET application + using DefaultAzureCredential instead of a connection string. + expected_patterns: + - "DefaultAzureCredential" + - "BlobServiceClient" + - "Azure.Identity" + - "blob.core.windows.net" + forbidden_patterns: + - "AccountKey=" + - "FromConnectionString" + - "SharedAccessKey" + tags: + - storage + - dotnet + - basic + mock_response: | + using Azure.Identity; + using Azure.Storage.Blobs; + + var credential = new DefaultAzureCredential(); + var client = new BlobServiceClient( + new Uri("https://mystorageaccount.blob.core.windows.net"), + credential); + + var container = client.GetBlobContainerClient("my-container"); + await foreach (var blob in container.GetBlobsAsync()) + { + Console.WriteLine(blob.Name); + } + + - name: rbac_least_privilege + prompt: | + My app needs to read blobs from Azure Storage. What RBAC role + should I assign to the managed identity? + expected_patterns: + - "Storage Blob Data Reader" + forbidden_patterns: + - "Contributor(?!.*Data)" + - "(? \ + --role "Storage Blob Data Reader" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.Storage/storageAccounts/ + ``` + + Avoid broader roles like Contributor or Owner. The Storage Blob Data Reader + role grants read-only access to blob data without access to management operations. From 2f041f5d873fd2f530404746cba469e858f11215 Mon Sep 17 00:00:00 2001 From: Arturo Lucatero <13911090+ArLucaID@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:46:03 -0700 Subject: [PATCH 3/3] feat: Replace DefaultAzureCredential with ManagedIdentityCredential for production Switch credential-free-dev skill to recommend ManagedIdentityCredential for production workloads, keeping DefaultAzureCredential for local dev only. Per Azure Identity best practices guidance: DefaultAzureCredential's credential chain probing can cause subtle issues or silent failures in production apps. Replace with a specific TokenCredential implementation such as ManagedIdentityCredential. Updated across all 3 skill files: - SKILL.md: new production auth section, updated core principles, code samples, migration steps, and added pitfall about DAC in production - migration-patterns.md: all AFTER examples now use ManagedIdentityCredential (Python, .NET, TypeScript, Java across 7 Azure services) - acceptance-criteria.md: ManagedIdentityCredential as correct production pattern, DefaultAzureCredential in production marked as incorrect --- .github/skills/credential-free-dev/SKILL.md | 75 +++++++++++++------ .../references/acceptance-criteria.md | 37 +++++++-- .../references/migration-patterns.md | 64 ++++++++-------- 3 files changed, 113 insertions(+), 63 deletions(-) diff --git a/.github/skills/credential-free-dev/SKILL.md b/.github/skills/credential-free-dev/SKILL.md index 3b2155a..3cd8708 100644 --- a/.github/skills/credential-free-dev/SKILL.md +++ b/.github/skills/credential-free-dev/SKILL.md @@ -6,7 +6,7 @@ description: | from connection strings and keys to credential-free authentication across Azure services. Triggers: "credential-free", "managed identity", "workload identity federation", "remove secrets", "connection string to managed identity", "DefaultAzureCredential", - "passwordless", "keyless authentication", "eliminate keys". + "ManagedIdentityCredential", "passwordless", "keyless authentication", "eliminate keys". --- # Azure Credential-Free Development @@ -17,13 +17,13 @@ Eliminate secrets and credentials from Azure applications using managed identiti Search `microsoft-docs` MCP for current patterns: - Query: "credential-free development Azure managed identity" -- Query: "DefaultAzureCredential [target service] [language]" +- Query: "ManagedIdentityCredential [target service] [language]" - Verify: RBAC role names match current documentation ## Core Principles 1. **No secrets in code, config, or environment variables.** Use managed identities for Azure-hosted workloads. Use workload identity federation for non-Azure or CI/CD workloads. -2. **DefaultAzureCredential is the standard entry point.** Unified credential chain that works locally (developer credentials) and in production (managed identity) without code changes. +2. **ManagedIdentityCredential for production. DefaultAzureCredential for local dev only.** In production, use `ManagedIdentityCredential` explicitly — `DefaultAzureCredential`'s credential chain probing can cause subtle failures, latency, and silent fallback to unintended credentials. `DefaultAzureCredential` remains convenient for local development (falls through to Azure CLI / VS credentials). 3. **System-assigned managed identity** for single-purpose resources. **User-assigned managed identity** when multiple resources share a credential or when you need pre-provisioned RBAC. 4. **Connection strings with keys are legacy.** Every Azure service that supports Entra auth should use it. If a service requires a key, treat it as a gap to escalate, not a pattern to accept. 5. **Least privilege always.** Grant the narrowest RBAC role that works. `Storage Blob Data Reader`, not `Storage Account Key Operator`. `db_datareader`, not `db_owner`. @@ -32,14 +32,27 @@ Search `microsoft-docs` MCP for current patterns: | Scenario | Use This | Why | |---|---|---| -| App on Azure (App Service, Container Apps, Functions, VMs, AKS) | **Managed Identity** (system or user-assigned) | Zero secrets, platform-managed rotation, Entra RBAC | +| App on Azure (App Service, Container Apps, Functions, VMs, AKS) | **ManagedIdentityCredential** (system or user-assigned) | Zero secrets, platform-managed rotation, Entra RBAC. Use explicit credential, not DefaultAzureCredential. | | CI/CD pipeline (GitHub Actions, Azure DevOps) | **Workload Identity Federation** | No secrets stored. OIDC token exchange with Entra. | | App on AWS/GCP calling Azure | **Workload Identity Federation** | Cross-cloud trust without shared secrets | -| Local development | **Azure CLI / VS credentials** (via DefaultAzureCredential) | Developer's own identity, no shared dev secrets | +| Local development | **DefaultAzureCredential** | Developer's own identity via CLI/VS fallback, no shared dev secrets | | Service-to-service (no Azure hosting) | **App registration + certificate** | When MI/WIF aren't possible. Certificates over secrets. | | Legacy app that can't change auth code | **Key Vault references** (stepping stone) | Secrets in Key Vault, not in config. Not the end state. | -## DefaultAzureCredential +## Production Authentication: ManagedIdentityCredential + +> **Do not use `DefaultAzureCredential` in production.** Its credential chain probing +> can cause subtle issues or silent failures. Replace it with a specific +> `TokenCredential` implementation such as `ManagedIdentityCredential`. +> — [Authentication best practices with the Azure Identity library](https://learn.microsoft.com/dotnet/azure/sdk/authentication/best-practices) + +Use `ManagedIdentityCredential` directly for Azure-hosted workloads. For user-assigned +managed identities, pass the `client_id` explicitly. + +## DefaultAzureCredential (Local Development Only) + +`DefaultAzureCredential` is convenient for local development because it automatically +falls through to developer credentials (Azure CLI, VS, etc.). **Do not use in production.** ### Credential Chain (in order) @@ -51,7 +64,8 @@ Search `microsoft-docs` MCP for current patterns: 6. Azure Developer CLI (`azd auth login`) 7. Interactive browser (if enabled) -In production on Azure, step 3 (Managed Identity) fires. Steps 4-7 are for development. +Locally, steps 4-6 fire using your developer identity. In production this chain +introduces latency and unpredictable fallback — use `ManagedIdentityCredential` instead. ### SDK Packages @@ -63,24 +77,28 @@ In production on Azure, step 3 (Managed Identity) fires. Steps 4-7 are for devel | TypeScript | `@azure/identity` | `npm install @azure/identity` | | Go | `azidentity` | `go get github.com/Azure/azure-sdk-for-go/sdk/azidentity` | -### Authentication Pattern (All Languages) +### Production Pattern (All Languages) ```python -# Python -from azure.identity import DefaultAzureCredential -credential = DefaultAzureCredential() +# Python — production +from azure.identity import ManagedIdentityCredential +credential = ManagedIdentityCredential() # system-assigned +# credential = ManagedIdentityCredential(client_id="") # user-assigned client = ServiceClient(endpoint, credential=credential) ``` ```csharp -// C# -var credential = new DefaultAzureCredential(); +// C# — production +var credential = new ManagedIdentityCredential(); // system-assigned +// var credential = new ManagedIdentityCredential(""); // user-assigned var client = new ServiceClient(new Uri(endpoint), credential); ``` ```java -// Java -TokenCredential credential = new DefaultAzureCredentialBuilder().build(); +// Java — production +TokenCredential credential = new ManagedIdentityCredentialBuilder().build(); // system-assigned +// TokenCredential credential = new ManagedIdentityCredentialBuilder() +// .clientId("").build(); // user-assigned ServiceClient client = new ServiceClientBuilder() .endpoint(endpoint) .credential(credential) @@ -88,19 +106,29 @@ ServiceClient client = new ServiceClientBuilder() ``` ```typescript -// TypeScript -import { DefaultAzureCredential } from "@azure/identity"; -const credential = new DefaultAzureCredential(); +// TypeScript — production +import { ManagedIdentityCredential } from "@azure/identity"; +const credential = new ManagedIdentityCredential(); // system-assigned +// const credential = new ManagedIdentityCredential(""); // user-assigned const client = new ServiceClient(endpoint, credential); ``` +### Local Development Pattern + +```python +# Python — local dev only +from azure.identity import DefaultAzureCredential +credential = DefaultAzureCredential() +client = ServiceClient(endpoint, credential=credential) +``` + ## Migration Pattern: Keys to Credential-Free Every migration follows the same 4 steps: 1. **Enable managed identity** on your compute resource 2. **Assign the right RBAC role** on the target resource -3. **Replace** connection string / key with endpoint URL + `DefaultAzureCredential` +3. **Replace** connection string / key with endpoint URL + `ManagedIdentityCredential` (production) or `DefaultAzureCredential` (local dev) 4. **Remove** the key/secret from all config ### Quick Reference: Service RBAC Roles @@ -143,10 +171,11 @@ No `AZURE_CLIENT_SECRET` needed. The GitHub OIDC token is exchanged directly. 1. **Forgetting RBAC roles.** Enabling MI is step 1. Assigning the right role on the target resource is step 2. Most "MI doesn't work" issues are missing role assignments. 2. **Overly broad roles.** `Contributor` on a resource group when you need `Storage Blob Data Reader` on one account. 3. **Not testing locally.** `DefaultAzureCredential` falls through to Azure CLI. Make sure `az login` is done with the right subscription. -4. **Mixing key and MI auth.** Some SDKs behave differently when both a connection string and a credential are provided. Pick one. -5. **Assuming all services support MI.** Most do. Some legacy or partner services don't yet. Check the [services that support managed identities](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/managed-identities-status) list. -6. **Not handling token refresh.** `DefaultAzureCredential` handles refresh automatically. If caching tokens manually, respect expiry. -7. **System-assigned when user-assigned is better.** Multiple resources needing same permissions = user-assigned MI to avoid duplicating RBAC assignments. +4. **Using `DefaultAzureCredential` in production.** Its credential chain probing adds latency, can fall back to unintended credentials, and masks configuration errors. Use `ManagedIdentityCredential` explicitly for Azure-hosted production apps. +5. **Mixing key and MI auth.** Some SDKs behave differently when both a connection string and a credential are provided. Pick one. +6. **Assuming all services support MI.** Most do. Some legacy or partner services don't yet. Check the [services that support managed identities](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/managed-identities-status) list. +7. **Not handling token refresh.** `ManagedIdentityCredential` handles refresh automatically. If caching tokens manually, respect expiry. +8. **System-assigned when user-assigned is better.** Multiple resources needing same permissions = user-assigned MI to avoid duplicating RBAC assignments. ## Reference Files diff --git a/.github/skills/credential-free-dev/references/acceptance-criteria.md b/.github/skills/credential-free-dev/references/acceptance-criteria.md index 4a148eb..aa77fdd 100644 --- a/.github/skills/credential-free-dev/references/acceptance-criteria.md +++ b/.github/skills/credential-free-dev/references/acceptance-criteria.md @@ -7,9 +7,21 @@ ## 1. Authentication Patterns -### 1.1 DefaultAzureCredential Usage +### 1.1 Production Credential Selection -#### ✅ CORRECT: DefaultAzureCredential with endpoint URL +#### ✅ CORRECT: ManagedIdentityCredential for production +```python +from azure.identity import ManagedIdentityCredential +from azure.storage.blob import BlobServiceClient + +credential = ManagedIdentityCredential() +client = BlobServiceClient( + account_url="https://mystorageaccount.blob.core.windows.net", + credential=credential +) +``` + +#### ✅ CORRECT: DefaultAzureCredential for local development ```python from azure.identity import DefaultAzureCredential from azure.storage.blob import BlobServiceClient @@ -21,6 +33,15 @@ client = BlobServiceClient( ) ``` +#### ❌ INCORRECT: DefaultAzureCredential in production +```python +# WRONG — DefaultAzureCredential's credential chain probing causes subtle failures, +# latency, and silent fallback to unintended credentials in production. +# Use ManagedIdentityCredential explicitly. +from azure.identity import DefaultAzureCredential +credential = DefaultAzureCredential() # Don't use in production +``` + #### ❌ INCORRECT: Hardcoded key in code ```python from azure.storage.blob import BlobServiceClient @@ -68,11 +89,11 @@ credential = ClientSecretCredential( ### 2.1 Storage Blob -#### ✅ CORRECT: Endpoint URL + credential +#### ✅ CORRECT: Endpoint URL + ManagedIdentityCredential ```python client = BlobServiceClient( account_url="https://myaccount.blob.core.windows.net", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` @@ -83,11 +104,11 @@ client = BlobServiceClient.from_connection_string("...AccountKey=...") ### 2.2 Service Bus -#### ✅ CORRECT: Namespace + credential +#### ✅ CORRECT: Namespace + ManagedIdentityCredential ```python client = ServiceBusClient( fully_qualified_namespace="my-namespace.servicebus.windows.net", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` @@ -98,11 +119,11 @@ client = ServiceBusClient.from_connection_string("...SharedAccessKey=...") ### 2.3 Cosmos DB -#### ✅ CORRECT: URL + credential +#### ✅ CORRECT: URL + ManagedIdentityCredential ```python client = CosmosClient( "https://myaccount.documents.azure.com:443/", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` diff --git a/.github/skills/credential-free-dev/references/migration-patterns.md b/.github/skills/credential-free-dev/references/migration-patterns.md index 1026f59..ff22211 100644 --- a/.github/skills/credential-free-dev/references/migration-patterns.md +++ b/.github/skills/credential-free-dev/references/migration-patterns.md @@ -8,11 +8,11 @@ Detailed before/after code for migrating Azure services from connection strings # BEFORE (connection string with password) conn_str = "Server=myserver.database.windows.net;Database=mydb;User Id=myuser;Password=mypassword;" -# AFTER (managed identity with Azure Identity SDK) -from azure.identity import DefaultAzureCredential +# AFTER (managed identity — production) +from azure.identity import ManagedIdentityCredential import pyodbc -credential = DefaultAzureCredential() +credential = ManagedIdentityCredential() token = credential.get_token("https://database.windows.net/.default") conn = pyodbc.connect( "Driver={ODBC Driver 18 for SQL Server};" @@ -34,7 +34,7 @@ conn = pyodbc.connect( ALTER ROLE db_datareader ADD MEMBER [my-app-name]; ``` 4. Remove the password from your connection string -5. Use `DefaultAzureCredential` in code to acquire tokens +5. Use `ManagedIdentityCredential` in code to acquire tokens --- @@ -48,11 +48,11 @@ client = BlobServiceClient.from_connection_string( ) # AFTER (managed identity) -from azure.identity import DefaultAzureCredential +from azure.identity import ManagedIdentityCredential from azure.storage.blob import BlobServiceClient client = BlobServiceClient( account_url="https://mystorageaccount.blob.core.windows.net", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` @@ -60,7 +60,7 @@ client = BlobServiceClient( 1. Enable MI on your compute resource 2. Assign `Storage Blob Data Reader` (or `Contributor`/`Owner`) role to the MI on the storage account -3. Replace connection string with account URL + `DefaultAzureCredential` +3. Replace connection string with account URL + `ManagedIdentityCredential` 4. Remove the storage account key from all config --- @@ -76,11 +76,11 @@ client = CosmosClient( ) # AFTER (managed identity via Entra RBAC) -from azure.identity import DefaultAzureCredential +from azure.identity import ManagedIdentityCredential from azure.cosmos import CosmosClient client = CosmosClient( "https://myaccount.documents.azure.com:443/", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` @@ -88,7 +88,7 @@ client = CosmosClient( 1. Enable Entra auth on Cosmos DB account (may need to disable key-based auth) 2. Assign `Cosmos DB Built-in Data Reader` (or `Contributor`) role -3. Replace key with `DefaultAzureCredential` +3. Replace key with `ManagedIdentityCredential` --- @@ -102,11 +102,11 @@ client = ServiceBusClient.from_connection_string( ) # AFTER (managed identity) -from azure.identity import DefaultAzureCredential +from azure.identity import ManagedIdentityCredential from azure.servicebus import ServiceBusClient client = ServiceBusClient( fully_qualified_namespace="my-namespace.servicebus.windows.net", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` @@ -114,7 +114,7 @@ client = ServiceBusClient( 1. Enable MI on your compute resource 2. Assign `Azure Service Bus Data Sender` and/or `Azure Service Bus Data Receiver` role -3. Replace connection string with namespace + `DefaultAzureCredential` +3. Replace connection string with namespace + `ManagedIdentityCredential` --- @@ -129,12 +129,12 @@ client = EventHubProducerClient.from_connection_string( ) # AFTER (managed identity) -from azure.identity import DefaultAzureCredential +from azure.identity import ManagedIdentityCredential from azure.eventhub import EventHubProducerClient client = EventHubProducerClient( fully_qualified_namespace="my-namespace.servicebus.windows.net", eventhub_name="my-hub", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` @@ -142,7 +142,7 @@ client = EventHubProducerClient( 1. Enable MI on your compute resource 2. Assign `Azure Event Hubs Data Sender` and/or `Azure Event Hubs Data Receiver` role -3. Replace connection string with namespace + `DefaultAzureCredential` +3. Replace connection string with namespace + `ManagedIdentityCredential` --- @@ -151,12 +151,12 @@ client = EventHubProducerClient( ```python # Key Vault already requires Entra auth — but ensure you're using MI, not client secret -# CORRECT -from azure.identity import DefaultAzureCredential +# CORRECT (production) +from azure.identity import ManagedIdentityCredential from azure.keyvault.secrets import SecretClient client = SecretClient( vault_url="https://my-vault.vault.azure.net", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` @@ -164,7 +164,7 @@ client = SecretClient( 1. Enable MI on your compute resource 2. Assign `Key Vault Secrets User` (for reading secrets) or `Key Vault Secrets Officer` (for read/write) -3. Use vault URL + `DefaultAzureCredential` +3. Use vault URL + `ManagedIdentityCredential` --- @@ -178,11 +178,11 @@ client = AzureAppConfigurationClient.from_connection_string( ) # AFTER (managed identity) -from azure.identity import DefaultAzureCredential +from azure.identity import ManagedIdentityCredential from azure.appconfiguration import AzureAppConfigurationClient client = AzureAppConfigurationClient( base_url="https://myconfig.azconfig.io", - credential=DefaultAzureCredential() + credential=ManagedIdentityCredential() ) ``` @@ -190,7 +190,7 @@ client = AzureAppConfigurationClient( 1. Enable MI on your compute resource 2. Assign `App Configuration Data Reader` role -3. Replace connection string with endpoint URL + `DefaultAzureCredential` +3. Replace connection string with endpoint URL + `ManagedIdentityCredential` --- @@ -205,7 +205,7 @@ var client = new BlobServiceClient("DefaultEndpointsProtocol=https;AccountName=. // AFTER var client = new BlobServiceClient( new Uri("https://mystorageaccount.blob.core.windows.net"), - new DefaultAzureCredential()); + new ManagedIdentityCredential()); ``` ### Azure Service Bus @@ -217,7 +217,7 @@ var client = new ServiceBusClient("Endpoint=sb://...;SharedAccessKeyName=...;Sha // AFTER var client = new ServiceBusClient( "my-namespace.servicebus.windows.net", - new DefaultAzureCredential()); + new ManagedIdentityCredential()); ``` ### Azure Cosmos DB @@ -229,7 +229,7 @@ var client = new CosmosClient("https://myaccount.documents.azure.com:443/", "pri // AFTER var client = new CosmosClient( "https://myaccount.documents.azure.com:443/", - new DefaultAzureCredential()); + new ManagedIdentityCredential()); ``` --- @@ -243,11 +243,11 @@ var client = new CosmosClient( const client = BlobServiceClient.fromConnectionString("DefaultEndpointsProtocol=https;..."); // AFTER -import { DefaultAzureCredential } from "@azure/identity"; +import { ManagedIdentityCredential } from "@azure/identity"; import { BlobServiceClient } from "@azure/storage-blob"; const client = new BlobServiceClient( "https://mystorageaccount.blob.core.windows.net", - new DefaultAzureCredential() + new ManagedIdentityCredential() ); ``` @@ -258,11 +258,11 @@ const client = new BlobServiceClient( const client = new ServiceBusClient("Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..."); // AFTER -import { DefaultAzureCredential } from "@azure/identity"; +import { ManagedIdentityCredential } from "@azure/identity"; import { ServiceBusClient } from "@azure/service-bus"; const client = new ServiceBusClient( "my-namespace.servicebus.windows.net", - new DefaultAzureCredential() + new ManagedIdentityCredential() ); ``` @@ -279,7 +279,7 @@ BlobServiceClient client = new BlobServiceClientBuilder() .buildClient(); // AFTER -TokenCredential credential = new DefaultAzureCredentialBuilder().build(); +TokenCredential credential = new ManagedIdentityCredentialBuilder().build(); BlobServiceClient client = new BlobServiceClientBuilder() .endpoint("https://mystorageaccount.blob.core.windows.net") .credential(credential) @@ -294,7 +294,7 @@ ServiceBusClientBuilder builder = new ServiceBusClientBuilder() .connectionString("Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..."); // AFTER -TokenCredential credential = new DefaultAzureCredentialBuilder().build(); +TokenCredential credential = new ManagedIdentityCredentialBuilder().build(); ServiceBusClientBuilder builder = new ServiceBusClientBuilder() .fullyQualifiedNamespace("my-namespace.servicebus.windows.net") .credential(credential);