Skip to content

feat(api): advertise OAuth resource metadata on 401 responses#59925

Merged
rafaeelaudibert merged 4 commits into
masterfrom
posthog-code/api-401-resource-metadata-hint
May 27, 2026
Merged

feat(api): advertise OAuth resource metadata on 401 responses#59925
rafaeelaudibert merged 4 commits into
masterfrom
posthog-code/api-401-resource-metadata-hint

Conversation

@rafaeelaudibert
Copy link
Copy Markdown
Member

Problem

Agents (MCP-style clients, in particular) that hit our API with no
credentials need a way to bootstrap OAuth discovery from a stock 401
response. RFC 9728 standardizes this: the server returns
WWW-Authenticate: Bearer resource_metadata="<URL>", and the client
fetches the protected-resource metadata document from that URL.

Today our DRF 401s carry no WWW-Authenticate header, so agents have
to be configured out-of-band to know where the discovery document
lives.

Changes

  • posthog/exceptions.py — new exception_handler that wraps
    exceptions_hog.exception_handler. When the wrapped response is a
    401, we append:
    Bearer resource_metadata="<absolute URL>/.well-known/oauth-protected-resource".
    The absolute URL is built from the incoming request (scheme + host)
    so it works correctly for both us.posthog.com and self-hosted
    deployments. If no request is present in the context we fall back to
    a relative URL.
  • posthog/settings/web.py — point DRF's EXCEPTION_HANDLER at the
    new wrapper (was exceptions_hog.exception_handler).
  • posthog/test/test_exceptions.py — new file with six parameterless
    cases.

Non-DRF 401 paths (e.g. raw JsonResponse(401) from
session_auth_required in posthog/auth.py) are intentionally out of
scope; happy to fold them into a shared helper in a follow-up if
reviewers want consistent coverage across the whole app.

How did you test this code?

This PR was authored by an agent. I did not perform any manual
testing.

Automated tests in posthog/test/test_exceptions.py cover:

Case Expected
NotAuthenticated over HTTPS 401 + hint with https:// URL
AuthenticationFailed over HTTPS 401 + hint with https:// URL
PermissionDenied 403, no WWW-Authenticate
ValidationError 400, no WWW-Authenticate
NotAuthenticated over HTTP 401 + hint with http:// URL
NotAuthenticated without request in context 401 + hint with relative URL

Validated against the real drf-exceptions-hog==0.4.0 in a temp venv
during development (the cloud env doesn't have hogli/flox/uv
available). Ruff check and format both clean.

Publish to changelog?

no

Docs update

n/a — no user-facing surface area change.

🤖 Agent context

Authored by Claude (Claude Code, Opus 4.7). The user asked us to
advertise the OAuth protected-resource metadata document via
WWW-Authenticate on any 401 from our API. Decision points:

  • Wrap vs. fork exceptions_hog. Wrapping is one tiny function
    and preserves all the existing error-body formatting and
    exception_reporting hook. Forking would have meant copying
    ~hundreds of lines of code for no gain.
  • Where to put the wrapper. posthog/exceptions.py already hosts
    the exception_reporting callback and the manual
    generate_exception_response helper, so it's the obvious neighbor.
  • Absolute vs. relative URL. RFC 9728 says the resource_metadata
    value SHOULD be a URI. request.build_absolute_uri() gives us the
    correct scheme + host without hardcoding SITE_URL, which matters
    for self-hosted users.
  • Scope. Only DRF-mediated 401s carry the hint. The non-DRF paths
    (legacy session_auth_required, a couple of raw 401s elsewhere)
    would need their own change and are not in this PR — flagged in the
    commit body so a reviewer can opt into expanding scope.

Wrap drf-exceptions-hog's handler so DRF 401 responses include a
`WWW-Authenticate: Bearer resource_metadata="<absolute URL to
/.well-known/oauth-protected-resource>"` header per RFC 9728.

This lets MCP-style agents bootstrap discovery from a stock 401 instead
of needing out-of-band configuration.

- `posthog/exceptions.py`: new `exception_handler` wrapping
  `exceptions_hog.exception_handler`; appends the discovery hint when
  the response is 401.
- `posthog/settings/web.py`: point DRF `EXCEPTION_HANDLER` at the new
  wrapper.
- `posthog/test/test_exceptions.py`: six cases covering
  `NotAuthenticated`/`AuthenticationFailed` (hint present),
  `PermissionDenied`/`ValidationError` (hint absent), http vs https
  scheme, and missing-request fallback.

Non-DRF 401 paths (e.g. raw `JsonResponse(401)` from
`session_auth_required`) are intentionally out of scope here; they can
be migrated to a shared helper in a follow-up.

Generated-By: PostHog Code
Task-Id: 091fa67c-8e3a-4907-a103-c4ca85fd9e5d
@graphite-app graphite-app Bot added the stamphog Request AI review from stamphog label May 25, 2026
Copy link
Copy Markdown

@stamphog stamphog Bot left a comment

Choose a reason for hiding this comment

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

This PR modifies the global DRF exception handler (auth-adjacent code) to add a WWW-Authenticate header on all 401 responses, which changes the API contract for authentication flows. Needs a human reviewer to confirm the OAuth resource metadata endpoint exists, the header value is correct per RFC 9728, and there are no unintended side effects on existing auth integrations.

@stamphog stamphog Bot removed the stamphog Request AI review from stamphog label May 25, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 25, 2026

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
posthog/test/test_exceptions.py:8-64
**Prefer parameterised tests**

