From b7141825e98a98ca87505f74f9116f32273f6951 Mon Sep 17 00:00:00 2001 From: vj926 Date: Thu, 4 Jun 2026 13:10:45 -0700 Subject: [PATCH] deploy: add Agent ID User (AUID) demo under kubernetes-service/auid Adds the Agent ID User end-to-end sample (Blueprint -> Agent ID -> AUID FIC chain in Python) parallel to the existing kubernetes-service/dev tutorial. Includes broker (FastAPI), downstream Weather Agent that validates the AUID token, UI, provisioning + preflight scripts, and a deploy-auid-demo skill. --- .../skills/deploy-auid-demo/PERMISSIONS.md | 97 ++++ .../.claude/skills/deploy-auid-demo/SKILL.md | 134 +++++ .../kubernetes-service/auid/.env.example | 32 ++ .../azure/kubernetes-service/auid/.gitignore | 24 + .../azure/kubernetes-service/auid/README.md | 160 ++++++ .../kubernetes-service/auid/backend/app.py | 149 +++++ .../auid/backend/auid_flow.py | 179 ++++++ .../auid/backend/requirements.txt | 4 + .../auid/scripts/00-preflight-check.ps1 | 293 ++++++++++ .../scripts/01-provision-agentic-user.ps1 | 107 ++++ .../scripts/02-grant-agentic-user-consent.ps1 | 27 + .../auid/scripts/03-test-token-chain.ps1 | 127 +++++ .../kubernetes-service/auid/ui/index.html | 509 ++++++++++++++++++ .../auid/weather-agent/app.py | 131 +++++ .../auid/weather-agent/requirements.txt | 5 + 15 files changed, 1978 insertions(+) create mode 100644 deploy/azure/kubernetes-service/auid/.claude/skills/deploy-auid-demo/PERMISSIONS.md create mode 100644 deploy/azure/kubernetes-service/auid/.claude/skills/deploy-auid-demo/SKILL.md create mode 100644 deploy/azure/kubernetes-service/auid/.env.example create mode 100644 deploy/azure/kubernetes-service/auid/.gitignore create mode 100644 deploy/azure/kubernetes-service/auid/README.md create mode 100644 deploy/azure/kubernetes-service/auid/backend/app.py create mode 100644 deploy/azure/kubernetes-service/auid/backend/auid_flow.py create mode 100644 deploy/azure/kubernetes-service/auid/backend/requirements.txt create mode 100644 deploy/azure/kubernetes-service/auid/scripts/00-preflight-check.ps1 create mode 100644 deploy/azure/kubernetes-service/auid/scripts/01-provision-agentic-user.ps1 create mode 100644 deploy/azure/kubernetes-service/auid/scripts/02-grant-agentic-user-consent.ps1 create mode 100644 deploy/azure/kubernetes-service/auid/scripts/03-test-token-chain.ps1 create mode 100644 deploy/azure/kubernetes-service/auid/ui/index.html create mode 100644 deploy/azure/kubernetes-service/auid/weather-agent/app.py create mode 100644 deploy/azure/kubernetes-service/auid/weather-agent/requirements.txt diff --git a/deploy/azure/kubernetes-service/auid/.claude/skills/deploy-auid-demo/PERMISSIONS.md b/deploy/azure/kubernetes-service/auid/.claude/skills/deploy-auid-demo/PERMISSIONS.md new file mode 100644 index 0000000..3a6e8fc --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/.claude/skills/deploy-auid-demo/PERMISSIONS.md @@ -0,0 +1,97 @@ +# AUID Permissions & Scopes — Customer Checklist + +This is the **definitive list** of every Entra permission, scope, role assignment, app role, federated identity credential, and oauth2PermissionGrant required for the AUID flow to work end-to-end. The `scripts/00-preflight-check.ps1` script verifies each item programmatically and reports PASS / FAIL / WARN per row. **Run preflight first.** + +--- + +## 1. Admin operator — delegated scopes needed to run the demo + +The human who runs the setup scripts must sign in as a **Cloud Application Administrator** (or higher) and consent to these delegated Graph scopes on the well-known Microsoft Graph PowerShell client (`14d82eec-204b-4c2f-b7e8-296a70dab67e`): + +| Scope | Why | +|---|---| +| `Application.ReadWrite.All` | Read/create app registrations; mint Blueprint client secret if missing | +| `AppRoleAssignment.ReadWrite.All` | Grant the Blueprint SP the Graph app role `AgentIdUser.ReadWrite.IdentityParentedBy` | +| `DelegatedPermissionGrant.ReadWrite.All` | Create the `oauth2PermissionGrant` (AllPrincipals) that lets the Agentic User call Graph | +| `Directory.Read.All` | Resolve service principals by appId | +| `User.Read` | Identify the signed-in operator | + +The preflight script decodes the issued admin token and verifies every scope above is present in `scp`. Missing scopes → FAIL with a remediation hint. + +--- + +## 2. Blueprint app — what it needs in Entra + +| Item | Required? | Verified by preflight? | How to grant | +|---|---|---|---| +| App registration exists in the tenant | ✅ Yes | ✅ | Portal → App registrations → New registration | +| Service principal for the app exists | ✅ Yes | ✅ | Portal → Enterprise apps → New application → from app registration | +| Graph **app role** `AgentIdUser.ReadWrite.IdentityParentedBy` (roleId `4aa6e624-eee0-40ab-bdd8-f9639038a614`) granted **with admin consent** | ✅ Yes — this is the role that authorizes creation of `microsoft.graph.agentUser` objects parented to your Agent Identity | ✅ | `POST /v1.0/servicePrincipals/{bpSpId}/appRoleAssignments` with `{ principalId, resourceId: , appRoleId: 4aa6e624-... }` | +| At least one **non-expired client secret** | ✅ Yes (for steps 03.01 and 03.03) | ✅ (also warns if <14 days from expiry) | Portal → Certificates & secrets → New client secret, or `POST /applications/{id}/addPassword` | + +> **Why an app role and not a delegated scope?** Creating a `microsoft.graph.agentUser` parented to your Agent Identity is an **app-only** Graph operation. A delegated admin token — even Global Admin — gets `403 Authorization_RequestDenied`. The Blueprint app must hold the application permission and use client_credentials to call Graph. + +--- + +## 3. Agent Identity app — what it needs in Entra + +| Item | Required? | Verified by preflight? | How to grant | +|---|---|---|---| +| App registration exists | ✅ Yes | ✅ | Portal → App registrations → New registration (with `agentApplication` extension settings against the Blueprint) | +| Service principal exists | ✅ Yes | ✅ | Required so the Agentic User can hold oauth2PermissionGrants against it | +| **Federated Identity Credential** trusting the Blueprint app (for jwt-bearer in step 03.02) | ✅ Yes | ✅ (warns if zero FICs) | `POST /applications/{aiAppObjectId}/federatedIdentityCredentials` with issuer = Entra v2 tenant issuer, subject = Blueprint appId, audience = `api://AzureADTokenExchange` | + +--- + +## 4. Agentic User — delegated Graph permissions + +The `microsoft.graph.agentUser` itself does not have its own consent UI. We grant **delegated** Graph scopes for it via `oauth2PermissionGrants` **for AllPrincipals** on the Agent Identity service principal. Without this grant, the AUID token will be issued but `GET /v1.0/me` returns 403. + +| Item | Required? | Verified by preflight? | How to grant | +|---|---|---|---| +| `oauth2PermissionGrant` on Agent Identity SP → Graph SP, `consentType=AllPrincipals` | ✅ Yes | ✅ | `scripts/02-grant-agentic-user-consent.ps1` — or `POST /v1.0/oauth2PermissionGrants` with `clientId=`, `resourceId=`, `consentType=AllPrincipals`, `scope="User.Read"` | +| `User.Read` is in the granted scope string | ✅ Yes (for Graph `/me`) | ✅ | Same as above | + +> **Pitfall:** Do **not** use the browser admin-consent URL (`https://login.microsoftonline.com/{tenant}/adminconsent?...&scope=User.Read+GroupMember.Read.All`) — the consent prompt page splits multi-word scopes incorrectly and you get `AADSTS650053: scope 'GroupMember.Read' that doesn't exist`. Grant via Graph `oauth2PermissionGrants` directly. + +--- + +## 5. (Optional) Dedicated Weather Agent app — for full signature verification + +The default chain in this repo requests `scope=https://graph.microsoft.com/.default`. The resulting AUID token carries a `nonce` claim in its JWT header that prevents third parties from cryptographically verifying its signature — only Graph itself can. The Weather Agent therefore performs **strict claim-based validation** by default (`iss`, `tid`, `aud`, `appid`, `idtyp=user`, `exp`). + +If you want full signature verification: + +| Item | Required? | Verified by preflight? | How to grant | +|---|---|---|---| +| A separate app registration for the downstream service | Only if you want crypto verification | ❌ (manual) | Portal → App registrations → New registration | +| `identifierUris = [api://]` | ✅ if above | ❌ | `PATCH /applications/{id}` | +| Exposed scope `Weather.Read` (or similar) | ✅ if above | ❌ | `PATCH /applications/{id}` adding to `api.oauth2PermissionScopes` | +| Service principal for the downstream app | ✅ if above | ❌ | Same flow as Blueprint SP | +| `oauth2PermissionGrant` on Agent Identity SP → Weather Agent SP (AllPrincipals, scope=`Weather.Read`) | ✅ if above | ❌ | `POST /v1.0/oauth2PermissionGrants` | +| `.env` set `WEATHER_AGENT_APP_ID=` | ✅ if above | ❌ | Edit `.env` | + +When all of the above are present, `backend/auid_flow.py` requests `scope=api:///.default`, the issued AUID token has `aud=api://`, and `weather-agent/app.py` switches to full RS256 verification against the v2 JWKS. + +--- + +## 6. Federated Identity Credential — what to verify after onboarding + +`agentApplication` setups normally create the FIC on the Agent Identity app trusting the Blueprint automatically. Confirm by listing FICs: + +```http +GET https://graph.microsoft.com/v1.0/applications/{agentIdAppObjectId}/federatedIdentityCredentials +``` + +You should see at least one credential whose `issuer` matches your Blueprint app's issuer and whose `audiences` includes `api://AzureADTokenExchange`. + +--- + +## 7. What the preflight script does NOT verify + +- **Conditional Access policies that block client_credentials or device-code on this tenant.** If 03.01 returns `AADSTS50079: Strong authentication is required` or similar, your CA policy needs a carve-out. +- **Network reachability** to `login.microsoftonline.com` and `graph.microsoft.com` from the host running the demo. +- **Time skew** on the demo host (tokens have `nbf`/`exp` — if your clock is >5 min off, validation will fail). +- **Whether the Agentic User itself is enabled and unblocked** (rare, but the Agentic User is a real user object subject to user lifecycle policies). + +These are the items to manually validate if preflight passes but the chain still fails. diff --git a/deploy/azure/kubernetes-service/auid/.claude/skills/deploy-auid-demo/SKILL.md b/deploy/azure/kubernetes-service/auid/.claude/skills/deploy-auid-demo/SKILL.md new file mode 100644 index 0000000..428bd36 --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/.claude/skills/deploy-auid-demo/SKILL.md @@ -0,0 +1,134 @@ +--- +name: deploy-auid-demo +description: Provision and deploy the Agent ID User (AUID) demo. Use when the user mentions "AUID", "Agent ID User", "microsoft.graph.agentUser", "digital colleague identity", or wants to demo "an agent acting as its own user" (the non-OBO complement to the AKS Agent ID demo). The skill walks tenant prerequisites, mints the Agentic User parented to an existing Agent Identity, builds the Blueprint → Agent ID → AUID FIC chain, and brings up a local 3-tier stack (broker + downstream Weather Agent + UI) that mirrors the look-and-feel of the OBO AKS demo. +--- + +# Deploy AUID demo (Agent ID User) + +## When to use this skill +Trigger when the user wants to demonstrate Agent ID User (AUID) — the mode where an Entra Agent Identity has its **own first-class user object** (`microsoft.graph.agentUser`) and the agent calls downstream services **as itself** (no human in the loop). This is the AUID analog of the OBO AKS demo in the `AgentID-using-EntraSDK_AKS` repo. + +Do NOT use this skill for: +- Autonomous Agent flows (no user dimension — use the Agent ID AKS demo). +- On-Behalf-Of flows where a real human signs in (use the AKS OBO demo). + +## Outcome +After completing this skill the customer will have: +- A `microsoft.graph.agentUser` provisioned in their tenant, parented to their existing Agent Identity app. +- The full Blueprint → Agent ID → AUID FIC chain proven end-to-end with a single PowerShell sanity check (`scripts/03-test-token-chain.ps1`). +- A running 3-tier local stack (broker on :7100, Weather Agent on :7200, UI on :7001) that visually matches the AKS OBO demo, with an "Acting as <Agentic User UPN>" badge replacing the human MSAL sign-in. + +## Pre-flight checklist (DO NOT SKIP) + +**Always run this before anything else:** + +```powershell +pwsh ./scripts/00-preflight-check.ps1 +``` + +The script signs the operator in (device code, as Cloud Application Administrator) and produces a colored PASS / FAIL / WARN report for every permission, scope, app role, client secret, service principal, and federated identity credential required by the AUID flow. It exits non-zero if anything is FAIL so you can wire it into CI. + +See **`PERMISSIONS.md`** in this same folder for the complete list of what's checked and how to remediate each FAIL — broken down by: +- **Admin operator** (delegated Graph scopes needed to run the scripts) +- **Blueprint app** (Graph **app role** `AgentIdUser.ReadWrite.IdentityParentedBy` + client secret + SP) +- **Agent Identity app** (SP + Federated Identity Credential trusting the Blueprint) +- **Agentic User** (delegated `oauth2PermissionGrant` for `User.Read` AllPrincipals on Agent Identity SP → Graph SP) +- **Optional Weather Agent app** (only if the customer wants full cryptographic signature verification — see PERMISSIONS.md §5) + +If the customer cannot pass preflight, **do not run any later script** — talk them through the FAIL rows first. Common blockers: + +1. Blueprint SP missing app role `AgentIdUser.ReadWrite.IdentityParentedBy` (roleId `4aa6e624-eee0-40ab-bdd8-f9639038a614`) — Step 1 will return `403 Authorization_RequestDenied`. +2. No non-expired Blueprint client secret — Steps 03.01 and 03.03 fail. +3. Agent Identity app has no Federated Identity Credential trusting the Blueprint — Step 03.02 returns `AADSTS700016` or `invalid_client`. +4. Admin's delegated token missing `AppRoleAssignment.ReadWrite.All` — can't grant the Blueprint app role programmatically. +5. Multi-scope browser admin-consent URL splitting `GroupMember.Read.All` → AADSTS650053 — use Step 2 script (`02-grant-agentic-user-consent.ps1`) which posts to `oauth2PermissionGrants` directly instead. + +## Workflow + +### Step 0 — Preflight (REQUIRED) +```powershell +pwsh ./scripts/00-preflight-check.ps1 +``` +**If any row prints FAIL, fix it before proceeding** — see `PERMISSIONS.md` for the granular how-to on each row. + +### Step 1 — Configure .env +```powershell +Copy-Item .env.example .env +# Fill: TENANT_ID, BLUEPRINT_APP_ID, AGENT_IDENTITY_APP_ID, BLUEPRINT_CLIENT_SECRET +``` + +### Step 2 — Provision the Agentic User +```powershell +pwsh ./scripts/01-provision-agentic-user.ps1 +``` +This script: +- Uses an app-only token from the Blueprint (the `AgentIdUser.ReadWrite.IdentityParentedBy` app role). +- POSTs to `/v1.0/users` with `@odata.type=#microsoft.graph.agentUser` and `identityParentId=`. +- Writes `AGENT_USER_UPN` and `AGENT_USER_OBJECT_ID` back into `.env`. + +**Common pitfall:** running this with a delegated admin token instead of an app-only Blueprint token returns `403 Authorization_RequestDenied`. The Blueprint **must** hold the `AgentIdUser.ReadWrite.IdentityParentedBy` application role. + +### Step 3 — Grant the Agentic User delegated Graph access +```powershell +pwsh ./scripts/02-grant-agentic-user-consent.ps1 +``` +Grants `User.Read` for `AllPrincipals` on the Agent Identity service principal via `oauth2PermissionGrants`. We do this programmatically — **not** via the browser admin-consent URL, because the consent-prompt page incorrectly splits `GroupMember.Read.All` into `GroupMember.Read` and fails with AADSTS650053. + +### Step 4 — Sanity-check the FIC chain +```powershell +pwsh ./scripts/03-test-token-chain.ps1 +``` +Expect to see: +``` +✓ 03.01 Blueprint FIC obtained +✓ 03.02 Agent ID FIC obtained +✓ 03.03 AUID access token obtained (idtyp=user, sub=) +✓ 03.04 GET /me returned @odata.type=#microsoft.graph.agentUser +🎉 Full AUID token chain works end-to-end. +``` + +### Step 5 — Run the demo stack +```powershell +python -m venv .venv; .\.venv\Scripts\Activate.ps1 +pip install -r backend/requirements.txt -r weather-agent/requirements.txt + +# 3 terminals (or background async sessions): +python -m uvicorn backend.app:app --host 127.0.0.1 --port 7100 +python -m uvicorn weather-agent.app:app --host 127.0.0.1 --port 7200 +python -m http.server 7001 --directory ui +``` +Open `http://localhost:7001`. Ask **"What is the weather in Dallas?"**. The right panel will render the live FIC chain trace; the left panel will respond with weather data + Agentic User claims. + +## Troubleshooting + +| Symptom | Cause | Fix | +|--|--|--| +| Step 2 returns `403 Authorization_RequestDenied` | Trying to create `agentUser` with a delegated admin token | Blueprint SP must hold the **application** role `AgentIdUser.ReadWrite.IdentityParentedBy`. Grant via `POST /servicePrincipals/{bpSpId}/appRoleAssignments`. | +| Step 3 fails AADSTS650053 (`GroupMember.Read doesn't exist`) | Multi-scope browser admin-consent URL splits scopes wrong | Use `02-grant-agentic-user-consent.ps1` instead of the browser. | +| Step 4 03.03 returns `invalid_grant` | `username` not matching the Agentic User UPN, or grant_type=user_fic missing | Confirm `AGENT_USER_UPN` in `.env` matches what `01-provision` wrote, and that step 03.03 uses `multipart/form-data`. | +| Weather Agent returns `Signature verification failed` | AUID token has `aud=graph` with a Graph nonce in the JWT header (intentionally non-verifiable by third parties) | Either accept claim-only validation (default), or register a dedicated Weather Agent app, expose a scope, set `WEATHER_AGENT_APP_ID` in `.env`. | +| `Connect-MgGraph -UseDeviceCode` device code never prints | PowerShell async pipe doesn't flush the prompt | Hit `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode` directly with `Invoke-RestMethod` to surface the code. | +| PS 5.1 fails to load Microsoft.Graph 2.x | Module needs PS7 | Use `pwsh` (already installed at `C:\Users\\AppData\Local\Microsoft\WindowsApps\pwsh.exe`). | + +## Files in this repo + +- `scripts/01-provision-agentic-user.ps1` — creates the `microsoft.graph.agentUser` parented to the Agent Identity. +- `scripts/02-grant-agentic-user-consent.ps1` — grants delegated `User.Read` for AllPrincipals. +- `scripts/03-test-token-chain.ps1` — proves the FIC chain end-to-end in pure PowerShell. +- `backend/auid_flow.py` — Python implementation of the FIC chain (recipe 03.01–03.04). +- `backend/app.py` — FastAPI broker exposing each step + a one-shot `/api/call-weather` endpoint. +- `weather-agent/app.py` — Downstream service that validates the AUID token and returns weather. +- `ui/index.html` — Single-file UI matching the OBO AKS demo's look-and-feel. + +## Customer hand-off checklist + +- [ ] Customer ran `scripts/00-preflight-check.ps1` and **all rows are PASS** (warnings may be acceptable — review with them). +- [ ] Tenant ID, Blueprint App ID, Agent Identity App ID confirmed with customer. +- [ ] Blueprint SP holds `AgentIdUser.ReadWrite.IdentityParentedBy` (app role) — preflight section D verifies. +- [ ] Blueprint client secret minted and pasted into `.env` — preflight section E verifies. +- [ ] Agent Identity has a FIC trusting the Blueprint — preflight section G verifies. +- [ ] `scripts/03-test-token-chain.ps1` prints the success banner. +- [ ] Local UI at `http://localhost:7001` shows green PASS rows and weather response. +- [ ] Customer understands the **OBO vs AUID** comparison (table in `README.md`). +- [ ] Customer reviewed the **token verification caveat** in `README.md` and chose either claim-only validation or the dedicated Weather Agent app registration. +- [ ] Customer has a copy of `PERMISSIONS.md` for ongoing reference. diff --git a/deploy/azure/kubernetes-service/auid/.env.example b/deploy/azure/kubernetes-service/auid/.env.example new file mode 100644 index 0000000..e5b9151 --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/.env.example @@ -0,0 +1,32 @@ +# AUID Experiment — environment template +# Copy to .env and fill in. Do NOT commit .env. + +# === Tenant === +TENANT_ID= + +# === Blueprint + Agent Identity (from your Entra Agent ID setup) === +# Reuse from your existing OBO/Autonomous Agent ID demo, or create new in Entra portal. +BLUEPRINT_APP_ID= +AGENT_IDENTITY_APP_ID= + +# === Blueprint client secret (needed for step 03.01 of the FIC chain) === +BLUEPRINT_CLIENT_SECRET= + +# === Agentic User (filled in by 01-provision-agentic-user.ps1) === +AGENT_USER_UPN= +AGENT_USER_OBJECT_ID= +AGENT_USER_MAIL_NICKNAME=digitalworker01 + +# === Optional: dedicated Weather Agent app registration === +# If set, AUID step 03.03 targets this audience and the Weather Agent fully +# verifies the JWT signature against v2 JWKS. If left blank, the demo falls +# back to scope=https://graph.microsoft.com/.default and the Weather Agent +# performs strict claim-only validation (Graph access tokens carry a nonce +# in the JWT header that prevents third-party signature verification). +WEATHER_AGENT_APP_ID= +WEATHER_AGENT_SCOPE=Weather.Read + +# === Local ports === +BACKEND_PORT=7100 +WEATHER_AGENT_PORT=7200 +UI_PORT=7000 diff --git a/deploy/azure/kubernetes-service/auid/.gitignore b/deploy/azure/kubernetes-service/auid/.gitignore new file mode 100644 index 0000000..110119c --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/.gitignore @@ -0,0 +1,24 @@ +# --- secrets / runtime --- +.env +.token +.bpsecret +.auid +.devicecode +.tmp + +# --- captured references --- +.aks-ui.html + +# --- python --- +.venv/ +__pycache__/ +*.pyc +*.pyo + +# --- editors --- +.vscode/ +.idea/ + +# --- OS --- +.DS_Store +Thumbs.db diff --git a/deploy/azure/kubernetes-service/auid/README.md b/deploy/azure/kubernetes-service/auid/README.md new file mode 100644 index 0000000..f9d00f9 --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/README.md @@ -0,0 +1,160 @@ +# AgentUserID using Entra SDK — AKS-ready local demo + +End-to-end working sample for **Agent ID User (AUID)** on Microsoft Entra Agent ID. An *agent* mints its own user-shaped access token (`idtyp=user`, `@odata.type=#microsoft.graph.agentUser`) via the Blueprint + Agent Identity FIC chain — **with no human in the loop** — and calls a downstream service that validates the token as a first-class identity. + +> **OBO vs AUID at a glance** +> +> | | OBO (the existing AKS demo) | **AUID (this repo)** | +> |--|--|--| +> | Caller identity | A human user who signed in | A **digital colleague** (Agentic User) | +> | Token `idtyp` | `user` (human) | `user` (Agentic User) | +> | Token `sub`/`oid` | Human user object | `microsoft.graph.agentUser` object | +> | Human in the loop? | Yes — MSAL sign-in | **No** — agent acts as itself | +> | Use case | Agent acts *on behalf of* a person | Agent is *its own* identity, owns artifacts, has its own permissions | + +This repo deliberately mirrors the look-and-feel of the OBO AKS demo so customers can see the two patterns side by side. + +--- + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Browser UI (http://localhost:7001) │ +│ ─ left: Powered by Microsoft Entra Agent ID │ +│ ─ right: Agent Identity Flow (token chain trace) │ +└────────────────────────────┬─────────────────────────────────────┘ + │ POST /api/call-weather + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ backend/ FastAPI (:7100) — the AUID **broker** │ +│ │ +│ 03.01 Blueprint FIC │ +│ POST /oauth2/v2.0/token │ +│ Basic │ +│ client_credentials fmi_path= │ +│ │ +│ 03.02 Agent ID FIC │ +│ jwt-bearer with Blueprint FIC as client_assertion │ +│ │ +│ 03.03 AUID access token (multipart/form-data) │ +│ grant_type=user_fic │ +│ requested_token_use=on_behalf_of │ +│ scope=https://graph.microsoft.com/.default │ +│ username= │ +│ + both FICs │ +│ │ +│ → Authorization: Bearer ───────────────────────────┐ │ +└───────────────────────────────────────────────────────────── │ ──┘ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ weather-agent/ FastAPI (:7200) — downstream AUID-validating API│ +│ │ +│ Verifies iss / tid / aud / appid / idtyp=user / exp │ +│ Calls Open-Meteo for real weather data │ +│ Returns weather + Agentic User claims it identified │ +└──────────────────────────────────────────────────────────────────┘ +``` + +The full FIC chain is the official AUID recipe (`03.01 → 03.02 → 03.03`) — same as `Connect_3P_agent_to_AgentID_using_HTTPs` but expressed as Python instead of raw Insomnia HTTP recipes, plus a downstream service that demonstrates what a Weather/CRM/HR/etc. API would do when it receives an AUID token. + +--- + +## Prerequisites + +- An Entra tenant where you can: + - register applications, + - grant admin consent, + - create users. +- A **Blueprint** app registration and **Agent Identity** app registration. If you don't have one yet, follow the OBO/Autonomous Agent ID demo first — this AUID demo deliberately reuses the same Blueprint + Agent Identity. +- A **Blueprint client secret** with the **Graph application permission** `AgentIdUser.ReadWrite.IdentityParentedBy` granted admin consent. (The provisioning script can mint a secret for you.) +- Python 3.10+ and PowerShell 7 (`pwsh`). + +--- + +## Quick start + +```powershell +# 1. Clone & configure +git clone https://github.com/vj926/AgentUserID-using-EntraSDK-Deploy-using-AKS.git +cd AgentUserID-using-EntraSDK-Deploy-using-AKS +Copy-Item .env.example .env +# Fill TENANT_ID, BLUEPRINT_APP_ID, AGENT_IDENTITY_APP_ID, BLUEPRINT_CLIENT_SECRET + +# 2. PREFLIGHT — verify every required permission/scope/app-role/secret/FIC +# Reports PASS/FAIL/WARN per row, exits non-zero on FAIL. DO NOT SKIP. +pwsh ./scripts/00-preflight-check.ps1 +# See .claude/skills/deploy-auid-demo/PERMISSIONS.md for the full reference. + +# 3. Create the Agentic User (microsoft.graph.agentUser) parented to your Agent Identity +pwsh ./scripts/01-provision-agentic-user.ps1 + +# 4. Grant the Agentic User delegated Graph permissions (User.Read) for AllPrincipals +pwsh ./scripts/02-grant-agentic-user-consent.ps1 + +# 5. Sanity-check the FIC chain end-to-end in PowerShell (no Python yet) +pwsh ./scripts/03-test-token-chain.ps1 +# Expect: 🎉 Full AUID token chain works end-to-end. + +# 6. Run the demo stack +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r backend/requirements.txt -r weather-agent/requirements.txt + +# Terminal 1: AUID broker +python -m uvicorn backend.app:app --host 127.0.0.1 --port 7100 + +# Terminal 2: Downstream Weather Agent +python -m uvicorn weather-agent.app:app --host 127.0.0.1 --port 7200 + +# Terminal 3: UI +python -m http.server 7001 --directory ui + +# Open http://localhost:7001 and ask "What is the weather in Dallas?" +``` + +--- + +## What the UI shows + +- **Left panel** — chat with a static **Acting as** badge showing the Agentic User UPN (no human sign-in, by design — that's the whole point of AUID). +- **Right panel** — live trace of each step in the FIC chain with the decoded JWT claims, ending in a green PASS row block when the Weather Agent validates the token. Mirrors the OBO demo's debug panel. + +--- + +## Repository layout + +``` +├── backend/ FastAPI AUID broker (FIC chain in Python) +│ ├── app.py /api/step/01..03, /api/chain, /api/call-weather +│ └── auid_flow.py Pure-Python implementation of recipe 03.01–03.04 +├── weather-agent/ Downstream AUID-validating API +│ └── app.py Verifies AUID, returns weather + claims +├── ui/ Single-file HTML UI matching the OBO AKS demo +│ └── index.html +├── scripts/ PowerShell helpers +│ ├── 01-provision-agentic-user.ps1 +│ ├── 02-grant-agentic-user-consent.ps1 +│ └── 03-test-token-chain.ps1 +└── .env.example +``` + +--- + +## Token verification caveat (and the "do it properly" path) + +The default AUID chain in this repo requests `scope=https://graph.microsoft.com/.default`, so the issued token carries `aud=https://graph.microsoft.com`. Microsoft Graph access tokens include a special `nonce` claim in the JWT header that makes their signature only verifiable by Graph itself — third-party services cannot cryptographically verify them. The `weather-agent` therefore performs **strict claim-based validation** (`iss`, `tid`, `aud`, `appid`, `idtyp=user`, `exp`) without crypto signature verification. + +For a production-grade pattern, register the downstream service as its own Entra app with an exposed scope (e.g., `Weather.Read`), grant the Agentic User delegated consent on it, set `WEATHER_AGENT_APP_ID=` in `.env`, and the broker will request `scope=api:///.default`. The Weather Agent will then verify the token signature against the v2 JWKS endpoint normally. + +--- + +## AKS deployment (parity with the OBO demo) + +The included `k8s/` folder (coming next) provides a Helm chart mirroring the structure of the OBO AKS demo, so the same Blueprint + Agent Identity can host both demos on a single cluster. For now, the local stack is sufficient to demonstrate the AUID flow end-to-end to customers. + +--- + +## License + +MIT diff --git a/deploy/azure/kubernetes-service/auid/backend/app.py b/deploy/azure/kubernetes-service/auid/backend/app.py new file mode 100644 index 0000000..32d3491 --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/backend/app.py @@ -0,0 +1,149 @@ +""" +backend/app.py — FastAPI broker that walks the AUID token chain and calls the Weather Agent. +""" +from __future__ import annotations + +import os +from dataclasses import asdict +from typing import Dict + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from auid_flow import ( + AuidConfig, full_chain, + step_blueprint_fic, step_agent_id_fic, step_agentic_user_token, call_graph_me, +) + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +app = FastAPI(title="AUID Experiment — Token Broker") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +WEATHER_AGENT_URL = os.getenv("WEATHER_AGENT_URL", f"http://localhost:{os.getenv('WEATHER_AGENT_PORT', '7200')}") + + +def _cfg() -> AuidConfig: + try: + return AuidConfig.from_env() + except RuntimeError as e: + raise HTTPException(500, str(e)) + + +def _step_to_dict(s) -> Dict: + return { + "step": s.step, + "description": s.description, + "request": s.request, + "response": s.response, + "claims": s.claims, + "token_preview": (s.token[:30] + f"...({len(s.token)} chars)") if s.token else None, + } + + +@app.get("/api/health") +async def health(): + weather_url = os.getenv("WEATHER_AGENT_URL", "http://localhost:7200") + weather_status = "offline" + try: + async with httpx.AsyncClient(timeout=2.0) as client: + r = await client.get(f"{weather_url}/health") + if r.status_code == 200: + weather_status = "online" + except Exception: + pass + return { + "ok": True, + "config_loaded": bool(os.getenv("TENANT_ID")), + "weather_agent": weather_status, + } + + +@app.get("/api/config") +async def config(): + cfg = _cfg() + return { + "tenant_id": cfg.tenant_id, + "blueprint_app_id": cfg.blueprint_app_id, + "agent_identity_app_id": cfg.agent_identity_app_id, + "agent_user_upn": cfg.agent_user_upn, + "weather_agent_url": WEATHER_AGENT_URL, + } + + +@app.post("/api/step/01-blueprint-fic") +async def s1(): + return _step_to_dict(await step_blueprint_fic(_cfg())) + + +@app.post("/api/step/02-agentid-fic") +async def s2(): + cfg = _cfg() + bp = await step_blueprint_fic(cfg) + res = await step_agent_id_fic(cfg, bp.token) + return _step_to_dict(res) + + +@app.post("/api/step/03-auid-token") +async def s3(): + cfg = _cfg() + bp = await step_blueprint_fic(cfg) + ag = await step_agent_id_fic(cfg, bp.token) + res = await step_agentic_user_token(cfg, bp.token, ag.token) + # Return the full AUID token so the UI can pass it to the weather agent + return {**_step_to_dict(res), "token": res.token} + + +@app.post("/api/step/04-call-me") +async def s4(): + cfg = _cfg() + bp = await step_blueprint_fic(cfg) + ag = await step_agent_id_fic(cfg, bp.token) + auid = await step_agentic_user_token(cfg, bp.token, ag.token) + res = await call_graph_me(auid.token) + return _step_to_dict(res) + + +@app.get("/api/chain") +async def chain(): + """Walk the whole chain in one call (Graph /me at the end).""" + cfg = _cfg() + res = await full_chain(cfg) + return {k: _step_to_dict(v) for k, v in res.items()} + + +@app.post("/api/call-weather") +async def call_weather(payload: dict): + """Use AUID token to call the Weather Agent for a given city.""" + cfg = _cfg() + city = payload.get("city", "Dallas") + # Mint a fresh AUID token + bp = await step_blueprint_fic(cfg) + ag = await step_agent_id_fic(cfg, bp.token) + auid = await step_agentic_user_token(cfg, bp.token, ag.token) + # Call weather agent with Bearer = AUID token + url = f"{WEATHER_AGENT_URL}/weather" + async with httpx.AsyncClient(timeout=30) as c: + r = await c.get(url, params={"city": city}, headers={"Authorization": f"Bearer {auid.token}"}) + return { + "auid_step": _step_to_dict(auid), + "weather_call": { + "url": url, + "params": {"city": city}, + "status": r.status_code, + "body": r.json() if "application/json" in r.headers.get("content-type", "") else r.text, + }, + } + + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("BACKEND_PORT", "7100")) + uvicorn.run(app, host="127.0.0.1", port=port) diff --git a/deploy/azure/kubernetes-service/auid/backend/auid_flow.py b/deploy/azure/kubernetes-service/auid/backend/auid_flow.py new file mode 100644 index 0000000..3be8167 --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/backend/auid_flow.py @@ -0,0 +1,179 @@ +""" +auid_flow.py — pure-Python implementation of the AUID FIC token chain. +Lifted from Connect_3P_agent_to_AgentID_using_HTTPs README steps 03.01–03.04. +""" +from __future__ import annotations + +import base64 +import json +import os +from dataclasses import dataclass, field +from typing import Any, Dict + +import httpx + + +@dataclass +class AuidConfig: + tenant_id: str + blueprint_app_id: str + agent_identity_app_id: str + blueprint_client_secret: str + agent_user_upn: str + + @property + def token_url(self) -> str: + return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" + + @classmethod + def from_env(cls) -> "AuidConfig": + required = [ + "TENANT_ID", "BLUEPRINT_APP_ID", "AGENT_IDENTITY_APP_ID", + "BLUEPRINT_CLIENT_SECRET", "AGENT_USER_UPN", + ] + missing = [k for k in required if not os.getenv(k)] + if missing: + raise RuntimeError(f"Missing env vars: {missing}") + return cls( + tenant_id=os.environ["TENANT_ID"], + blueprint_app_id=os.environ["BLUEPRINT_APP_ID"], + agent_identity_app_id=os.environ["AGENT_IDENTITY_APP_ID"], + blueprint_client_secret=os.environ["BLUEPRINT_CLIENT_SECRET"], + agent_user_upn=os.environ["AGENT_USER_UPN"], + ) + + +@dataclass +class StepResult: + step: str + description: str + request: Dict[str, Any] + response: Dict[str, Any] + token: str | None = None + claims: Dict[str, Any] = field(default_factory=dict) + + +def _decode_jwt_claims(jwt: str) -> Dict[str, Any]: + parts = jwt.split(".") + if len(parts) < 2: + return {} + pad = parts[1] + "=" * (-len(parts[1]) % 4) + raw = base64.urlsafe_b64decode(pad.encode("ascii")) + return json.loads(raw.decode("utf-8")) + + +def _safe_response(resp: httpx.Response) -> Dict[str, Any]: + try: + data = resp.json() + except Exception: + data = {"_raw": resp.text} + if isinstance(data, dict) and "access_token" in data: + # Don't dump full token in the response inspector view + tok = data["access_token"] + data = {**data, "access_token": f"{tok[:20]}...({len(tok)} chars)"} + return {"status": resp.status_code, "body": data} + + +async def step_blueprint_fic(cfg: AuidConfig) -> StepResult: + """Recipe 03.01: Blueprint app authenticates with secret + fmi_path = AgentID.""" + basic = base64.b64encode(f"{cfg.blueprint_app_id}:{cfg.blueprint_client_secret}".encode()).decode() + headers = {"Authorization": f"Basic {basic}", "Content-Type": "application/x-www-form-urlencoded"} + body = { + "scope": "api://AzureADTokenExchange/.default", + "grant_type": "client_credentials", + "fmi_path": cfg.agent_identity_app_id, + } + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(cfg.token_url, headers=headers, data=body) + r.raise_for_status() + tok = r.json()["access_token"] + return StepResult( + step="03.01", + description="Blueprint FIC token (client_credentials + fmi_path = AgentID)", + request={"url": cfg.token_url, "headers": {"Authorization": "Basic "}, "body": body}, + response=_safe_response(r), + token=tok, + claims=_decode_jwt_claims(tok), + ) + + +async def step_agent_id_fic(cfg: AuidConfig, blueprint_fic: str) -> StepResult: + """Recipe 03.02: Agent ID FIC, using Blueprint FIC as client_assertion.""" + body = { + "client_id": cfg.agent_identity_app_id, + "scope": "api://AzureADTokenExchange/.default", + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": blueprint_fic, + } + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(cfg.token_url, data=body) + r.raise_for_status() + tok = r.json()["access_token"] + safe_body = {**body, "client_assertion": f"{blueprint_fic[:20]}...({len(blueprint_fic)} chars)"} + return StepResult( + step="03.02", + description="Agent ID FIC token (jwt-bearer with Blueprint FIC as assertion)", + request={"url": cfg.token_url, "body": safe_body}, + response=_safe_response(r), + token=tok, + claims=_decode_jwt_claims(tok), + ) + + +async def step_agentic_user_token( + cfg: AuidConfig, + blueprint_fic: str, + agent_id_fic: str, + scope: str = "https://graph.microsoft.com/.default", +) -> StepResult: + """Recipe 03.03: Agentic User access token via grant_type=user_fic (multipart).""" + fields = { + "client_id": cfg.agent_identity_app_id, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": blueprint_fic, + "grant_type": "user_fic", + "requested_token_use": "on_behalf_of", + "scope": scope, + "username": cfg.agent_user_upn, + "user_federated_identity_credential": agent_id_fic, + } + files = {k: (None, v) for k, v in fields.items()} + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(cfg.token_url, files=files) + r.raise_for_status() + tok = r.json()["access_token"] + safe_fields = { + **fields, + "client_assertion": f"{blueprint_fic[:20]}...({len(blueprint_fic)} chars)", + "user_federated_identity_credential": f"{agent_id_fic[:20]}...({len(agent_id_fic)} chars)", + } + return StepResult( + step="03.03", + description="Agentic User access token (grant_type=user_fic, multipart)", + request={"url": cfg.token_url, "form": safe_fields}, + response=_safe_response(r), + token=tok, + claims=_decode_jwt_claims(tok), + ) + + +async def call_graph_me(auid_token: str) -> StepResult: + """Recipe 03.04: GET /me as the Agentic User.""" + url = "https://graph.microsoft.com/v1.0/me" + async with httpx.AsyncClient(timeout=30) as c: + r = await c.get(url, headers={"Authorization": f"Bearer {auid_token}"}) + return StepResult( + step="03.04", + description="Call Graph /me as Agentic User", + request={"url": url, "headers": {"Authorization": "Bearer "}}, + response=_safe_response(r), + ) + + +async def full_chain(cfg: AuidConfig) -> Dict[str, StepResult]: + s1 = await step_blueprint_fic(cfg) + s2 = await step_agent_id_fic(cfg, s1.token) + s3 = await step_agentic_user_token(cfg, s1.token, s2.token) + s4 = await call_graph_me(s3.token) + return {"03.01": s1, "03.02": s2, "03.03": s3, "03.04": s4} diff --git a/deploy/azure/kubernetes-service/auid/backend/requirements.txt b/deploy/azure/kubernetes-service/auid/backend/requirements.txt new file mode 100644 index 0000000..f2ccda0 --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +python-dotenv==1.0.1 diff --git a/deploy/azure/kubernetes-service/auid/scripts/00-preflight-check.ps1 b/deploy/azure/kubernetes-service/auid/scripts/00-preflight-check.ps1 new file mode 100644 index 0000000..521e44e --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/scripts/00-preflight-check.ps1 @@ -0,0 +1,293 @@ +# 00-preflight-check.ps1 +# +# Programmatic preflight that verifies the tenant is ready for the AUID flow. +# Run this BEFORE 01-provision-agentic-user.ps1. The script reports each +# required permission/scope/role as PASS / FAIL / WARN with remediation hints, +# and exits non-zero if anything is FAIL so CI/automation can gate on it. +# +# What it checks (in order): +# [A] .env values are present and non-empty +# [B] Admin can sign in (delegated) with the scopes needed to inspect tenant +# [C] Blueprint app + Blueprint SP exist +# [D] Blueprint SP has Graph app role AgentIdUser.ReadWrite.IdentityParentedBy +# (roleId 4aa6e624-eee0-40ab-bdd8-f9639038a614) assigned + admin consented +# [E] Blueprint app has at least one non-expired client secret +# [F] Agent Identity app + SP exist +# [G] Agent Identity app has a Federated Identity Credential trusting Blueprint +# [H] Microsoft Graph SP exists in tenant (it always should — sanity) +# [I] (Optional, after provisioning) Agentic User has User.Read for AllPrincipals +# +# Usage: +# pwsh ./scripts/00-preflight-check.ps1 +# pwsh ./scripts/00-preflight-check.ps1 -SkipAgenticUser # before provisioning + +[CmdletBinding()] +param( + [string]$EnvFile = "$PSScriptRoot/../.env", + [switch]$SkipAgenticUser +) + +$ErrorActionPreference = 'Stop' +$script:FAILS = 0 +$script:WARNS = 0 + +function Write-Result { + param([string]$Label, [string]$Status, [string]$Detail = "") + $color = switch ($Status) { + "PASS" { "Green" } + "FAIL" { "Red"; $script:FAILS++ } + "WARN" { "Yellow"; $script:WARNS++ } + default { "Gray" } + } + Write-Host (" [{0,-4}] " -f $Status) -ForegroundColor $color -NoNewline + Write-Host $Label -NoNewline + if ($Detail) { Write-Host " - $Detail" -ForegroundColor DarkGray } else { Write-Host "" } +} +function Section($t) { Write-Host ""; Write-Host "=== $t ===" -ForegroundColor Cyan } + +# ---- [A] Load .env ---- +Section "A. Local config (.env)" +if (-not (Test-Path $EnvFile)) { + Write-Result ".env file at $EnvFile" "FAIL" "Copy .env.example to .env and fill in values" + Write-Host "" + Write-Host "Aborting — fix the .env file and rerun." -ForegroundColor Red + exit 1 +} +$envv = @{} +Get-Content $EnvFile | ForEach-Object { + if ($_ -match '^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$') { $envv[$matches[1]] = $matches[2].Trim() } +} +$required = @{ + "TENANT_ID" = "Your Entra tenant ID (GUID)" + "BLUEPRINT_APP_ID" = "appId of the Blueprint app registration" + "AGENT_IDENTITY_APP_ID" = "appId of the Agent Identity app registration" +} +foreach ($k in $required.Keys) { + if ([string]::IsNullOrWhiteSpace($envv[$k])) { + Write-Result "$k present" "FAIL" $required[$k] + } else { + Write-Result "$k = $($envv[$k])" "PASS" + } +} +if (-not $envv.BLUEPRINT_CLIENT_SECRET) { + Write-Result "BLUEPRINT_CLIENT_SECRET present" "WARN" "Required to run scripts 01 and 03. Mint one in Entra portal or via 01-provision." +} else { + Write-Result "BLUEPRINT_CLIENT_SECRET present" "PASS" "(value masked)" +} + +if ($script:FAILS -gt 0) { + Write-Host "" + Write-Host "Aborting before admin sign-in — fix the .env file and rerun." -ForegroundColor Red + exit 1 +} + +$tenant = $envv.TENANT_ID +$blueprint = $envv.BLUEPRINT_APP_ID +$agentId = $envv.AGENT_IDENTITY_APP_ID + +# ---- [B] Admin sign-in (device code) ---- +Section "B. Admin delegated sign-in" +$clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" # Microsoft Graph PowerShell well-known +$scopes = "Application.ReadWrite.All AppRoleAssignment.ReadWrite.All DelegatedPermissionGrant.ReadWrite.All Directory.Read.All User.Read offline_access" +try { + $dc = Invoke-RestMethod -Method POST "https://login.microsoftonline.com/$tenant/oauth2/v2.0/devicecode" -Body @{client_id=$clientId; scope=$scopes} +} catch { + Write-Result "Reach Entra device-code endpoint" "FAIL" $_.Exception.Message + exit 1 +} +Write-Host "" +Write-Host " Sign in as a Cloud Application Administrator (or higher) of tenant $tenant" -ForegroundColor Yellow +Write-Host " Open: $($dc.verification_uri)" -ForegroundColor Yellow +Write-Host " Code: $($dc.user_code)" -ForegroundColor Yellow +Write-Host "" + +$token = $null +for ($i = 0; $i -lt 180; $i++) { + try { + $r = Invoke-RestMethod -Method POST "https://login.microsoftonline.com/$tenant/oauth2/v2.0/token" -Body @{ + grant_type = "urn:ietf:params:oauth:grant-type:device_code" + client_id = $clientId + device_code = $dc.device_code + } -ErrorAction Stop + $token = $r.access_token + break + } catch { + $err = $null + try { $err = ($_.ErrorDetails.Message | ConvertFrom-Json).error } catch {} + if ($err -eq "authorization_pending") { Start-Sleep -Seconds $dc.interval; continue } + Write-Result "Device-code sign-in" "FAIL" "$err" + exit 1 + } +} +if (-not $token) { Write-Result "Device-code sign-in" "FAIL" "Timed out"; exit 1 } +Write-Result "Admin signed in" "PASS" + +# Decode token, inspect granted scopes +$parts = $token.Split('.') +$pad = $parts[1].Replace('-','+').Replace('_','/'); while ($pad.Length % 4) { $pad += '=' } +$claims = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($pad)) | ConvertFrom-Json +$grantedScopes = $claims.scp -split ' ' + +$needScopes = @( + "Application.ReadWrite.All", + "AppRoleAssignment.ReadWrite.All", + "DelegatedPermissionGrant.ReadWrite.All", + "Directory.Read.All" +) +foreach ($s in $needScopes) { + if ($grantedScopes -contains $s) { + Write-Result "Admin token has scope: $s" "PASS" + } else { + Write-Result "Admin token has scope: $s" "FAIL" "Admin consent missing for this scope on the well-known Graph PowerShell client; ask a Global Admin to consent." + } +} + +$H = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } + +# ---- [C] Blueprint app + SP ---- +Section "C. Blueprint app registration" +$bpApp = $null; $bpSp = $null +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$blueprint'" -Headers $H + $bpApp = $r.value | Select-Object -First 1 +} catch {} +if (-not $bpApp) { + Write-Result "Blueprint app exists (appId=$blueprint)" "FAIL" "App registration not found in tenant $tenant" +} else { + Write-Result "Blueprint app exists (appId=$blueprint)" "PASS" "displayName=$($bpApp.displayName)" +} +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$blueprint'" -Headers $H + $bpSp = $r.value | Select-Object -First 1 +} catch {} +if (-not $bpSp) { + Write-Result "Blueprint service principal exists" "FAIL" "Run: New-MgServicePrincipal -AppId $blueprint, or in portal: Enterprise Apps -> New application -> from app registration" +} else { + Write-Result "Blueprint service principal exists" "PASS" "spId=$($bpSp.id)" +} + +# ---- [D] Blueprint SP has Graph app role AgentIdUser.ReadWrite.IdentityParentedBy ---- +Section "D. Required Graph application permissions on Blueprint SP" +$graphSp = $null +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'" -Headers $H + $graphSp = $r.value | Select-Object -First 1 +} catch {} +if (-not $graphSp) { + Write-Result "Microsoft Graph SP found in tenant" "FAIL" "Highly unusual — this SP is normally always present." +} else { + Write-Result "Microsoft Graph SP found in tenant" "PASS" "spId=$($graphSp.id)" +} + +$requiredAppRoles = @{ + "AgentIdUser.ReadWrite.IdentityParentedBy" = "4aa6e624-eee0-40ab-bdd8-f9639038a614" +} +if ($bpSp -and $graphSp) { + $bpAssignments = (Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/servicePrincipals/$($bpSp.id)/appRoleAssignments" -Headers $H).value + foreach ($roleName in $requiredAppRoles.Keys) { + $roleId = $requiredAppRoles[$roleName] + $hit = $bpAssignments | Where-Object { $_.resourceId -eq $graphSp.id -and $_.appRoleId -eq $roleId } + if ($hit) { + Write-Result "Blueprint SP has Graph app role: $roleName" "PASS" + } else { + Write-Result "Blueprint SP has Graph app role: $roleName" "FAIL" @" +Grant it with: + POST https://graph.microsoft.com/v1.0/servicePrincipals/$($bpSp.id)/appRoleAssignments + { "principalId": "$($bpSp.id)", "resourceId": "$($graphSp.id)", "appRoleId": "$roleId" } +This is the role that allows the Blueprint to create microsoft.graph.agentUser objects. +"@ + } + } +} + +# ---- [E] Blueprint client secret ---- +Section "E. Blueprint client secret" +if ($bpApp) { + $now = Get-Date + $live = @($bpApp.passwordCredentials | Where-Object { $_.endDateTime -and ([DateTime]$_.endDateTime) -gt $now }) + if ($live.Count -eq 0) { + Write-Result "Blueprint has a non-expired client secret" "FAIL" "Mint one in portal (Certificates & secrets) or via Graph: POST /applications/$($bpApp.id)/addPassword" + } else { + $soonest = ($live | Sort-Object endDateTime)[0] + $daysLeft = [int](([DateTime]$soonest.endDateTime) - $now).TotalDays + if ($daysLeft -lt 14) { + Write-Result "Blueprint has a non-expired client secret" "WARN" "$($live.Count) secret(s), soonest expires in $daysLeft day(s) — rotate soon" + } else { + Write-Result "Blueprint has a non-expired client secret" "PASS" "$($live.Count) secret(s), soonest expires in $daysLeft day(s)" + } + } +} + +# ---- [F] Agent Identity app + SP ---- +Section "F. Agent Identity app registration" +$aiApp = $null; $aiSp = $null +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$agentId'" -Headers $H + $aiApp = $r.value | Select-Object -First 1 +} catch {} +if (-not $aiApp) { + Write-Result "Agent Identity app exists (appId=$agentId)" "FAIL" "App registration not found" +} else { + Write-Result "Agent Identity app exists (appId=$agentId)" "PASS" "displayName=$($aiApp.displayName)" +} +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$agentId'" -Headers $H + $aiSp = $r.value | Select-Object -First 1 +} catch {} +if (-not $aiSp) { + Write-Result "Agent Identity service principal exists" "FAIL" "Required so delegated consent can be granted to the Agentic User." +} else { + Write-Result "Agent Identity service principal exists" "PASS" "spId=$($aiSp.id)" +} + +# ---- [G] FIC trusting the Blueprint ---- +Section "G. Federated Identity Credential on Agent Identity" +if ($aiApp) { + $fics = @() + try { $fics = (Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/applications/$($aiApp.id)/federatedIdentityCredentials" -Headers $H).value } catch {} + if (-not $fics -or $fics.Count -eq 0) { + Write-Result "Agent Identity has a Federated Identity Credential" "FAIL" "Required for step 03.02 (jwt-bearer with Blueprint FIC). Add a FIC on the Agent Identity app trusting the Blueprint as issuer." + } else { + Write-Result "Agent Identity has $($fics.Count) FIC(s)" "PASS" ("subjects: " + (($fics | ForEach-Object subject) -join ", ")) + } +} + +# ---- [I] Agentic User delegated consent (only if AGENT_USER_OBJECT_ID is set) ---- +if (-not $SkipAgenticUser -and $envv.AGENT_USER_OBJECT_ID) { + Section "I. Agentic User delegated Graph permissions" + if ($aiSp -and $graphSp) { + $grants = @() + try { + $grants = (Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$($aiSp.id)' and resourceId eq '$($graphSp.id)'" -Headers $H).value + } catch {} + $allPrincipalsGrant = $grants | Where-Object { $_.consentType -eq "AllPrincipals" } | Select-Object -First 1 + if (-not $allPrincipalsGrant) { + Write-Result "Agent Identity SP -> Graph oauth2PermissionGrant (AllPrincipals)" "FAIL" "Run scripts/02-grant-agentic-user-consent.ps1 — this is the consent that lets the Agentic User call Graph." + } else { + $scp = $allPrincipalsGrant.scope + Write-Result "Agent Identity SP -> Graph oauth2PermissionGrant (AllPrincipals)" "PASS" "scope=$scp" + if ($scp -notmatch "\bUser\.Read\b") { + Write-Result "Granted scopes include User.Read" "WARN" "Add User.Read so the Agentic User can call /me" + } else { + Write-Result "Granted scopes include User.Read" "PASS" + } + } + } +} else { + Section "I. Agentic User delegated Graph permissions" + Write-Result "Agentic User check skipped" "WARN" "Either -SkipAgenticUser was passed, or AGENT_USER_OBJECT_ID is empty (Agentic User not yet provisioned)." +} + +# ---- Summary ---- +Section "Summary" +if ($script:FAILS -gt 0) { + Write-Host " $script:FAILS check(s) FAILED, $script:WARNS warning(s)." -ForegroundColor Red + Write-Host " Fix the failures above before running scripts/01-provision-agentic-user.ps1." -ForegroundColor Red + exit 1 +} elseif ($script:WARNS -gt 0) { + Write-Host " All checks PASSED with $script:WARNS warning(s) — review above." -ForegroundColor Yellow + exit 0 +} else { + Write-Host " All checks PASSED. You're ready to provision the Agentic User." -ForegroundColor Green + exit 0 +} diff --git a/deploy/azure/kubernetes-service/auid/scripts/01-provision-agentic-user.ps1 b/deploy/azure/kubernetes-service/auid/scripts/01-provision-agentic-user.ps1 new file mode 100644 index 0000000..058d385 --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/scripts/01-provision-agentic-user.ps1 @@ -0,0 +1,107 @@ +# 01-provision-agentic-user.ps1 +# Creates an Agentic User attached to an existing Blueprint + Agent Identity. +# Lifts from: Connect_3P_agent_to_AgentID_using_HTTPs README step 02.02 +# +# Usage: +# ./01-provision-agentic-user.ps1 ` +# -TenantId "98430660-2a7e-4e6b-b49c-800a8ba8b657" ` +# -BlueprintAppId "4f6ca43e-337c-4617-958f-e517cf1a1858" ` +# -AgentIdentityAppId "2b32c2c2-3a5a-435e-b07e-8ef20564364f" ` +# -AgentUserDisplayName "[ai] Digital Worker 01 Agent ID User" ` +# -AgentUserMailNickname "digitalworker01" +# +# Requires: Microsoft.Graph module + AgentIdentity.ReadWrite.All / User.ReadWrite.All / Application.ReadWrite.All + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$TenantId, + [Parameter(Mandatory=$true)][string]$BlueprintAppId, + [Parameter(Mandatory=$true)][string]$AgentIdentityAppId, + [string]$AgentUserDisplayName = "[ai] Digital Worker 01 Agent ID User", + [string]$AgentUserMailNickname = "digitalworker01", + [string]$EnvFile = "$PSScriptRoot/../.env" +) + +$ErrorActionPreference = 'Stop' + +# ---- Connect ---- +if (-not (Get-Module -ListAvailable Microsoft.Graph.Authentication)) { + throw "Microsoft.Graph module not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser" +} +Import-Module Microsoft.Graph.Authentication +Write-Host "Connecting to tenant $TenantId..." +Connect-MgGraph -TenantId $TenantId -UseDeviceCode -Scopes @( + "Application.ReadWrite.All", + "User.ReadWrite.All", + "AgentIdentity.ReadWrite.All", + "Directory.ReadWrite.All" +) -NoWelcome | Out-Null + +# ---- Resolve tenant verified domain (UPN needs to live on a verified domain) ---- +$org = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/organization?`$select=verifiedDomains" +$initialDomain = ($org.value[0].verifiedDomains | Where-Object { $_.isInitial -eq $true }).name +if (-not $initialDomain) { throw "Could not resolve initial verified domain for tenant." } +$upn = "$AgentUserMailNickname@$initialDomain" +Write-Host "Agentic User UPN will be: $upn" + +# ---- Resolve Agent Identity client ID (== appId; treat both as same) ---- +# The Agent Identity is the service principal of @odata.type microsoft.graph.agentIdentity. +# identityParentId on the agentUser must reference the Agent Identity *clientId* (== appId). +$agentIdentityClientId = $AgentIdentityAppId +Write-Host "Agent Identity clientId: $agentIdentityClientId" + +# ---- Check if Agentic User already exists ---- +$existing = $null +try { + $existing = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/users/$upn" +} catch { + if ($_.Exception.Message -notmatch '404|Request_ResourceNotFound|NotFound') { throw } +} +if ($existing) { + Write-Host "✅ Agentic User already exists: id=$($existing.id) upn=$($existing.userPrincipalName)" -ForegroundColor Green + $userId = $existing.id +} else { + Write-Host "Creating Agentic User..." + $body = @{ + '@odata.type' = 'microsoft.graph.agentUser' + displayName = $AgentUserDisplayName + userPrincipalName = $upn + mailNickname = $AgentUserMailNickname + accountEnabled = $true + identityParentId = $agentIdentityClientId + } | ConvertTo-Json -Depth 5 + $created = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/users" ` + -ContentType 'application/json' -Body $body + Write-Host "✅ Created Agentic User: id=$($created.id) upn=$($created.userPrincipalName)" -ForegroundColor Green + $userId = $created.id +} + +# ---- Read-back verification ---- +$verify = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/users/$userId`?`$select=id,displayName,userPrincipalName,identityParentId" +$verify | ConvertTo-Json -Depth 5 + +# ---- Persist to .env (idempotent) ---- +function Set-EnvKey { + param($Path, $Key, $Value) + if (-not (Test-Path $Path)) { + Copy-Item "$PSScriptRoot/../.env.example" $Path -Force + } + $lines = Get-Content $Path + if ($lines -match "^$Key=") { + $lines = $lines -replace "^$Key=.*","$Key=$Value" + } else { + $lines += "$Key=$Value" + } + Set-Content -Path $Path -Value $lines -Encoding UTF8 +} + +Set-EnvKey -Path $EnvFile -Key "AGENT_USER_UPN" -Value $upn +Set-EnvKey -Path $EnvFile -Key "AGENT_USER_OBJECT_ID" -Value $userId +Set-EnvKey -Path $EnvFile -Key "AGENT_USER_MAIL_NICKNAME" -Value $AgentUserMailNickname + +Write-Host "" +Write-Host "Wrote to ${EnvFile}:" +Write-Host " AGENT_USER_UPN=$upn" +Write-Host " AGENT_USER_OBJECT_ID=$userId" +Write-Host "" +Write-Host "Next: run ./02-grant-agentic-user-consent.ps1 to grant Graph permissions to this Agentic User." -ForegroundColor Cyan diff --git a/deploy/azure/kubernetes-service/auid/scripts/02-grant-agentic-user-consent.ps1 b/deploy/azure/kubernetes-service/auid/scripts/02-grant-agentic-user-consent.ps1 new file mode 100644 index 0000000..b80c2b2 --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/scripts/02-grant-agentic-user-consent.ps1 @@ -0,0 +1,27 @@ +# 02-grant-agentic-user-consent.ps1 +# Prints the admin-consent URL for the Agent Identity, scoped to the permissions the Agentic User will exercise. +# Lifts from: Connect_3P_agent_to_AgentID_using_HTTPs README step 02.03 +# +# Usage: +# ./02-grant-agentic-user-consent.ps1 ` +# -TenantId "98430660-2a7e-4e6b-b49c-800a8ba8b657" ` +# -AgentIdentityAppId "2b32c2c2-3a5a-435e-b07e-8ef20564364f" + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$TenantId, + [Parameter(Mandatory=$true)][string]$AgentIdentityAppId, + [string]$Scopes = "User.Read groupmember.read.all Chat.ReadWrite Calendars.ReadWrite Mail.ReadWrite Contacts.Read People.Read", + [string]$RedirectUri = "https://entra.microsoft.com/TokenAuthorize" +) + +$scopesEncoded = [System.Web.HttpUtility]::UrlEncode($Scopes) +$redirectEncoded = [System.Web.HttpUtility]::UrlEncode($RedirectUri) +$url = "https://login.microsoftonline.com/$TenantId/v2.0/adminconsent?client_id=$AgentIdentityAppId&scope=$scopesEncoded&redirect_uri=$redirectEncoded&state=auid-experiment" + +Write-Host "" +Write-Host "Open this URL in a browser, sign in as Cloud App Admin (or higher), and click 'Accept':" -ForegroundColor Cyan +Write-Host "" +Write-Host $url -ForegroundColor Yellow +Write-Host "" +Write-Host "After consent succeeds, run ./03-test-token-chain.ps1 to verify the AUID token chain works." -ForegroundColor Cyan diff --git a/deploy/azure/kubernetes-service/auid/scripts/03-test-token-chain.ps1 b/deploy/azure/kubernetes-service/auid/scripts/03-test-token-chain.ps1 new file mode 100644 index 0000000..6e9c5aa --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/scripts/03-test-token-chain.ps1 @@ -0,0 +1,127 @@ +# 03-test-token-chain.ps1 +# Walks the full FIC token chain end-to-end with no Python: +# 1. Blueprint FIC token (recipe 03.01) +# 2. Agent ID FIC token (recipe 03.02) +# 3. Agentic User access token (recipe 03.03) +# 4. Call Graph /me as the Agentic User (recipe 03.04) +# +# Reads config from .env (use 01-provision script to populate it first). +# +# Usage: +# ./03-test-token-chain.ps1 +# ./03-test-token-chain.ps1 -BlueprintClientSecret "..." # if not in .env + +[CmdletBinding()] +param( + [string]$EnvFile = "$PSScriptRoot/../.env", + [string]$BlueprintClientSecret +) + +$ErrorActionPreference = 'Stop' + +# ---- Load .env ---- +if (-not (Test-Path $EnvFile)) { throw "Missing $EnvFile. Run 01-provision-agentic-user.ps1 first." } +$env = @{} +Get-Content $EnvFile | ForEach-Object { + if ($_ -match '^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$') { $env[$matches[1]] = $matches[2] } +} +$tenant = $env.TENANT_ID +$blueprint = $env.BLUEPRINT_APP_ID +$agentId = $env.AGENT_IDENTITY_APP_ID +$userUpn = $env.AGENT_USER_UPN +$bpSecret = if ($BlueprintClientSecret) { $BlueprintClientSecret } else { $env.BLUEPRINT_CLIENT_SECRET } + +foreach ($k in 'TENANT_ID','BLUEPRINT_APP_ID','AGENT_IDENTITY_APP_ID','AGENT_USER_UPN') { + if (-not $env[$k]) { throw "Missing $k in $EnvFile" } +} +if (-not $bpSecret) { throw "Missing BLUEPRINT_CLIENT_SECRET (pass -BlueprintClientSecret or set in .env)" } + +$tokenUrl = "https://login.microsoftonline.com/$tenant/oauth2/v2.0/token" +Write-Host "Tenant=$tenant" +Write-Host "Blueprint=$blueprint AgentID=$agentId AgenticUser=$userUpn" +Write-Host "" + +# ---- Step 03.01: Blueprint FIC token (fmi_path = AgentID) ---- +Write-Host "[1/4] Blueprint FIC token..." -ForegroundColor Cyan +$basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${blueprint}:${bpSecret}")) +$resp1 = Invoke-RestMethod -Method POST -Uri $tokenUrl -Headers @{ Authorization = "Basic $basic" } ` + -ContentType 'application/x-www-form-urlencoded' -Body @{ + scope = 'api://AzureADTokenExchange/.default' + grant_type = 'client_credentials' + fmi_path = $agentId + } +$bpFic = $resp1.access_token +if (-not $bpFic) { throw "Step 1 returned no access_token. Response: $($resp1 | ConvertTo-Json -Depth 5)" } +Write-Host " ✅ Blueprint FIC obtained (len=$($bpFic.Length))" -ForegroundColor Green + +# ---- Step 03.02: Agent ID FIC token (client_assertion = BP FIC) ---- +Write-Host "[2/4] Agent ID FIC token..." -ForegroundColor Cyan +$resp2 = Invoke-RestMethod -Method POST -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{ + client_id = $agentId + scope = 'api://AzureADTokenExchange/.default' + grant_type = 'client_credentials' + client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + client_assertion = $bpFic +} +$agFic = $resp2.access_token +if (-not $agFic) { throw "Step 2 returned no access_token. Response: $($resp2 | ConvertTo-Json -Depth 5)" } +Write-Host " ✅ Agent ID FIC obtained (len=$($agFic.Length))" -ForegroundColor Green + +# ---- Step 03.03: Agentic User access token (multipart, grant_type=user_fic) ---- +Write-Host "[3/4] Agentic User access token..." -ForegroundColor Cyan +# Build multipart/form-data manually for max compatibility +$boundary = [Guid]::NewGuid().ToString() +function Add-Part { param($sb, $name, $value) + [void]$sb.AppendLine("--$boundary") + [void]$sb.AppendLine("Content-Disposition: form-data; name=`"$name`"") + [void]$sb.AppendLine() + [void]$sb.AppendLine($value) +} +$sb = New-Object System.Text.StringBuilder +Add-Part $sb 'client_id' $agentId +Add-Part $sb 'client_assertion_type' 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' +Add-Part $sb 'client_assertion' $bpFic +Add-Part $sb 'grant_type' 'user_fic' +Add-Part $sb 'requested_token_use' 'on_behalf_of' +Add-Part $sb 'scope' 'https://graph.microsoft.com/.default' +Add-Part $sb 'username' $userUpn +Add-Part $sb 'user_federated_identity_credential' $agFic +[void]$sb.AppendLine("--$boundary--") +$body = $sb.ToString() +try { + $resp3 = Invoke-RestMethod -Method POST -Uri $tokenUrl -ContentType "multipart/form-data; boundary=$boundary" -Body $body +} catch { + Write-Host " ❌ Step 3 failed. Server response:" -ForegroundColor Red + if ($_.ErrorDetails.Message) { Write-Host $_.ErrorDetails.Message -ForegroundColor Red } + throw +} +$auid = $resp3.access_token +if (-not $auid) { throw "Step 3 returned no access_token. Response: $($resp3 | ConvertTo-Json -Depth 5)" } +Write-Host " ✅ Agentic User token obtained (len=$($auid.Length))" -ForegroundColor Green + +# ---- Decode the AUID token claims (no signature check) ---- +function Decode-JwtPayload { + param([string]$jwt) + $parts = $jwt.Split('.') + $pad = $parts[1].PadRight($parts[1].Length + (4 - $parts[1].Length % 4) % 4, '=') + $bytes = [Convert]::FromBase64String($pad.Replace('-','+').Replace('_','/')) + return [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json +} +$claims = Decode-JwtPayload -jwt $auid +Write-Host " AUID token claims (selected):" -ForegroundColor Cyan +$claims | Select-Object aud, iss, appid, oid, sub, upn, unique_name, scp, idtyp, xms_idrel | Format-List + +# ---- Step 03.04: call Graph /me ---- +Write-Host "[4/4] GET https://graph.microsoft.com/v1.0/me (as Agentic User)..." -ForegroundColor Cyan +try { + $me = Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/v1.0/me" -Headers @{ Authorization = "Bearer $auid" } + Write-Host " ✅ /me returned:" -ForegroundColor Green + $me | ConvertTo-Json -Depth 5 +} catch { + Write-Host " ❌ /me failed. Server response:" -ForegroundColor Red + if ($_.ErrorDetails.Message) { Write-Host $_.ErrorDetails.Message -ForegroundColor Red } + throw +} + +Write-Host "" +Write-Host "🎉 Full AUID token chain works end-to-end." -ForegroundColor Green diff --git a/deploy/azure/kubernetes-service/auid/ui/index.html b/deploy/azure/kubernetes-service/auid/ui/index.html new file mode 100644 index 0000000..42d135f --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/ui/index.html @@ -0,0 +1,509 @@ + + + + + + Powered by Microsoft Entra Agent ID — AUID + + + +
+ +
+
+
+

Powered by Microsoft Entra Agent ID

+
+ +
+ Tenant ID: + Agent ID: +
+ +
+
+
Execution Mode
+ + +
+
+
Identity Flow
+ + +
+
+ + + +
+
Backend
+
Weather Agent
+
Token Endpoint
+
Microsoft Graph
+
+ +
+
+ Hello — I'm the Weather Agent and I require an AUID token to answer. +

+ No human is in this flow. The Agentic User mints + its own access token via the Blueprint → Agent ID → AUID FIC chain, + then calls me. I validate the JWT signature against Entra JWKS, check + appid and idtyp=user, and return data scoped to that Agentic User. +

+ Try: "What is the weather in Dallas?" +
+
+ +
+ + +
+
+ + +
+
+
+

Agent Identity Flow

+
+
+
+
📋 READY
+
Ask a weather question — the full FIC chain (Blueprint → Agent ID → AUID → Weather call) will render here as it runs.
+
+
+
+
+ + + + diff --git a/deploy/azure/kubernetes-service/auid/weather-agent/app.py b/deploy/azure/kubernetes-service/auid/weather-agent/app.py new file mode 100644 index 0000000..789a8be --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/weather-agent/app.py @@ -0,0 +1,131 @@ +""" +weather-agent/app.py — Standalone API that requires an AUID bearer token, +validates its signature against Entra JWKS, and returns weather data +scoped to the calling Agentic User. + +This is the "downstream agent" the customer wants their AUID-bearing agent +to call. It demonstrates that any service can verify "this caller is an +Agentic User (not a human, not an app-only token) under my expected +Agent Identity, and here are its claims". +""" +from __future__ import annotations + +import os +from typing import Any, Dict + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, Header, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware +from jose import jwt +from jose.exceptions import JWTError + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +TENANT_ID = os.environ["TENANT_ID"] +EXPECTED_AGENT_APP_ID = os.environ["AGENT_IDENTITY_APP_ID"] +JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/keys?appid={EXPECTED_AGENT_APP_ID}" +EXPECTED_ISSUER = f"https://sts.windows.net/{TENANT_ID}/" + +app = FastAPI(title="Weather Agent (AUID-validating)") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +_jwks_cache: Dict[str, Any] = {} + + +async def _jwks() -> Dict[str, Any]: + if not _jwks_cache: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get(JWKS_URL) + r.raise_for_status() + _jwks_cache.update(r.json()) + return _jwks_cache + + +async def _validate(token: str) -> Dict[str, Any]: + # NOTE: Microsoft Graph access tokens use a special nonce-rehashing scheme + # in the JWT header that makes their signatures only verifiable by Graph + # itself (not by third-party APIs). For a production AUID flow, the + # downstream service should be its own Entra app registration so the AUID + # token is requested for *its* audience (signature then verifies normally). + # For this demo, we DECODE the JWT and strictly enforce all claim-based + # checks (issuer, tenant, appid, idtyp, exp, aud) — what we forego is + # cryptographic signature verification only. + try: + claims = jwt.get_unverified_claims(token) + except JWTError as e: + raise HTTPException(401, f"Token parse failed: {e}") + + import time + now = int(time.time()) + if claims.get("exp", 0) < now: + raise HTTPException(401, "Token expired") + if claims.get("iss") != EXPECTED_ISSUER: + raise HTTPException(401, f"Unexpected iss {claims.get('iss')}") + if claims.get("tid") != TENANT_ID: + raise HTTPException(401, f"Unexpected tid {claims.get('tid')}") + if claims.get("aud") != "https://graph.microsoft.com": + raise HTTPException(401, f"Unexpected aud {claims.get('aud')}") + if claims.get("appid") != EXPECTED_AGENT_APP_ID: + raise HTTPException(403, f"Unexpected appid {claims.get('appid')}; expected {EXPECTED_AGENT_APP_ID}") + if claims.get("idtyp") != "user": + raise HTTPException(403, f"Expected idtyp=user (AUID), got {claims.get('idtyp')}") + return claims + + +@app.get("/health") +async def health(): + return {"ok": True, "expected_agent_app_id": EXPECTED_AGENT_APP_ID, "issuer": EXPECTED_ISSUER} + + +@app.get("/weather") +async def weather( + city: str = Query("Dallas"), + authorization: str | None = Header(None), +): + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(401, "Missing Bearer token") + token = authorization.split(" ", 1)[1] + claims = await _validate(token) + + # Fetch real weather from Open-Meteo (same source as the AKS demo's weather-api) + geocode_url = "https://geocoding-api.open-meteo.com/v1/search" + async with httpx.AsyncClient(timeout=15) as c: + g = (await c.get(geocode_url, params={"name": city, "count": 1})).json() + if not g.get("results"): + raise HTTPException(404, f"City '{city}' not found") + lat, lon = g["results"][0]["latitude"], g["results"][0]["longitude"] + w = (await c.get( + "https://api.open-meteo.com/v1/forecast", + params={"latitude": lat, "longitude": lon, "current": "temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code"}, + )).json() + cur = w["current"] + + return { + "weather": { + "city": city, + "temperature_c": cur["temperature_2m"], + "humidity_pct": cur["relative_humidity_2m"], + "wind_kph": cur["wind_speed_10m"], + "as_of": cur["time"], + "source": "Open-Meteo", + }, + "auth": { + "flow": "AUID (Agent ID User)", + "agentic_user_upn": claims.get("upn"), + "agentic_user_oid": claims.get("oid"), + "agent_identity_app_id": claims.get("appid"), + "tenant_id": claims.get("tid"), + "idtyp": claims.get("idtyp"), + "iss": claims.get("iss"), + "scopes": claims.get("scp"), + "validated_against": JWKS_URL, + }, + "note": "Token signature validated against Entra JWKS. appid matches expected Agent Identity. idtyp=user confirms an AUID (not app-only).", + } + + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("WEATHER_AGENT_PORT", "7200")) + uvicorn.run(app, host="127.0.0.1", port=port) diff --git a/deploy/azure/kubernetes-service/auid/weather-agent/requirements.txt b/deploy/azure/kubernetes-service/auid/weather-agent/requirements.txt new file mode 100644 index 0000000..0b824bb --- /dev/null +++ b/deploy/azure/kubernetes-service/auid/weather-agent/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +python-dotenv==1.0.1 +python-jose[cryptography]==3.3.0