vgi-rpc Unauthorized Response Specification¶
This document is the cross-language contract for the shape of an HTTP 401 from a vgi-rpc service. The Python implementation in this repository is the reference; other-language implementations (Go, Rust, TypeScript, Java, …) MUST implement the contract below so the canonical TestUnauthorized conformance group in vgi_rpc/conformance/_pytest_suite.py passes against them.
1. Scope and motivation¶
Before this contract, a 401 was whatever the server's web framework happened to produce. Two things went wrong with that.
A client could not branch on it. The only machine-readable signal was the status code, which does not distinguish "send a credential" from "your credential expired, refresh it" from "you are who you say and still may not do this". A client wanting to retry after a token refresh had to substring-match an English sentence.
The most common production cause was invisible. Authentication schemes that read a header injected by a reverse proxy — mTLS forwarded as X-SSL-Client-Cert or x-forwarded-client-cert, or 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 common during a deployment, and the 401 said nothing about it. Operators rotated credentials that were never the problem.
This spec fixes both: a coarse reason code from a closed set, and a static note on services whose authentication depends on a proxy.
HTTP-only. Pipe / subprocess / shared-memory / unix / tcp transports do not invoke an authenticate callback at all and never produce a 401.
2. What the reason code is, and is not¶
The reason code names the stage that refused the request. It never carries a verifier's internal diagnosis.
The distinction matters. Telling a caller "your JWT signature did not verify" versus "your JWT expired" is fine — both are facts about a token they hold. Telling them "the kid you supplied is not in my secret map" is not: the caller chose that kid, and echoing per-attempt verifier state back to whoever is probing turns a rejection into an oracle. docs/proxy-proof-spec.md §6 states this as a hard requirement for proxy proof, and this spec keeps it: every proxy-proof outcome — absent, malformed, unknown key, expired, bad MAC, replayed — collapses onto the single code proxy_required.
Implementations MUST NOT add codes outside §3. A failure that maps onto none of them uses unauthorized. Keeping the set closed is what makes it usable: a client that switches on the code needs to know the set will not grow under it in a language it does not control.
3. Reason codes¶
| Code | Meaning | What the caller should do |
|---|---|---|
missing_credential |
No credential was presented at all. | Send one. |
invalid_credential |
A credential was presented and rejected. | Fix or re-obtain it; do not retry unchanged. |
expired_credential |
A well-formed credential outside its validity window. | Refresh and retry. |
insufficient_scope |
The caller was identified but is not permitted. | Do not retry; escalate. |
proxy_required |
The request did not carry evidence that it arrived through the trusted proxy. | Almost always an operator's problem, not the caller's — see §5. |
unauthorized |
Refused, unclassified. | Fallback. |
insufficient_scope is deliberately a 401, not a 403, because the framework's authenticate callback runs before any method is resolved: there is no route yet whose permissions could be evaluated. A service that wants a true 403 raises it from the method body.
3.1 Composition¶
When several alternative credentials are tried (chain_authenticate and its equivalents), the code reported is:
missing_credentialonly when every alternative agreed nothing was presented — the one case where "send a credential" is actionable advice.- otherwise the first code that is not
missing_credential.
When a precondition gate and a credential are ANDed (require_all), the gate runs first, so a gate failure reports the gate's code and the credential is never consulted.
4. Response¶
4.1 Headers¶
| Header | When | Value |
|---|---|---|
VGI-Auth-Reason |
every 401 | One code from §3. |
VGI-Auth-Proxy-Required |
401s from a service whose auth depends on a proxy (§5) | "true". Omitted otherwise — never "false". |
Cache-Control |
every 401 | MUST prevent shared caching (no-store in the reference). A 401 is per-request and flips to 200 on the next attempt with a credential. |
WWW-Authenticate |
when the service declares a challenge | Unchanged from RFC 7235 / RFC 9728 behaviour. |
Both VGI- headers describe a rejection, so they MUST NOT be emitted on successful responses — they are not capability advertisements. When the service uses CORS, both MUST appear in Access-Control-Expose-Headers, otherwise a browser client cannot read them cross-origin and is back to guessing from the body.
4.2 Content negotiation¶
Request Accept contains text/html |
Response |
|---|---|
| yes | The service's styled HTML 401 page. |
no (including */* and an absent header) |
The JSON envelope of §4.3. |
Substring-matching Accept rather than full media-type negotiation is intentional and matches the OAuth browser-redirect rule: the only clients that ask for text/html are browsers, and an RPC client sends */*, which MUST resolve to JSON. A service MAY skip the HTML page entirely and always answer with JSON; a service MUST NOT answer a non-HTML request with HTML.
The HTML page is presentation, not contract — nothing may be parsed out of it. When a service renders one it SHOULD show the reason code and, when applicable, the §5 note, since a human reading the page is exactly the audience for both.
4.3 JSON envelope¶
{
"error": "unauthorized",
"reason": "proxy_required",
"detail": "Missing x-forwarded-client-cert header",
"proxy_hint": "This service only accepts requests that arrive through its configured reverse proxy, which must set the x-forwarded-client-cert header. …"
}
| Field | Required | Notes |
|---|---|---|
error |
yes | Always the literal "unauthorized". Marks the envelope kind so a client can tell it from a framework's default error JSON. |
reason |
yes | A code from §3. |
detail |
yes | Human-readable, may be "". Free text, subject to §2 — never a verifier's per-attempt state. |
proxy_hint |
no | Present only under §5. Absent, not empty, when it does not apply, so its presence alone is a usable signal. |
Content-Type MUST be application/json. Readers MUST ignore unknown fields, and MUST treat an unrecognised reason as unauthorized — that means the server is newer, not broken.
5. The proxy note¶
A service whose authentication can only succeed on requests a reverse proxy has stamped MUST set VGI-Auth-Proxy-Required: true and include proxy_hint on every 401 it produces.
The note is derived from server configuration, not from what failed on this request. A worker that requires proxy-injected evidence emits the identical note whether the proof was absent, the certificate was expired, or the bearer token behind the proxy was simply wrong. Two consequences follow, and both are the point:
- It discloses nothing. The note restates a static property the service already advertises through its capability headers, so it cannot be used to probe which stage rejected a given attempt. This is what lets it coexist with the uniform-rejection rule of
docs/proxy-proof-spec.md§6. - It is still right in the case that matters. When a deployment's proxy is not forwarding the header, every request 401s, and every one of those 401s says so.
A service MUST NOT emit the note when its authentication does not depend on a proxy — a note that appears everywhere teaches operators to ignore it.
5.1 Discovering the dependency¶
An implementation SHOULD discover the dependency from the authenticators it has installed rather than requiring the operator to restate it: the built-in mTLS and proxy-proof authenticators know which header they read. Composition helpers (chain, require-all) MUST carry declarations through, otherwise wrapping an authenticator silently drops the note. An implementation MUST also offer a direct way for an operator to state header names for a custom authenticator the framework cannot introspect — make_wsgi_app(proxy_auth_headers=[...]) in the reference.
A proxy-proof gate contributes its header only in require mode. In allow mode an absent proof never denies, so the note would misdirect.
5.2 Wording¶
The note text is not normative — it is prose for a human. It MUST convey: that the service is only reachable through its proxy; which header names the proxy must set; and that a rejection here is at least as likely to be a proxy misconfiguration as a bad credential.
6. Client behaviour¶
A client receiving a 401 MUST:
- read
reasonfrom the JSON body when present, falling back toVGI-Auth-Reason, falling back tounauthorized; - surface
proxy_hintto whoever sees the error. The reference appends it to the exception message rather than leaving it on an attribute alone, because the place it actually gets read is a traceback in a deployment log; - degrade without raising when the body is not the §4.3 envelope. A 401 can come from an intermediary the service never sees — a gateway, a WAF, an SSO portal — with its own idea of an error body. The reference keeps a bounded prefix of such a body, and replaces an HTML page with a one-line note rather than pasting markup into an exception message.
A client MUST NOT attempt to parse an Arrow IPC stream from a 401 body. The rejection happens before any method is resolved, so no output schema exists.
7. Conformance¶
TestUnauthorized in vgi_rpc/conformance/_pytest_suite.py is the canonical suite. It is capability-gated on the server emitting VGI-Auth-Reason, so a port that has not adopted this contract skips rather than fails.
| Test | Asserts |
|---|---|
test_reason_header_present |
Every 401 carries VGI-Auth-Reason. |
test_reason_in_closed_set |
The code is one of §3. |
test_json_envelope_for_machine_clients |
Accept: */* yields application/json matching §4.3, with reason agreeing with the header. |
test_html_page_for_browsers |
Accept: text/html yields text/html, and the reason header is still present. |
test_not_cached |
Cache-Control forbids shared caching. |
test_no_proxy_note_without_proxy_auth |
A service with no proxy dependency omits VGI-Auth-Proxy-Required and proxy_hint. |
test_proxy_note_when_proxy_required |
A require-mode proxy-proof worker sets the header and includes a non-empty proxy_hint. Gated on the proof_worker_factory fixture. |
test_proxy_rejection_is_uniform |
Absent, malformed, and bad-MAC proofs produce the same reason code and the same detail — the uniform-rejection rule of docs/proxy-proof-spec.md §6, asserted from the outside. |
test_requested_reason_is_honoured |
Each of the four request-classifiable reasons round-trips onto both the header and the body. Gated on conformance_http_auth_reason_port. |
test_unclassified_failure_is_unauthorized |
A rejection naming no reason lands on unauthorized rather than a guess. |
test_reason_codes_are_distinct |
N distinct requests produce N distinct codes. |
test_proxy_required_is_not_request_driven |
A request cannot summon proxy_required on a service with no proxy dependency (§5). |
Runners supply the existing conformance_http_auth_port fixture (a server whose RPC endpoints all 401) and, for the proxy tests, proof_worker_factory.
7.1 Proving the codes are discriminated¶
Membership in the closed set is a weak assertion on its own: a server that stamps unauthorized on every 401 satisfies every test above it. That is the failure mode this section exists to catch, because it defeats the entire point of §3 — a client cannot branch on a constant.
Runners that want the discrimination tests supply one more optional fixture:
| Fixture | Shape |
|---|---|
conformance_http_auth_reason_port |
An int port for a worker whose authenticate reads the X-Conformance-Auth-Reason request header and fails with the matching reason from §3. |
The header is a conformance-fixture affordance, not part of the protocol — a production server must never let a request steer its reason code. The reference worker is tests/serve_conformance_http_auth.py, about ten lines: a dict from header value to reason, raising AuthFailure(reason) on a hit and a bare ValueError on a miss.
Two values are deliberately not requestable, and both are asserted:
proxy_required— derived from server configuration per §5, never from the request. A worker that let a caller summon it would advertise a proxy dependency that does not exist.unauthorized— this is what the absence of a requested reason must produce, so making it requestable would hide whether the fallback path works at all.
A port that omits the fixture skips these four tests. That is a real gap rather than a pass, so it is worth closing: the fixture is cheaper than the contract it protects.