Skip to content

fix(server): send WWW-Authenticate on every 401 - #1122

Merged
ragnorc merged 6 commits into
ModernRelay:mainfrom
ragnorc:claude/omnigraph-issue-1020-a9791f
Oct 22, 2026
Merged

ragnorc merged 6 commits into
ModernRelay:mainfrom
ragnorc:claude/omnigraph-issue-1020-a9791f

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What & why

Every 401 the server returns now carries a WWW-Authenticate challenge, on REST routes and /mcp alike. Before, only /mcp sent one, and only on an OIDC server, so a REST client could not discover OAuth sign-in and no other 401 met RFC 9110 §15.5.2.

Server No credential Refused bearer credential
Static or signed data tokens Bearer Bearer error="invalid_token"
OIDC resource identity Bearer resource_metadata="<url>" Bearer error="invalid_token", resource_metadata="<url>"

The challenge belongs to the authentication decision, not to a router:

  • One reading of Authorization (crates/omnigraph-server/src/challenge.rs). The scheme is case-insensitive (RFC 9110 §11.1) and one or more spaces precede the token (RFC 6750 §2.1). Before, authentication took only the exact prefix "Bearer " while the /mcp layer read the header a second time case-insensitively, so bearer <valid token> was refused as "missing" and /mcp then told the client the token was invalid.
  • A typed outcome on the 401. ApiError::unauthorized now takes Unauthenticated::{NoCredential, Refused}, carried as a body-less ApiErrorDetails variant classed Refusal, so the disposition feat(server)!: say on every error body whether the request ran #1076 added is unchanged for every 401.
  • One rendering layer outside every router adds the header to every 401, including the handler 401s and any future one. Both header values are built once from the boot-fixed OIDC resource, never from Host, Origin or the path. /mcp's own layer keeps only Cache-Control: no-store.
  • OpenAPI. Every protected operation's 401 documents WWW-Authenticate, in the <code> when … style feat(server)!: say on every error body whether the request ran #1076 uses for 404/405. The four /cluster/* operations gain the 401 they always returned but never documented. openapi.json was regenerated through the drift test; the diff against main is those additions only.

Backing issue / RFC

Checklist

  • Change is focused (one logical change)
  • Tests added/updated for behavior changes
  • Public docs updated: docs/user/operations/{server,mcp,troubleshooting}.md, RFC 0003 amendment with a decision-log entry, RFC A1 disposition, changelog.d/www-authenticate-on-every-401.fixed.md
  • Reviewed against docs/dev/invariants.md: trust is still established only at the boundary (invariant 10); nothing is read from the request to build the challenge; no storage effect, so the DST cost golden is unchanged

Local verification

  • cargo test -p omnigraph-server --locked --lib --test integration --test boot_settings --test multi_graph -- --test-threads=6 on the tree merged with 888223b7: 217 lib, 275 integration (including the OpenAPI drift test), 43 boot settings and 8 multi-graph tests pass
  • Before the fix (source reverted, new tests kept): auth_policy::issue_1020_every_401_carries_the_one_bearer_challenge and mcp::static_token_server_serves_mcp_without_oauth_discovery fail; after it, they pass
  • Clean baseline before any change: auth_policy::, mcp::, openapi:: (148 passed)
  • OMNIGRAPH_UPDATE_OPENAPI=1 cargo test -p omnigraph-server --test integration openapi::openapi_spec_is_up_to_date, then the drift test passes
  • cargo fmt --all --check — clean
  • cargo clippy -p omnigraph-server --all-targets --locked -- -D warnings -W clippy::dbg_macro — clean
  • python3 scripts/check-docs.py — OK; typos on the changed files — clean
  • Not run: the canonical workspace test graph and workspace clippy, CLI tests, GQT, S3/Azure suites (no server-side change reaches them beyond a response header)

New tests:

  • auth_policy::issue_1020_every_401_carries_the_one_bearer_challenge takes every protected operation from the served OpenAPI document (so a newly declared route is covered as declared), adds /mcp and the engine catalogue, and runs them on a static-token, a signed-data-token and an OIDC server with five failing credentials (none, Basic, wrong token, lowercase bearer wrong token, two Authorization headers, plus a wrong-audience OIDC token). It checks the 401 and the exact header, that the document records the header, that lowercase and multi-space schemes authenticate on all three servers, and that open-mode discovery's 401 is challenged.
  • In-source challenge::tests::*_issue_1020 own the header reading and the two challenge forms.
  • The static-token MCP test now expects the challenge; the OIDC test fixture moved to tests/support/oidc.rs so both suites share it.

Notes for reviewers

Decisions the issue's assessment left open:

  • error="invalid_token" without OIDC. A refused bearer gets it on static and signed-token servers too, not a bare Bearer as the issue text reads. The outcome and the server's discovery configuration are independent facts; this tells every client "wrong token" apart from "no token". RFC 6750 allows either.
  • Lowercase scheme authenticates. A user-visible change, noted in the changelog.
  • Several Authorization headers stay 401 (RFC 6750 would say 400 invalid_request) with the no-credential challenge: they present no single credential and nothing is checked, so the header asks for one, as the body does, rather than telling the client to refresh.
  • Open-mode GET /graphs/discovery stays 401, now with Bearer; changing its status is a separate decision.
  • Resource identifier (RFC 9728 §3.3). Every route's challenge names the one resource identifier, so a strict client uses the metadata only on the URL the identifier names. The MCP guide says a service-root identifier covers every route for clients that accept a path prefix (as the MCP TypeScript SDK's checkResourceAllowed does), while one naming /mcp covers only that endpoint. Guidance only, no runtime change.

The branch merges main through #1117, whose into_response takes the status from error.status(); the challenge reason is copied off the error before into_judged_output consumes its details. It also merges main after #1076: unauthorized sits on its ApiError::new, the reason is a Refusal-class detail, and the 401 OpenAPI entry goes through its document_error (replacing an equivalent helper this branch had added).

Only /mcp sent a challenge, and only with OIDC, so a REST client could
not discover that a server accepts OAuth tokens and no other 401 met
RFC 9110's requirement for a challenge.

Authentication now reads the Authorization header once (scheme
case-insensitive, one or more spaces before the token) and records on
its 401 whether a bearer credential was presented. One layer outside
every router renders the challenge, built once from the boot-fixed OIDC
resource, on every 401: Bearer, plus error="invalid_token" for a refused
credential, plus resource_metadata with OIDC. /mcp keeps only its
no-store header, so REST and MCP cannot drift. The OpenAPI document
records the header on every protected 401, and the /cluster operations
gain the 401 they always returned.

Closes ModernRelay#1020.
…e-1020-a9791f

# Conflicts:
#	crates/omnigraph-server/src/http_contract.rs
#	crates/omnigraph-server/src/lib.rs
#	openapi.json
@ragnorc
ragnorc requested a review from aaltshuler as a code owner October 21, 2026 17:37
@cursor

cursor Bot commented Oct 21, 2026

Copy link
Copy Markdown

Recommendation

Request changes before merge. The 401 behavior at f76abb93 is sound. Rebase onto current main (10f5517c) first. crates/omnigraph-server/src/lib.rs conflicts with #1117 in ApiError::into_response, and that function is what attaches the challenge reason.

Why

RFC 9110 §15.5.2 requires every 401 to carry a WWW-Authenticate challenge. On current main, only POST /mcp on an OIDC server sends one. A REST client cannot discover OAuth sign-in from a 401. Authentication and /mcp also read Authorization separately. Authentication accepts only the exact prefix Bearer . /mcp treats any case of bearer as a presented token. A valid token sent as bearer <token> is refused as missing, and /mcp then says the token is invalid.

What

Every 401 carries one bearer challenge. The server builds both values once at boot from the fixed OIDC resource identifier. The value does not use Host, Origin, or the request path.

Server No bearer credential Refused bearer credential
Static or signed token Bearer Bearer error="invalid_token"
OIDC Bearer resource_metadata="<url>" Bearer error="invalid_token", resource_metadata="<url>"

How

challenge::bearer_token is the one reader of Authorization. The scheme compare ignores ASCII case (RFC 9110 §11.1). One or more spaces separate the scheme from the token (RFC 6750 §2.1). ApiError::unauthorized stores Unauthenticated::{NoCredential, Refused} as a detail with no JSON field, classed Refusal. The disposition from #1076 stays effects: none, action: fix. IntoResponse copies the reason onto the response. One layer outside every router writes the header, including a handler 401 and a future 401. /mcp keeps only Cache-Control: no-store. OpenAPI records the header on every protected 401. The four /cluster/* operations gain the 401 they already returned.

Impact

A client can tell "send a token" from "this token was refused" on every route. With OIDC, the challenge names the same protected-resource metadata URL on every route. The JSON body stays code, disposition, and error. bearer <token> and Bearer <token> now authenticate. A Bearer challenge with no resource_metadata names no metadata URL, so there is no OAuth document to fetch.

Blocking

  1. Rebase onto 10f5517c before merge. fix(server): log the status an error response sends #1117 rewrote ApiError::into_response. It calls with_request_facts, takes error.status(), and then moves the error into into_judged_output. This branch still computes the status with status_of and inserts Unauthenticated after into_output. Those edits conflict. On rebase, copy the reason off the error before into_judged_output consumes the detail, and keep fix(server): log the status an error response sends #1117's status. A resolution that drops the insert answers every refused credential with the no-credential challenge. fix(server): settle an embedded error's facts before it is logged #1123 edits this same function again.

Optional

  1. Repeated Authorization headers. Two or more header values stay 401. When one of them is a bearer credential, the challenge says error="invalid_token" and the body says "one bearer credential is required". The server does not check the token. RFC 6750 §3.1 calls a repeated credential invalid_request. The pull request records the choice to keep 401. The header and the body then ask for two different recoveries. A client that refreshes on invalid_token will refresh a request that never presented one credential. Use invalid_token only after one bearer credential is refused, or give the malformed request its own outcome.

  2. Service-root wording in the MCP guide. The new paragraph in docs/user/operations/mcp.md says to name the service root when REST clients should discover sign-in. RFC 9728 §3.3 requires the metadata resource to equal the URL the client requested. This server has one resource identifier for every route, so a strict client rejects that metadata for any other URL. The MCP TypeScript SDK checkResourceAllowed accepts a path prefix, so a service-root identifier works for that SDK on /mcp. An identifier of https://host/mcp matches only that endpoint. Say that the service root is for clients that accept a prefix.

Tradeoff and liability

Invariant 10 puts trust at the boundary. The HTTP contract adds one more rule: a 401 must say how to authenticate, and one reader must decide whether a bearer credential was presented. The old code made that decision twice, and only one route rendered it.

This change puts the decision in authentication and the bytes in one outer layer. A new route or a handler 401 gets the header with no extra middleware. The OpenAPI walk checks every secured operation, so a new one is covered as declared.

The new pieces are one enum, one boot-time Challenge, and one response extension. The extension carries the authentication result across IntoResponse to the outer layer. It is the same fact, not a second source. Five more changes of this kind add a case to the enum and a clause in Challenge::header. They do not add a layer per router.

The part we leave coarse is a third outcome for a malformed request. Two outcomes cover the issue. The repeated-header case is the remaining mismatch, and it is rare. There is no storage, format, or Lance effect. The pinned Lance 13.0.0 substrate does not build these responses.

Overlap

Validation

Reviewed head: f76abb93ef1c4b6423562a90c906236ba11167ec.
Current main: 10f5517c5d41c433005603b0c5a29358ec9560a8.
Merge base: 235cd3b9a18aa4b89da57c796e41f504d7ef10b4. From that base to current main, handlers.rs, mcp.rs, and http_contract.rs have no auth diff, so the pre-fix 401 behavior on 235cd3b9 is the behavior on current main.

GQT cannot express an HTTP response header. The rows, counts, column types, and query errors are unchanged. The owner is crates/omnigraph-server/tests/integration/auth_policy.rs, which this pull request already extends.

Before, sources at 235cd3b9, with this pull request's test. The OpenAPI document assert was skipped for that run so the HTTP check could report the missing header. Command:

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-server --locked --test integration -- auth_policy::issue_1020_every_401_carries_the_one_bearer_challenge --exact

Result: failed, exit 101. 678 rows were HTTP 401 with no WWW-Authenticate. Static and signed servers failed for /mcp as well. The six OIDC POST /mcp rows were absent from the failure list. That is the old /mcp-only OIDC challenge.

After, unmodified f76abb93. The same test, the three challenge:: unit tests, mcp::mcp_authenticates_every_request_and_enforces_resource_and_http_bounds, mcp::static_token_server_serves_mcp_without_oauth_discovery, and openapi::openapi_spec_is_up_to_date.

Result: passed. 3 unit tests and 4 integration tests.

A local probe on this head, reverted and not pushed, also saw Cache-Control: no-store on an MCP 401, and a body whose keys are code, disposition, and error, with effects none and action fix.

Not run: the workspace test graph, clippy, GQT, S3, and Azure. No live OAuth client. The SDK prefix rule was read from checkResourceAllowed in the MCP TypeScript SDK, not from a client run against this server. The temporary test edit was restored. Nothing was pushed.

Inline notes

  • crates/omnigraph-server/src/lib.rs (the extensions_mut().insert(reason) in ApiError::into_response): On rebase onto 10f5517c, copy this reason off the error before into_judged_output consumes the detail, and keep the status from fix(server): log the status an error response sends #1117 (error.status() after with_request_facts). This function conflicts with that commit. A resolution that drops the insert answers every refused credential with the no-credential challenge.

  • crates/omnigraph-server/src/challenge.rs (bearer_token, the branch that returns Refused for more than one header): Optional. When more than one Authorization value is present and one of them is a bearer credential, this returns Refused. The body still says that one credential is required, and the token is not checked. RFC 6750 §3.1 calls a repeated credential invalid_request. invalid_token tells a client to refresh. The pull request records the choice to keep 401. The header and the body then ask for two different recoveries.

  • docs/user/operations/mcp.md (the sentence that says to name the service root for REST clients): Optional. A service-root identifier is a path prefix of every route. The MCP TypeScript SDK accepts that prefix (checkResourceAllowed). RFC 9728 §3.3 requires the metadata resource to equal the URL the client requested. A strict REST client still rejects one identifier for every route. Say that the service root is for clients that accept a prefix.

…e-1020-a9791f

# Conflicts:
#	crates/omnigraph-server/src/lib.rs
Several Authorization values present no single credential and nothing
is checked, so the challenge asks for one instead of answering
invalid_token, which tells a client to refresh. The header now matches
the body's "one bearer credential is required".

The MCP guide and RFC 0003 scope the service-root resource identifier
to clients that accept a path prefix: RFC 9728 has a strict client use
the metadata only on the URL the identifier names.
@ragnorc
ragnorc enabled auto-merge October 21, 2026 23:13
@ragnorc
ragnorc added this pull request to the merge queue Oct 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 21, 2026
…e-1020-a9791f

# Conflicts:
#	docs/rfcs/2026-10-16-graph-access-serving-and-cross-process-writes.md
…e-1020-a9791f

# Conflicts:
#	crates/omnigraph-server/src/mcp.rs
@ragnorc
ragnorc enabled auto-merge October 22, 2026 01:11
@ragnorc
ragnorc added this pull request to the merge queue Oct 22, 2026
Merged via the queue into ModernRelay:main with commit 97788d3 Oct 22, 2026
32 checks passed
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.

server: send WWW-Authenticate on every 401, not only /mcp

1 participant