The six test methods share almost identical setup and assertion logic; the custom instructions explicitly prefer parameterised tests. The two HTTPS cases (`test_not_authenticated_includes_resource_metadata_hint` and `test_authentication_failed_includes_resource_metadata_hint`) are especially close — they differ only in the exception type. The scheme and presence/absence of the header could also be collapsed. Consider grouping with `@pytest.mark.parametrize` or `subTest`.

### Issue 2 of 2
posthog/exceptions.py:137
Dead `else` branch — `ExceptionContext` is a `TypedDict`, which is a plain `dict` at runtime, so `isinstance(context, dict)` is always `True` and the `getattr` fallback is unreachable. Removing it keeps the code clean and satisfies the "no superfluous parts" simplicity rule.

```suggestion
        request = context.get("request")
```

Reviews (1): Last reviewed commit: "feat(api): advertise OAuth resource meta..." | Re-trigger Greptile

Comment thread posthog/test/test_exceptions.py Outdated
Comment thread posthog/exceptions.py Outdated
Comment thread posthog/exceptions.py Outdated
@veria-ai
Copy link
Copy Markdown

veria-ai Bot commented May 25, 2026

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

`posthog/exceptions.py` is imported very early during `manage.py`
bootstrap (e.g. by `ensure_migration_defaults`). The previous commit
added a top-level `from exceptions_hog import exception_handler`, but
`exceptions_hog/handler.py` evaluates
`_("A server error occurred.")` at module import time. That non-lazy
gettext call needs Django's app registry, which isn't loaded yet at
that point, so import explodes with `AppRegistryNotReady` and breaks
all Django test jobs and the migration step.

Defer the `exceptions_hog` import to inside `exception_handler` so it
only resolves at request-handling time, when Django is fully up.
Behavior is unchanged for the actual DRF handler call path.

Also addresses Greptile review feedback:

- Drop the dead `isinstance(context, dict) else getattr(...)` branch in
  `exception_handler` — `ExceptionContext` is a `TypedDict` (plain dict
  at runtime), so the fallback was unreachable.
- Collapse the five DRF-exception test cases into a single
  `@parameterized.expand` method per the repo's test convention. The
  no-request fallback case remains standalone because it doesn't share
  the request-construction path.

Generated-By: PostHog Code
Task-Id: 091fa67c-8e3a-4907-a103-c4ca85fd9e5d
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

🎭 Playwright didn't run on this PR — your changes touch code that could affect E2E behavior, but Playwright is opt-in via label now to keep CI cost down.

Add the run-playwright label if you want an E2E sweep before merging — CI will pick it up automatically.

Most PRs don't need this. Real regressions still get caught on master and fix-forward.

@github-actions
Copy link
Copy Markdown
Contributor

ClickHouse migration SQL per cloud environment

No ClickHouse migrations changed in this PR.

`ty` flagged `exception_handler(NotAuthenticated(), {})` in
`test_no_request_in_context_falls_back_to_relative_path` because
`ExceptionContext` is a `TypedDict` with `request` marked required, so
the empty literal fails the `missing-typed-dict-key` check. Cast the
literal to `ExceptionContext` — the test exists specifically to
exercise the defensive runtime path where `context.get("request")`
returns `None`, so silencing the static check is the right move here.

Generated-By: PostHog Code
Task-Id: 091fa67c-8e3a-4907-a103-c4ca85fd9e5d
The previous version built the `resource_metadata` URL with
`request.build_absolute_uri()`. Because PostHog's default `ALLOWED_HOSTS`
is `*` (`posthog/settings/access.py:58`), an unauthenticated request
with a spoofed `Host` header would have produced a 401 advertising
`resource_metadata="https://attacker.example/.well-known/oauth-protected-resource"`,
which an MCP-style client following the hint would fetch from the
attacker-controlled origin.

Switch to `posthog.utils.absolute_uri`, which pins URLs to the configured
`settings.SITE_URL`. This mirrors the same defensive pattern the helper
already enforces for other code paths (it raises
`PotentialSecurityProblemException` for cross-host absolute URIs).

Side effects:

- `HttpRequest` is no longer consulted, so the no-request fallback and
  its test are gone (which also removes the `cast(ExceptionContext, {})`
  workaround that the previous commit added for `ty`).
- Added a regression test (`test_hint_ignores_host_header`) that drives a
  spoofed `Host: attacker.example` request and asserts the hint still
  points at `SITE_URL`.
- `absolute_uri` is imported lazily inside the handler, matching the
  existing `exceptions_hog` deferral that fixed the bootstrap-time
  `AppRegistryNotReady` regression.

Generated-By: PostHog Code
Task-Id: 091fa67c-8e3a-4907-a103-c4ca85fd9e5d
@rafaeelaudibert rafaeelaudibert requested review from a team, MattBro and fercgomes and removed request for a team May 25, 2026 18:51
@rafaeelaudibert rafaeelaudibert merged commit 09d3945 into master May 27, 2026
225 of 226 checks passed
@rafaeelaudibert rafaeelaudibert deleted the posthog-code/api-401-resource-metadata-hint branch May 27, 2026 17:43
@deployment-status-posthog
Copy link
Copy Markdown

deployment-status-posthog Bot commented May 27, 2026

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-05-27 18:08 UTC Run
prod-us ✅ Deployed 2026-05-27 18:26 UTC Run
prod-eu ✅ Deployed 2026-05-27 18:31 UTC Run

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