feat: add Python OBO sample for Work IQ - #23
feat: add Python OBO sample for Work IQ#23Shakir Fattani (shakir-fattani) wants to merge 36 commits into
Conversation
Adds python/obo/ -- a FastAPI middle-tier service that accepts a frontend's access token, exchanges it for a Work IQ token via the On-Behalf-Of flow, and calls the Copilot Chat REST API through the Work IQ Gateway. Every existing sample is a public client that signs a user in directly. This is the first middle-tier sample: the user signs in to your frontend, and the service brokers the Work IQ call on their behalf. A frontend token cannot be forwarded as-is -- its `aud` is your own API, so the Gateway rejects it with 401. Audience is signed into the JWT, so the token must be exchanged, not re-pointed at another resource. Auth (python/obo/app/auth.py): - Inbound tokens validated against Entra's JWKS (signature, aud, iss, scp). - OBO exchange via azure-identity's OnBehalfOfCredential, which is MSAL-backed (it wraps msal.ConfidentialClientApplication underneath). - Managed identity support, for enterprise security postures that disallow deployed secrets: with AZURE_CLIENT_SECRET unset, the service authenticates using a workload-identity federated credential -- DefaultAzureCredential fetches a token for api://AzureADTokenExchange and supplies it as the client assertion, so no secret ever ships. A client secret remains supported for local development. Note that DefaultAzureCredential alone cannot perform the exchange: its chain (managed identity, environment service principal, Azure CLI) issues app-only or developer identities, and WorkIQAgent.Ask is delegated-only. It proves the app's identity; the user assertion supplies the user's. Both are required. Contents: - python/obo/app/config.py -- env config, fails fast on missing values - python/obo/app/auth.py -- JWT validation + OBO exchange - python/obo/app/workiq.py -- async Gateway client (sync + SSE streaming) - python/obo/app/main.py -- FastAPI routes (/api/chat, /api/chat/stream) - python/obo/smoke_test.py -- Gateway faked via httpx.MockTransport - README.md: python/obo/ row added to the sample table; Python 3.10+ added to the toolchain list; callout that scripts/admin-setup.sh does not cover this sample -- it creates a public client, and OBO needs a confidential one that also exposes its own API. Validation: - smoke_test.py passes with no credentials and no network: conversation creation, citation parsing, cumulative-to-delta stream conversion, the 403 path surfacing request-id, and the auth gate (missing and malformed tokens both 401 before body validation). - azure-identity 1.25.3 confirmed to expose client_assertion_func on the async OnBehalfOfCredential. - NOT yet validated end-to-end against a live tenant. That requires a confidential app registration with admin-consented WorkIQAgent.Ask and a Copilot-licensed user; the wire contract was matched against dotnet/rest/ rather than observed from the Gateway.
|
@microsoft-github-policy-service agree |
There was a problem hiding this comment.
Pull request overview
Adds a new python/obo/ sample that demonstrates a FastAPI middle-tier service using the Entra On-Behalf-Of (OBO) flow to exchange a frontend access token for a Work IQ token, then calling the Work IQ Gateway Copilot Chat REST endpoints (sync + SSE streaming). This complements the existing “public client” samples by showing the brokered backend pattern required when the frontend token audience is your own API.
Changes:
- Introduces a FastAPI service with inbound JWT validation (JWKS) and OBO exchange via
azure-identityOnBehalfOfCredential. - Implements an async Work IQ Gateway REST client with synchronous chat and SSE streaming (cumulative-to-delta conversion).
- Adds documentation, environment templates, and a no-network smoke test using
httpx.MockTransport.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds the python/obo/ entry and clarifies that it needs a confidential app registration; adds Python toolchain note. |
| python/obo/README.md | Full sample documentation: OBO rationale, app registration steps, endpoints, and operational notes. |
| python/obo/requirements.txt | Declares Python dependencies for FastAPI + auth + HTTP client. |
| python/obo/smoke_test.py | Adds a self-contained smoke test with a mocked Gateway and basic auth gating checks. |
| python/obo/app/config.py | Adds environment-backed configuration and derived settings (issuer, JWKS URI, Work IQ base URL). |
| python/obo/app/auth.py | Implements JWT validation and OBO token exchange (client secret or federated assertion). |
| python/obo/app/main.py | Defines /api/chat and /api/chat/stream routes and wires auth/token exchange into request handling. |
| python/obo/app/workiq.py | Implements async REST calls to the Gateway and SSE parsing/delta emission. |
| python/obo/app/init.py | Marks the app package. |
| python/obo/.gitignore | Ignores local virtual environments. |
| python/obo/.env.example | Provides example environment variables and guidance for secretless vs local dev auth. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Address all 6 review comments from Copilot on microsoft#23: - Use relative URL paths (no leading /) consistently across all WorkIQClient methods, not just chatOverStream - Add trailing slash to workiq_base so relative paths resolve correctly - Fix config.py docstring: settings fail at get_settings() call, not import - Strip whitespace from REQUIRED_SCOPE and WORKIQ_HOST env vars - Make Bearer scheme check case-insensitive per RFC 9110 - Update smoke_test BASE to match trailing-slash convention Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
- Bearer token parsing: split on whitespace and require exactly scheme + token, rejecting "BearerXYZ" (no space) and bare "Bearer" with no value - Config env vars: treat whitespace-only REQUIRED_SCOPE and WORKIQ_HOST as unset and fall back to defaults instead of passing empty strings - Error messages: remove response body from WorkIQError to avoid leaking potentially sensitive data into logs; keep status + request-id only Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
… string Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
- Wrap asyncio.run(main()) in if __name__ == "__main__" to prevent side effects on import - README: clarify that request-id is surfaced in WorkIQError exception (workiq.py), not logged there — logging happens in main.py Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
All three WorkIQClient methods (create_conversation, chat, chat_stream) now catch httpx.HTTPError and re-raise as WorkIQError so that main.py's existing except-WorkIQError handler returns 502 instead of an unhandled 500. Also catch ValueError from response.json() for non-JSON gateway responses. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
Mid-stream transport failures (connection drops, read timeouts) during aiter_lines() were not wrapped in WorkIQError, causing them to bypass main.py's error handler and surface as unhandled 500s. Now caught and re-raised as WorkIQError for proper 502 handling. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
Consolidate the nested try/except for httpx.HTTPError into one block covering both response.aread() (error path) and aiter_lines() (streaming). Previously aread() failures would escape as unhandled httpx errors. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
Verify that httpx.ConnectError (simulating DNS/network failures) is properly wrapped in WorkIQError for all three client methods: create_conversation, chat, and chat_stream. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
Document the 502 Work IQ request failed error path so users know to check connectivity and request-id in logs when Gateway calls fail. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
The exception now wraps transport errors and bad responses, not just HTTP error codes — update the docstring to reflect this. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
… finally - auth.py: wrap sync DefaultAzureCredential.close() in asyncio.to_thread to avoid blocking the event loop during lifespan shutdown - workiq.py: initialize response=None and guard the finally block so response.aclose() is only called when send() succeeded, preventing potential UnboundLocalError during async generator cleanup Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
…dingError - main.py: narrow bare except Exception to (ClientAuthenticationError, HttpResponseError) so programming errors propagate as 500 instead of being silently swallowed as 403 - main.py: validate conversation_id with regex pattern to prevent path traversal via caller-supplied IDs injected into URL paths - workiq.py: catch httpx.DecodingError alongside ValueError when parsing response JSON — DecodingError is not a ValueError subclass and would escape as an unhandled 500 - smoke_test.py: clear get_settings lru_cache to avoid stale env vars when running in multi-test processes Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
Starlette 1.3+ requires httpx2 for TestClient. Adding it alongside httpx (still needed by the app itself) eliminates the deprecation warning during smoke tests. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
- workiq.py: assert conversation_id from gateway is a string, not just truthy — a non-string id would cause 422 on continuation turns - workiq.py + main.py: accept optional time_zone parameter so frontends can supply the user's IANA timezone instead of always using the server's locale (which is typically UTC in containers) - ChatRequest gains time_zone field with IANA pattern validation Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
Inject FastAPI Request and read state from request.app.state so the dependency always references the live app instance, improving test isolation if the app object is ever replaced. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
python/obo/requirements.txt:8
httpx2is only referenced here and isn’t imported anywhere in the sample. The smoke test usesfastapi.testclient(which rides onhttpx), andhttpxis already a dependency above, sohttpx2looks unnecessary and may break installs if it’s not available.
# Test dependency: starlette.testclient requires httpx2 at runtime.
httpx2>=2.7
…ess, and cleanup - Remove incorrect httpx2 dep, bump azure-identity>=1.25.3 for client_assertion_func - Add WORKIQ_HOST allowlist to prevent SSRF via misconfigured env - Add body size limit middleware (64 KB) and security response headers - Add /healthz endpoint for Kubernetes liveness probes - Fix AsyncIterator -> AsyncGenerator return types on async generators - Explicit rejection of app-only tokens with clear error message - Add JWKS cache TTL (1h) for key rotation resilience - Centralize CONV_ID_PATTERN, validate before URL construction - Log warning on timezone detection fallback instead of silent UTC - Use Self return type on __aenter__, distinct 401 detail messages - Generic SSE error message to avoid leaking upstream service name Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
…meout tuning - Catch httpx.StreamError alongside HTTPError in chat_stream to prevent 500s - Add proper type annotations on SecurityHeadersMiddleware.dispatch() - Harden body size middleware with streaming byte counter for chunked encoding - Remove cache_keys=True from PyJWKClient to respect JWKS key rotation - Use differentiated httpx.Timeout (connect=10s, read=300s, write=30s, pool=5s) - Add pip-compile --generate-hashes comment for production pinning - Add smoke test coverage for /healthz, security headers, and 413 body limit Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
…anitisation - Fix rejected re-entry guard in body size middleware (was write-only dead code) - Guard int(Content-Length) against ValueError for isolated middleware tests - Add chunked-encoding body limit smoke test (slow path coverage) - Exclude .env from git to prevent accidental secret commits - Suppress Bandit B105 false positive on TOKEN_EXCHANGE_SCOPE - Log only exception type name in token rejection to avoid leaking JWT fragments Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
…tion nullability - Remove JSONResponse from inside limited_receive to avoid ASGI protocol violation when inner app has already started its response - Remove private _last_text_message import from smoke test; edge cases are covered indirectly through the public WorkIQClient API - Move timezone detection from module import to lifespan startup so the fallback warning fires after logging is configured - Make Citation.see_more_web_url and CitationModel.url nullable (str | None) so absent URLs are explicit rather than empty strings Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
…ping - Hide client_secret from Settings repr to prevent accidental credential leaks in logs or tracebacks (repr=False) - Replace BaseHTTPMiddleware with raw ASGI middleware for security headers to avoid response buffering that breaks true SSE streaming - Add OpenAPI responses= annotation documenting the SSE event contract on /api/chat/stream - Add EXTRA_WORKIQ_HOSTS env var (comma-separated) so staging/test hosts can be allowlisted without editing source code - Scope os.environ mutation in smoke_test inside patch.dict so fake credentials don't leak when imported by a test runner - Document print() usage as a deliberate choice in smoke_test docstring Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
Reject non-HTTPS entries at startup to prevent OBO tokens from being forwarded over plaintext HTTP to misconfigured or malicious hosts. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
python/obo/app/main.py:79
- In the chunked/unknown Content-Length path, exceeding the body limit currently returns
http.disconnectwithout sending a 413 response. This makes oversized requests look like a dropped connection (often surfacing as 500) even when the app has not started a response yet.
rejected = True
# Do NOT send a response here — the inner app may have
# already started its response. Returning http.disconnect
# causes the inner app to abort cleanly.
return {"type": "http.disconnect"}
python/obo/app/config.py:103
- WORKIQ_HOST is validated via exact string match, but the value is not normalized. A common value like
https://workiq.svc.cloud.microsoft/(trailing slash) will fail the allowlist check even though it should be equivalent, causing an avoidable startup ConfigError.
def _validated_workiq_host(host: str) -> str:
allowed = _allowed_hosts()
if host not in allowed:
raise ConfigError(
f"WORKIQ_HOST {host!r} is not in the allowed list: {allowed}"
…ailing slash - Body size middleware now tracks whether the inner app has started its response; sends a proper 413 when it hasn't, disconnects when it has - Normalize WORKIQ_HOST by stripping trailing slashes before allowlist check so "https://host/" matches "https://host" Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
python/obo/app/main.py:172
- Middleware order means 413 responses generated by _BodySizeLimitMiddleware bypass _SecurityHeadersMiddleware, so oversized-body rejections won’t include the security headers you add to other responses. Swap the add_middleware calls so security headers wrap all responses, including early 413s.
app = FastAPI(title="Work IQ OBO Backend", lifespan=lifespan)
app.add_middleware(_SecurityHeadersMiddleware)
app.add_middleware(_BodySizeLimitMiddleware)
python/obo/smoke_test.py:169
- Allowing a 500 here bakes in non-deterministic behavior for oversized chunked bodies. Once the body-limit middleware consistently returns 413, this assertion should require 413 so regressions are caught.
assert r.status_code in (413, 500), r.status_code # 413 or 500 from disconnect
python/obo/app/main.py:88
- In the chunked/slow-path body limiter, returning http.disconnect after sending a 413 can still trigger downstream request-parsing errors that surface as a 500 (your smoke test currently allows this). Instead, after rejecting, stop feeding the body to FastAPI with a clean end-of-body message and suppress any downstream response writes so the client deterministically sees 413.
This issue also appears on line 170 of the same file.
async def limited_receive() -> dict: # type: ignore[type-arg]
nonlocal seen, rejected
if rejected:
return {"type": "http.disconnect"}
message = await receive()
- Return clean end-of-body (not http.disconnect) after rejection so FastAPI doesn't raise request-parsing errors - Suppress downstream response writes after sending 413 to prevent the inner app from corrupting the response - Swap middleware order: body-size outermost, security headers inner, so 413 rejections also carry security headers - Smoke test now requires 413 deterministically (not 413-or-500) Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
python/obo/app/main.py:180
- The middleware-order comment is inaccurate/confusing: with Starlette/FastAPI
add_middleware, the last added middleware is outermost, so_SecurityHeadersMiddlewarewraps_BodySizeLimitMiddleware(which is good because 413 responses from the body-size limiter still get the security headers). Update the comment to match the actual order/intent so future edits don’t accidentally break the header guarantee.
# Body-size runs outermost (added last = LIFO), security headers wraps the
# inner app so 413 rejections also carry the security headers.
app.add_middleware(_BodySizeLimitMiddleware)
app.add_middleware(_SecurityHeadersMiddleware)
python/obo/app/main.py:310
- The
doneSSE frame currently sends a single-space payload (data: ␠). Some SSE clients treat this as non-empty data, which can complicate “done with no payload” handling. Emit an actually-empty data line (data:) instead.
yield "event: done\ndata: \n\n"
…rcement - Guard typing.Self import with fallback to typing_extensions for Python 3.10 - Fix middleware ordering comment to match LIFO reality - Emit empty data line in done SSE frame (no trailing space) - Require nbf claim in JWT validation for defense-in-depth - Update README error event text to match code (upstream request failed) Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
python/obo/app/config.py:93
_validated_workiq_host()normalizesWORKIQ_HOSTby stripping a trailing slash, but_allowed_hosts()does not normalize entries fromEXTRA_WORKIQ_HOSTS. As a result, an extra host likehttps://workiq.test/will never match (it is stored with the slash, but compared against the normalized form), causing a confusing startupConfigError.
for raw in extra.split(","):
host = raw.strip()
if not host:
continue
if not host.startswith("https://"):
raise ConfigError(
f"EXTRA_WORKIQ_HOSTS entry {host!r} must use the https:// scheme"
)
additions.add(host)
return _ALLOWED_WORKIQ_HOSTS | frozenset(additions)
_validated_workiq_host() strips trailing slashes from WORKIQ_HOST before comparing against the allowlist, but _allowed_hosts() stored EXTRA_WORKIQ_HOSTS entries as-is. An entry like "https://workiq.test/" would never match the normalized form, causing a false ConfigError. Co-Authored-By: Shakir's Advisor Claude(Opus 4.6 (1M context)) <m.shakirfattani@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
python/obo/app/config.py:101
_validated_workiq_hostonly strips trailing slashes before checking membership in the allowlist. IfWORKIQ_HOSTincludes a path/query/fragment, it can bypass the intended 'host-only' shape checks and also result in an incorrectworkiq_basewhenWORKIQ_PATHis appended. NormalizingWORKIQ_HOSTto an origin (scheme + netloc) makes the allowlist unambiguous.
def _validated_workiq_host(host: str) -> str:
# Normalize trailing slashes so "https://host/" matches "https://host".
normalized = host.rstrip("/")
allowed = _allowed_hosts()
if normalized not in allowed:
python/obo/app/config.py:94
EXTRA_WORKIQ_HOSTSis documented as a list of gateway hosts, but the current check only enforces the https:// scheme and then stores the full string (minus trailing slash). This allows entries with paths/query/fragment (e.g. https://example.com/evil), which weakens the allowlist intent and can produce unexpected base URLs when WORKIQ_PATH is appended.
This issue also appears on line 97 of the same file.
if not host.startswith("https://"):
raise ConfigError(
f"EXTRA_WORKIQ_HOSTS entry {host!r} must use the https:// scheme"
)
additions.add(host.rstrip("/"))
Adds python/obo/ -- a FastAPI middle-tier service that accepts a frontend's access token, exchanges it for a Work IQ token via the On-Behalf-Of flow, and calls the Copilot Chat REST API through the Work IQ Gateway.
Every existing sample is a public client that signs a user in directly. This is the first middle-tier sample: the user signs in to your frontend, and the service brokers the Work IQ call on their behalf. A frontend token cannot be forwarded as-is -- its
audis your own API, so the Gateway rejects it with401. Audience is signed into the JWT, so the token must be exchanged, not re-pointed at another resource.
Auth (python/obo/app/auth.py):
Note that DefaultAzureCredential alone cannot perform the exchange: its chain (managed identity, environment service principal, Azure CLI) issues app-only or developer identities, and WorkIQAgent.Ask is delegated-only. It proves the app's identity; the user assertion supplies the user's. Both are required.
Contents:
the toolchain list; callout that scripts/admin-setup.sh does not cover this
sample -- it creates a public client, and OBO needs a confidential one that
also exposes its own API.
Validation: