Repository navigation
fix(server): send WWW-Authenticate on every 401 - #1122
Conversation
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
RecommendationRequest changes before merge. The 401 behavior at WhyRFC 9110 §15.5.2 requires every 401 to carry a WhatEvery 401 carries one bearer challenge. The server builds both values once at boot from the fixed OIDC resource identifier. The value does not use
How
ImpactA 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 Blocking
Optional
Tradeoff and liabilityInvariant 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 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
ValidationReviewed head: GQT cannot express an HTTP response header. The rows, counts, column types, and query errors are unchanged. The owner is Before, sources at
Result: failed, exit 101. 678 rows were HTTP 401 with no After, unmodified Result: passed. 3 unit tests and 4 integration tests. A local probe on this head, reverted and not pushed, also saw Not run: the workspace test graph, clippy, GQT, S3, and Azure. No live OAuth client. The SDK prefix rule was read from Inline notes
|
…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.
…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
What & why
Every 401 the server returns now carries a
WWW-Authenticatechallenge, on REST routes and/mcpalike. Before, only/mcpsent 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.BearerBearer error="invalid_token"Bearer resource_metadata="<url>"Bearer error="invalid_token", resource_metadata="<url>"The challenge belongs to the authentication decision, not to a router:
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/mcplayer read the header a second time case-insensitively, sobearer <valid token>was refused as "missing" and/mcpthen told the client the token was invalid.ApiError::unauthorizednow takesUnauthenticated::{NoCredential, Refused}, carried as a body-lessApiErrorDetailsvariant classedRefusal, so thedispositionfeat(server)!: say on every error body whether the request ran #1076 added is unchanged for every 401.Host,Originor the path./mcp's own layer keeps onlyCache-Control: no-store.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.jsonwas regenerated through the drift test; the diff againstmainis those additions only.Backing issue / RFC
issue_1020tests below)Checklist
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.mdLocal verification
cargo test -p omnigraph-server --locked --lib --test integration --test boot_settings --test multi_graph -- --test-threads=6on the tree merged with888223b7: 217 lib, 275 integration (including the OpenAPI drift test), 43 boot settings and 8 multi-graph tests passauth_policy::issue_1020_every_401_carries_the_one_bearer_challengeandmcp::static_token_server_serves_mcp_without_oauth_discoveryfail; after it, they passauth_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 passescargo fmt --all --check— cleancargo clippy -p omnigraph-server --all-targets --locked -- -D warnings -W clippy::dbg_macro— cleanpython3 scripts/check-docs.py— OK;typoson the changed files — cleanNew tests:
auth_policy::issue_1020_every_401_carries_the_one_bearer_challengetakes every protected operation from the served OpenAPI document (so a newly declared route is covered as declared), adds/mcpand 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, lowercasebearerwrong token, twoAuthorizationheaders, 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.challenge::tests::*_issue_1020own the header reading and the two challenge forms.tests/support/oidc.rsso 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 bareBeareras 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.Authorizationheaders stay 401 (RFC 6750 would say 400invalid_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.GET /graphs/discoverystays 401, now withBearer; changing its status is a separate decision.checkResourceAlloweddoes), while one naming/mcpcovers only that endpoint. Guidance only, no runtime change.The branch merges
mainthrough #1117, whoseinto_responsetakes the status fromerror.status(); the challenge reason is copied off the error beforeinto_judged_outputconsumes its details. It also mergesmainafter #1076:unauthorizedsits on itsApiError::new, the reason is aRefusal-class detail, and the 401 OpenAPI entry goes through itsdocument_error(replacing an equivalent helper this branch had added).