Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
07974a7
feat: add Python OBO sample for Work IQ
shakir-fattani Jul 16, 2026
2c73508
Potential fix for pull request finding
shakir-fattani Jul 16, 2026
54473b4
fix: resolve remaining Copilot review findings on Python OBO sample
shakir-fattani Jul 16, 2026
ed8f911
fix: address second-round Copilot review findings
shakir-fattani Jul 16, 2026
72df7c2
chore: strip trailing whitespace from bearer token, consolidate error…
shakir-fattani Jul 16, 2026
d2b9409
fix: guard smoke_test with __name__ check, fix README request-id docs
shakir-fattani Jul 16, 2026
b3bdd53
fix: wrap httpx transport errors in WorkIQError for proper 502 handling
shakir-fattani Jul 16, 2026
d51e18d
fix: catch httpx errors during SSE stream iteration
shakir-fattani Jul 16, 2026
c8ff0f4
refactor: simplify chat_stream error handling with single except block
shakir-fattani Jul 16, 2026
0000877
test: add transport error coverage to smoke test
shakir-fattani Jul 16, 2026
a31abfd
docs: add 502 error to troubleshooting table
shakir-fattani Jul 16, 2026
75fecf7
docs: update WorkIQError docstring to cover transport failures
shakir-fattani Jul 16, 2026
3206316
fix: avoid blocking event loop on credential close, guard response in…
shakir-fattani Jul 16, 2026
b6f529e
fix: narrow OBO exception catch, validate conversation_id, catch Deco…
shakir-fattani Jul 16, 2026
caa8acd
chore: add httpx2 dependency to silence starlette deprecation warning
shakir-fattani Jul 16, 2026
72c25c9
fix: validate conversation_id type, add per-request timezone support
shakir-fattani Jul 16, 2026
44d8412
fix: use request.app.state in workiq_token instead of module-level app
shakir-fattani Jul 16, 2026
7fa8598
feat: emit SSE done sentinel on success, document streaming contract
shakir-fattani Jul 16, 2026
ed5cf72
fix: relax time_zone regex to accept UTC, GMT, and Etc/* identifiers
shakir-fattani Jul 16, 2026
6bf5f89
fix: handle non-dict JSON responses from gateway without crashing
shakir-fattani Jul 16, 2026
84bcf0d
test: add edge case coverage for non-dict payloads in _last_text_message
shakir-fattani Jul 16, 2026
e48c42e
fix: JSON-encode SSE conversation event, clarify httpx2 as test dep
shakir-fattani Jul 16, 2026
efd1d95
fix: guard messages:null in _last_text_message, use request.app.state…
shakir-fattani Jul 16, 2026
c1bb1c9
fix: validate conversation_id at client level, cache server timezone
shakir-fattani Jul 16, 2026
caaf05e
fix: guard attributions:null, use consistent .get() for reply text
shakir-fattani Jul 16, 2026
be2f9f4
fix: guard non-dict attribution entries, add time_zone to curl example
shakir-fattani Jul 16, 2026
15cd4ba
fix: address code review findings — security hardening, type correctn…
shakir-fattani Aug 2, 2026
ca43d06
fix: round-2 review — stream error handling, middleware hardening, ti…
shakir-fattani Aug 2, 2026
d332af5
fix: round-3 review — dead guard, chunked test, .env exclusion, log s…
shakir-fattani Aug 2, 2026
7fc44e6
fix: round-4 review — middleware protocol safety, timezone init, cita…
shakir-fattani Aug 2, 2026
9ced8f3
fix: round-5 review — repr safety, ASGI middleware, SSE docs, env sco…
shakir-fattani Aug 2, 2026
05e86ca
fix: enforce https:// scheme on EXTRA_WORKIQ_HOSTS entries
shakir-fattani Aug 2, 2026
5492f35
fix: send 413 when response not yet started, normalize WORKIQ_HOST tr…
shakir-fattani Aug 2, 2026
9dcb48e
fix: deterministic 413 for chunked bodies, correct middleware ordering
shakir-fattani Aug 2, 2026
ab9d112
fix: Python 3.10 compat, middleware comment, SSE done frame, nbf enfo…
shakir-fattani Aug 2, 2026
b376ac1
fix: normalize trailing slash on EXTRA_WORKIQ_HOSTS entries
shakir-fattani Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ Sample clients for the [Work IQ](https://learn.microsoft.com/en-us/microsoft-365
| [**dotnet/a2a/**](dotnet/a2a/) | C# | Windows, macOS, Linux | [A2A](https://a2a-protocol.org) | Interactive agent session using the A2A protocol over JSON-RPC |
| [**dotnet/a2a-raw/**](dotnet/a2a-raw/) | C# | Windows, macOS, Linux | [A2A](https://a2a-protocol.org) | Same, but with raw `HttpClient` + JSON (no A2A SDK) |
| [**dotnet/rest/**](dotnet/rest/) | C# | Windows, macOS, Linux | REST | Interactive chat using the [Copilot Chat API](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/chat/overview) |
| [**python/obo/**](python/obo/) | Python | Windows, macOS, Linux | REST | Backend service brokering Work IQ for a frontend via the [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow) |
| [**rust/a2a/**](rust/a2a/) | Rust | Windows, macOS, Linux | [A2A](https://a2a-protocol.org) | Interactive agent session with MSAL auth and token caching |
| [**swift/a2a/**](swift/a2a/) | Swift | iOS/iPadOS (macOS to build) | [A2A](https://a2a-protocol.org) | SwiftUI chat app for Work IQ |

Every sample is a **public client** that signs a user in directly, except [**python/obo/**](python/obo/) — that one is a **middle tier**: the user signs in to your frontend, and the service exchanges their token for a Work IQ token. It needs a different app registration; see its [README](python/obo/README.md#app-registration).

---

## Gateway
Expand All @@ -24,6 +27,7 @@ All samples target the **Work IQ Gateway** (`workiq.svc.cloud.microsoft`) — th
2. **Entra app registration** configured in your tenant — this is a one-time setup per tenant. Details below.
3. **Your language toolchain**:
- **dotnet/** samples: [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) or later
- **python/** samples: [Python 3.10+](https://www.python.org/downloads/)
- **rust/** samples: [Rust toolchain](https://rustup.rs/) (stable)
- **swift/** samples: [Xcode 26+](https://developer.apple.com/xcode/) (macOS only)

Expand All @@ -46,6 +50,8 @@ You (or your tenant admin) must create an Entra app registration with specific p

After setup you'll have two values: `APP_ID` and `TENANT_ID`. Pass them to any sample via `--appid` and `--tenant`.

> **[`python/obo/`](python/obo/) is the exception.** The script above creates a *public* client; the On-Behalf-Of flow requires a *confidential* client that also exposes its own API. See [its README](python/obo/README.md#app-registration) for the separate setup.

---

## Authentication methods
Expand Down
18 changes: 18 additions & 0 deletions python/obo/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Backend app registration (confidential client).
AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000
AZURE_CLIENT_ID=00000000-0000-0000-0000-000000000000

# Application ID URI this backend's tokens are issued for. The frontend must
# request `${API_AUDIENCE}/access_as_user`.
API_AUDIENCE=api://00000000-0000-0000-0000-000000000000

# Scope the inbound token must carry. Defaults to access_as_user.
# REQUIRED_SCOPE=access_as_user

# Leave AZURE_CLIENT_SECRET unset in Azure: the app then authenticates with a
# managed identity federated credential via DefaultAzureCredential (no secret).
# Set it only for local development.
# AZURE_CLIENT_SECRET=

# Override the gateway host for testing. Defaults to the production gateway.
# WORKIQ_HOST=https://workiq.svc.cloud.microsoft
3 changes: 3 additions & 0 deletions python/obo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.venv/
.env
*.env
222 changes: 222 additions & 0 deletions python/obo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
# Work IQ Python OBO Sample

A FastAPI **middle-tier** service that accepts your frontend's access token, exchanges it for a Work IQ token via the [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow), and calls the [Copilot Chat REST API](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/chat/overview) through the **Work IQ Gateway**.

Every other sample in this repo is a **public client** that signs a user in directly. This one is different: the user signs in to *your* frontend, and this service brokers the Work IQ call for them.

```
Frontend ──token(aud=your API)──▶ This service ──token(aud=Work IQ)──▶ Gateway
└── validate ──▶ OBO exchange
```

Supports both **synchronous** and **streaming** (SSE) modes.

## API reference

| Operation | Method | Path | Docs |
|-----------|--------|------|------|
| Create conversation | `POST` | `/rest/beta/conversations` | [Docs](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/chat/copilotroot-post-conversations) |
| Chat (sync) | `POST` | `/rest/beta/conversations/{id}/chat` | [Docs](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/chat/copilotconversation-chat) |
| Chat (stream) | `POST` | `/rest/beta/conversations/{id}/chatOverStream` | [Docs](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/chat/copilotconversation-chatoverstream) |

## Why you can't just forward the frontend's token

A token's audience is baked into the signed JWT. The token your frontend holds has
`aud` set to *your* API — the Gateway rejects it with `401`. You cannot re-point it at
another resource; you exchange it for a new one. That exchange is OBO.

**`DefaultAzureCredential` cannot do this exchange.** Its chain (managed identity,
environment service principal, Azure CLI) issues tokens for the *app's own* identity or
a *developer's* identity. None of them accept an inbound user token and re-issue it for
a different resource. And `WorkIQAgent.Ask` is delegated-only — an app-only token gets
you nothing, because responses depend on the signed-in user's Copilot license and their
own data access.

`azure.identity.aio.OnBehalfOfCredential` is the credential that does this.

`DefaultAzureCredential` still has a real job here: proving the *app's* identity so no
client secret is ever deployed. Leave `AZURE_CLIENT_SECRET` unset and the service uses a
managed identity federated credential as its client assertion — see [`app/auth.py`](app/auth.py):

```python
def assertion() -> str:
return credential.get_token("api://AzureADTokenExchange/.default").token

OnBehalfOfCredential(
tenant_id=..., client_id=...,
client_assertion_func=assertion, # app identity — DefaultAzureCredential
user_assertion=inbound_token, # user identity — from your frontend
)
```

Both identities are required: the app proves it is allowed to ask, the user token
determines what comes back.

## Prerequisites

1. **Microsoft 365 Copilot license** on your test user.
2. **Two Entra app registrations.** `scripts/admin-setup.sh` at the repo root does **not**
cover this sample — it creates a *public* client for the CLI samples, and OBO requires
a *confidential* client. See [App registration](#app-registration) below.
3. **Python 3.10+**.

## App registration

**Frontend app** (SPA / public client) — requests `api://<BACKEND_APP_ID>/access_as_user`.

**Backend app** (this service — confidential client):

| Blade | Setting |
|-------|---------|
| Expose an API | Application ID URI `api://<BACKEND_APP_ID>`, scope `access_as_user` |
| API permissions | `Work IQ` → delegated `WorkIQAgent.Ask` → **Grant admin consent** |
| Certificates & secrets | In Azure, prefer a **federated credential** bound to your managed identity over a client secret |

```bash
# Ensure the Work IQ service principal exists in your tenant
az ad sp create --id fdcc1f02-fc51-4226-8753-f668596af7f7

# Grant this service the delegated Work IQ permission, then consent
az ad app permission add --id <BACKEND_APP_ID> \
--api fdcc1f02-fc51-4226-8753-f668596af7f7 \
--api-permissions "0b1715fd-f4bf-4c63-b16d-5be31f9847c2=Scope"
az ad app permission admin-consent --id <BACKEND_APP_ID>
```

## Quick start

```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env # fill in tenant, client id, audience
set -a && source .env && set +a

uvicorn app.main:app --reload
```

```bash
curl -X POST localhost:8000/api/chat \
-H "Authorization: Bearer <token your frontend got for api://<BACKEND_APP_ID>/access_as_user>" \
-H "Content-Type: application/json" \
-d '{"message": "What meetings do I have tomorrow?", "time_zone": "America/New_York"}'
```

```json
{
"conversation_id": "conv-123",
"text": "You have 3 meetings scheduled...",
"citations": [
{ "type": "citation", "source": "sharepoint", "provider": "Q3.docx", "url": "https://..." }
]
}
```

Pass `conversation_id` back on later turns to continue the same conversation with full context.

### Verify without credentials

```bash
python smoke_test.py
```

Fakes the Gateway with `httpx.MockTransport` and checks conversation creation, citation
parsing, streaming deltas, the `403` error path, and the auth gate. No tenant, no license,
no network.

## Endpoints

| Endpoint | Mode | Response |
|----------|------|----------|
| `GET /healthz` | — | `{"status": "ok"}` (unauthenticated, for liveness probes) |
| `POST /api/chat` | Synchronous | JSON — `conversation_id`, `text`, `citations` |
| `POST /api/chat/stream` | SSE | See streaming contract below |

**Stream event sequence:**

1. `event: conversation` — `data: {"conversation_id": "..."}` (first frame)
2. `data: {"text": "<delta>"}` — one per text chunk (default event type)
3. `event: done` — signals clean completion, or `event: error` — `data: upstream request failed` on failure

Request body for both: `{"message": "...", "conversation_id": "...", "time_zone": "..."}` (`conversation_id` and `time_zone` optional; `time_zone` is an IANA identifier like `America/New_York`).

**Streaming contract:** once the HTTP 200 is committed, errors cannot change the status code. Clients **must** listen for `event: error` frames to detect mid-stream failures. A `event: done` frame signals clean completion; its absence (with no `error`) indicates a dropped connection.

## Layout

| File | Purpose |
|------|---------|
| [`app/config.py`](app/config.py) | Environment config; fails fast on missing values |
| [`app/auth.py`](app/auth.py) | Inbound JWT validation + the OBO exchange |
| [`app/workiq.py`](app/workiq.py) | Async Gateway client (sync + streaming) |
| [`app/main.py`](app/main.py) | FastAPI routes |
| [`smoke_test.py`](smoke_test.py) | Gateway faked via `httpx.MockTransport` |

## How it works

```
Frontend This service Gateway
| | |
|-- POST /api/chat --->| |
| Bearer <user tok> | |
| |-- validate signature/aud/scp |
| | against Entra JWKS |
| | |
| |-- OBO exchange ---▶ Entra |
| |◀-- token(aud=Work IQ) -- |
| | |
| |-- POST .../conversations ----->|
| |<-- 201 { "id": "conv-123" } ---|
| |-- POST .../conv-123/chat ----->|
| |<-- 200 { "messages": [...] } --|
|<-- 200 JSON ---------| |
```

Each SSE event from the Gateway contains the **full conversation state** (cumulative, not
incremental). [`app/workiq.py`](app/workiq.py) diffs against the previous event and yields
only new text, so `/api/chat/stream` emits true deltas.

Auth is resolved before body validation, so an unauthenticated caller gets `401` and
learns nothing about the request schema.

## Dependencies

| Package | Purpose |
|---------|---------|
| `azure-identity` | `OnBehalfOfCredential` for the exchange; `DefaultAzureCredential` for the app assertion |
| `pyjwt[crypto]` | Validating the inbound token against Entra's JWKS |
| `httpx` | Async HTTP + SSE against the Gateway |
| `fastapi` / `uvicorn` | The service itself |

No MSAL wrapper needed — `azure-identity` builds on MSAL underneath.

## Sample-specific troubleshooting

| Symptom | Fix |
|---------|-----|
| `401` from this service | Inbound token failed validation. Check `aud` matches `API_AUDIENCE` and `scp` includes `access_as_user`. |
| `403 Unable to obtain Work IQ access` | The OBO exchange failed. Usually missing admin consent on `WorkIQAgent.Ask`, or the user lacks a Copilot license. |
| `401` from the Gateway | The *outbound* token's `aud` is wrong — must be `api://workiq.svc.cloud.microsoft`, not your API. |
| `AADSTS50013: Assertion failed signature validation` | The federated credential subject/issuer doesn't match your managed identity. |
| `502 Work IQ request failed` | The Gateway call failed — network error, timeout, or the Gateway returned a server error. Check connectivity and the `request-id` in logs. |
| Slow first response per turn | Every request does a fresh OBO round trip. See [Notes before production](#notes-before-production). |

See the [root README](../../README.md#troubleshooting) for the full matrix (Copilot license, consent, audience mismatch).

## Notes before production

- **Cache the OBO result.** Each request currently exchanges the token again. Cache on a
hash of the inbound token, honoring `expires_on`, to save a round trip per turn.
- **The `request-id` response header** is what Microsoft support asks for.
[`app/workiq.py`](app/workiq.py) includes it in the `WorkIQError` exception;
[`app/main.py`](app/main.py) logs it when handling errors.
- **Never log tokens.** Errors here are logged with the user's `oid`, not the assertion.

## Resources

- [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow)
- [Workload identity federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation)
- [`azure-identity` for Python](https://learn.microsoft.com/en-us/python/api/overview/azure/identity-readme)
- [Chat API Overview](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/chat/overview)
- [Work IQ Overview](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/workiq-overview)
1 change: 1 addition & 0 deletions python/obo/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Work IQ On-Behalf-Of backend sample."""
Loading