Skip to content

feat: add Python OBO sample for Work IQ - #23

Open
Shakir Fattani (shakir-fattani) wants to merge 36 commits into
microsoft:mainfrom
shakir-fattani:python-obo-sample
Open

feat: add Python OBO sample for Work IQ#23
Shakir Fattani (shakir-fattani) wants to merge 36 commits into
microsoft:mainfrom
shakir-fattani:python-obo-sample

Conversation

@shakir-fattani

Copy link
Copy Markdown

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.

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.
Copilot AI review requested due to automatic review settings July 16, 2026 18:48
@shakir-fattani

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-identity OnBehalfOfCredential.
  • 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.

Comment thread python/obo/app/workiq.py
Comment thread python/obo/app/workiq.py Outdated
Comment thread python/obo/app/workiq.py
Comment thread python/obo/app/config.py
Comment thread python/obo/app/config.py
Comment thread python/obo/app/main.py Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Comment thread python/obo/app/main.py Outdated
Comment thread python/obo/app/config.py Outdated
Comment thread python/obo/app/workiq.py
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comment thread python/obo/smoke_test.py Outdated
Comment thread python/obo/README.md Outdated
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Comment thread python/obo/app/workiq.py
Comment thread python/obo/app/workiq.py Outdated
Comment thread python/obo/app/workiq.py
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • httpx2 is only referenced here and isn’t imported anywhere in the sample. The smoke test uses fastapi.testclient (which rides on httpx), and httpx is already a dependency above, so httpx2 looks unnecessary and may break installs if it’s not available.
# Test dependency: starlette.testclient requires httpx2 at runtime.
httpx2>=2.7

Comment thread python/obo/requirements.txt Outdated
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.disconnect without 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 _SecurityHeadersMiddleware wraps _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 done SSE 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() normalizes WORKIQ_HOST by stripping a trailing slash, but _allowed_hosts() does not normalize entries from EXTRA_WORKIQ_HOSTS. As a result, an extra host like https://workiq.test/ will never match (it is stored with the slash, but compared against the normalized form), causing a confusing startup ConfigError.
    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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_host only strips trailing slashes before checking membership in the allowlist. If WORKIQ_HOST includes a path/query/fragment, it can bypass the intended 'host-only' shape checks and also result in an incorrect workiq_base when WORKIQ_PATH is appended. Normalizing WORKIQ_HOST to 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_HOSTS is 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("/"))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants