feat(http): standardize the 401 response, with a proxy-config note - #36
Merged
Merged
Conversation
A 401 used to be whatever Falcon produced: a styled HTML page, served to every caller including the Arrow client, which then pasted the entire page into `RpcError.error_message`. The only machine-readable signal was the status code, so a client wanting to refresh a token and retry had to substring-match an English sentence. Worse, the most common production cause was invisible. Schemes that read a proxy-injected header — mTLS as `x-forwarded-client-cert`, a proxy proof — fail identically whether the caller sent a bad certificate or the proxy was never configured to forward one. The second is far more likely during a deployment, and the response said nothing about it. Every 401 now carries a coarse reason code from a closed six-member set, on a `VGI-Auth-Reason` header and in a JSON envelope; `Accept: text/html` still gets the page. The code names the *stage* that refused, never a verifier's diagnosis — every proxy-proof outcome collapses onto `proxy_required`, which is what keeps the uniform-rejection rule of the proxy-proof spec intact. The proxy note is derived from server configuration, not from what failed on a given request, so it is identical on every 401 and discloses nothing a caller could not read off the capability headers. That is also what makes it right in the case it exists for: when the proxy isn't forwarding the header, *everything* 401s, and every one of those says so. Header dependencies are discovered from the installed authenticators — the mTLS factories and the require-mode proof gate declare their own, and chain/require-all propagate — with `make_wsgi_app(proxy_auth_headers=...)` for a custom callback. Client-side, 401s raise `AuthenticationError` (an `RpcError` subclass, so existing catch sites are unaffected) exposing `reason`/`detail`/`proxy_hint`, and a body that isn't the envelope degrades instead of being dumped whole — a 401 can come from a gateway or WAF the service never sees. Contract: docs/unauthorized-spec.md, conformance group `TestUnauthorized` (capability-gated on `VGI-Auth-Reason`, so unported languages skip cleanly). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| """ | ||
|
|
||
| @staticmethod | ||
| def _post(port: int, accept: str | None = None) -> Any: |
| # 401 responses only — they explain a rejection rather than advertise a | ||
| # capability, so putting them on every response would be noise. Both are | ||
| # CORS-exposed so a browser client can read them cross-origin. | ||
| AUTH_REASON_HEADER = "VGI-Auth-Reason" |
| # CORS-exposed so a browser client can read them cross-origin. | ||
| AUTH_REASON_HEADER = "VGI-Auth-Reason" | ||
| """Machine-readable reason code from the closed :class:`AuthReason` set.""" | ||
| AUTH_PROXY_REQUIRED_HEADER = "VGI-Auth-Proxy-Required" |
There was a problem hiding this comment.
Pull request overview
This PR standardizes HTTP 401 (Unauthorized) responses for vgi-rpc’s Falcon-based HTTP transport, providing a closed-set reason code plus an optional, configuration-derived reverse-proxy hint so both machine clients and operators can reliably interpret auth failures.
Changes:
- Introduces a cross-language 401 contract (
VGI-Auth-Reason+ JSON envelope by default; optional HTML whenAcceptrequests it) and wires it into the Falcon error serializer. - Adds a typed client-side
AuthenticationErrorthat parses the envelope and degrades safely for non-envelope 401 bodies. - Adds proxy-dependency discovery (
declare_proxy_headers/proxy_headers_of) to emit a static “check the reverse proxy config” note on every 401 for proxy-dependent services, plus conformance + Python tests and documentation updates.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| vgi_rpc/http/server/_middleware.py | Classifies auth failures and stashes the reason on req.context for 401 serialization. |
| vgi_rpc/http/server/_factory.py | Plumbs proxy-header dependency discovery into app creation and installs the new error serializer. |
| vgi_rpc/http/server/_errors.py | Implements standardized 401 serialization (JSON by default, HTML for browsers), headers, and bounded caching. |
| vgi_rpc/http/_unauthorized.py | Adds the reference implementation for reason codes, typed exceptions, and proxy-header declarations. |
| vgi_rpc/http/_testing.py | Exposes new make_wsgi_app parameters through the sync test client helper. |
| vgi_rpc/http/_proof.py | Marks proxy-proof failures with the coarse proxy_required reason code and propagates proxy header dependency in require mode. |
| vgi_rpc/http/_oauth_pkce.py | Switches missing-cookie auth failures to the new AuthFailure/reason-code mechanism. |
| vgi_rpc/http/_oauth_jwt.py | Adds missing/invalid/expired reason-code separation for JWT auth failures. |
| vgi_rpc/http/_mtls.py | Adds proxy-required vs invalid-credential reason codes for mTLS header/cert parsing and declares proxy header dependency. |
| vgi_rpc/http/_common.py | Defines standardized 401 headers and adds CSS for reason/note display in the HTML page. |
| vgi_rpc/http/_client.py | Parses the standardized 401 envelope into AuthenticationError and truncates/normalizes foreign/HTML 401 bodies. |
| vgi_rpc/http/_bearer.py | Adds missing-vs-invalid bearer reason codes, reason aggregation for chains, and proxy-header propagation in compositions. |
| vgi_rpc/http/init.py | Exports the new public unauthorized/auth types and header constants. |
| vgi_rpc/conformance/_pytest_suite.py | Adds TestUnauthorized conformance group and closed-set reason assertions. |
| vgi_rpc/cli.py | Updates comments to reflect 401 bodies are JSON/HTML rather than plain text. |
| tests/test_unauthorized.py | Adds Python-specific tests for reason selection, proxy note discovery, negotiation, caching bounds, and client parsing. |
| tests/test_bearer.py | Updates bearer auth tests to assert reason-code behavior via AuthFailure. |
| README.md | Documents the new proxy_auth_headers option and links to the 401 spec. |
| mkdocs.yml | Adds the unauthorized response spec to the documentation nav. |
| docs/WIRE_PROTOCOL.md | Updates the wire protocol docs to reflect standardized 401 bodies. |
| docs/unauthorized-spec.md | Adds the new normative cross-language 401 response specification. |
| docs/proxy-proof-spec.md | Aligns proxy-proof rejection uniformity requirements with the new 401 contract. |
| docs/porting-guide.md | Documents how ports should implement the unauthorized response contract. |
| docs/index.md | Highlights standardized 401 behavior as a headline feature. |
| CLAUDE.md | Updates repository guidance to reflect the new 401 contract and implementation points. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+208
to
+210
| declared = getattr(fn, _PROXY_HEADERS_ATTR, ()) | ||
| if isinstance(declared, tuple | list): | ||
| return tuple(str(name) for name in declared) |
| else: | ||
| resp.content_type = falcon.MEDIA_JSON | ||
| resp.data = exc.to_json() | ||
| return "text/html" in (req.get_header("Accept") or "") |
| payload: object = None | ||
| with contextlib.suppress(ValueError): | ||
| payload = json.loads(content) | ||
| if isinstance(payload, dict): |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A 401 was whatever Falcon produced: the styled HTML page, served to every caller including the Arrow client, which then pasted the entire page into
RpcError.error_message. The only machine-readable signal was the status code, so a client wanting to refresh a token and retry had to substring-match an English sentence.Worse, the most common production cause was invisible. Schemes that read a proxy-injected header — mTLS as
x-forwarded-client-cert, a proxy proof — fail identically whether the caller sent a bad certificate or the proxy was never configured to forward one. The second is far more likely during a deployment, and the response said nothing about it. Operators rotate credentials that were never the problem.What changed
A reason code from a closed set (
missing_credential,invalid_credential,expired_credential,insufficient_scope,proxy_required,unauthorized) on aVGI-Auth-Reasonheader and in a JSON envelope.Accept: text/htmlstill gets the page;*/*— what every RPC client sends — gets JSON.The code names the stage, never a verifier's diagnosis. Every proxy-proof outcome — absent, malformed, unknown kid, expired, bad MAC, replayed — collapses onto
proxy_requiredwith an identical detail. That keepsdocs/proxy-proof-spec.md§6's uniform-rejection rule intact rather than carving an exception into it; a new conformance test asserts the uniformity from the outside.The proxy note is derived from server configuration, not from what failed on a given request. It is therefore identical on every 401 from a proxy-dependent service and discloses nothing a caller could not read off the capability headers. That is also what makes it right in the case it exists for: when the proxy isn't forwarding the header, everything 401s, and every one of those says so.
Header dependencies are discovered from the installed authenticators —
mtls_authenticate*and the require-modeproxy_proof_gatedeclare their own viadeclare_proxy_headers, andchain_authenticate/require_allpropagate — withmake_wsgi_app(proxy_auth_headers=[...])for a custom callback the framework can't introspect.Client side: 401s raise
AuthenticationError, anRpcErrorsubclass witherror_typestill"AuthenticationError", so existingexcept RpcError/match="AuthenticationError"call sites are unaffected. It exposesreason/detail/proxy_hint, and appends the hint to the message because the place it actually gets read is a traceback in a deployment log. A body that isn't the envelope degrades rather than being dumped whole — a 401 can come from a gateway, WAF, or SSO portal the service never sees.Contract and coverage
docs/unauthorized-spec.md— new normative spec (added to the mkdocs nav).TestUnauthorized— 8 conformance tests, capability-gated onVGI-Auth-Reasonso unported languages skip cleanly. Two reuseproof_worker_factoryfor the proxy-dependent cases.tests/test_unauthorized.py— 41 Python-specific tests: per-authenticator codes, chain/require-all composition, negotiation, HTML escaping, cache bounding, client parsing.docs/porting-guide.md,docs/WIRE_PROTOCOL.md(which still described 401 bodies as plain text),docs/proxy-proof-spec.md,CLAUDE.md, README/index updated.Behaviour change to flag
bearer_authenticateused to raiseValueError("Missing or invalid Authorization header")for both an absent header and a non-Bearer scheme. It now splits them —missing_credentialvsinvalid_credential— with distinct messages, since telling a caller who sentBasicto "send a credential" is the wrong advice. One test intests/test_bearer.pywas updated accordingly.Not touched: the access log gains no
auth_reasonfield. That would be anaccess-log-spec.mdchange and is easy to add separately if wanted.🤖 Generated with Claude Code