Repository navigation
server: a signed-in person the admission file does not name gets 401 invalid_token, so OAuth clients sign in again #1172
Description
What happens
A server started with --oidc-identity-trust answers 401 error="invalid_token" to an access token whose signature, issuer, audience, organization and lifetime all check out, when the token's subject is not in the admission file. That is the same answer as for a forged or expired token.
OidcIdentityTrust::verify_atreturnsNonewhen the subject has no mapping (oidc_identity.rs:368). That is the sameNoneit returns for a bad signature.require_bearer_authturns everyNoneintoUnauthenticated::Refused(handlers.rs:425), so the challenge carrieserror="invalid_token".- The MCP guide documents this: "When a bearer credential was presented and refused (expired, wrong audience, unknown subject), the challenge also carries
error="invalid_token", which tells a client to refresh or sign in again" (docs/user/operations/mcp.md, Sign-in and discovery).
Why it matters
For an unknown subject, refreshing or signing in again cannot help. The person signs in, the authorization server issues a new token for the same subject, and the server refuses it the same way. A standard OAuth or MCP client that follows RFC 6750 §3.1 then does one of two things:
- it signs in again, in a loop;
- it reports a failed sign-in, which is not the cause: the person is signed in but not admitted to this resource.
The person cannot tell "your sign-in did not work" from "you are not allowed here", and neither can the client. RFC 6750 §3.1 separates the two: invalid_token for a token that is expired, revoked or malformed, and 403 for a request the token cannot authorize.
A related case: when the admission file's expires_at passes (a publisher outage), every OIDC request gets the same 401 invalid_token. Every client signs in again, and that cannot help either. The server answers 503 with Retry-After when admission is closed elsewhere.
What a fix could look like
Classify why a token was not accepted, and keep the answer bounded and typed:
| Cause | Answer |
|---|---|
| Signature, issuer, audience, organization, lifetime, delegation claims | 401 invalid_token (unchanged) |
| Valid token, subject not admitted | 403 with a typed error code (for example not_admitted), no sign-in challenge |
| Admission snapshot expired | 503 with Retry-After, or 401 without error="invalid_token" |
Tradeoffs to settle:
- What a 403 tells the holder. It says the token was well formed and correctly signed. The holder already has the token, and the answer names no other subject, so it discloses little. The order of checks should stay as it is, with a subject lookup only after the signature and claims pass.
- Offboarding. Removing a person from the admission file would turn their next request into a 403 instead of a 401. That is the truthful answer, and clients stop rather than retry.
- The 403's error code. RFC 6750's
insufficient_scopemeans "a token with more scope would work", which is not true here. So a 403 should carry the server's own error code, not that challenge.
A test belongs at the server tier, next to the existing challenge tests. It should send a correctly signed token for an unmapped subject and assert the status, the error code and the absence of error="invalid_token". The MCP guide's sentence above changes with it.
Related: #1025 (OAuth for standard clients) and #1122 (the challenge on every 401).
Activity
ragnorc commentedon Oct 26, 2026
Summary
A person can hold a valid OIDC access token and still be absent from the admission file. The server then answers 401 with
error="invalid_token". A forged token and an expired token get that same answer. RFC 6750 says a client may take a new token and retry. A new token for the same subject fails again. The client signs in again, or it reports a failed sign-in. The person is signed in. This server has not admitted them.Reproduction
Reproduced on
mainatda1c46368c33876d61615add0ceb6778f9ce2b58.Environment: Linux,
rustc 1.97.1(the pin inrust-toolchain.toml). The check uses the server integration router frombuild_app. That is the routerserveinstalls. It uses the existing OIDC test key and trust file (tests/fixtures/oidc-test-key.pem,tests/support/oidc.rs). No separate TCP process.I added a temporary test,
auth_policy::issue_1172_unadmitted_subject_is_not_an_invalid_token, ran it, and removed it. The tree is clean. The test sendsGET /graphs/discoverywith four locally signed RS256 tokens. The route returns 200 for any admitted identity, so the success case does not depend on a Cedar grant.Result: failed, 0 passed, 1 failed. The failure is the unadmitted subject. The other three cases match the current contract.
WWW-Authenticatecodedisposition.actionaliceuser_unadmittedBearer error="invalid_token", resource_metadata="https://data.example/.well-known/oauth-protected-resource/clusters/A/incarnations/one"unauthorizedfixalice, last signature character changedunauthorizedfixalice,expalready pastunauthorizedfixThe three failures are the same status, the same header, and the same body:
{"error":"invalid bearer token","code":"unauthorized","disposition":{"effects":"none","action":"fix"}}.The report matches this result.
verify_atreturnsNonefor a missing subject (oidc_identity.rsline 368).require_bearer_authmaps everyNonetoUnauthenticated::Refused(handlers.rslines 425–429). The guide says an unknown subject getserror="invalid_token"(docs/user/operations/mcp.md, Sign-in and discovery).The report also says an expired admission file produces the same 401. I reproduced that at
verify_atwith the fixed clock in the existing unit tests (cargo test -p omnigraph-server --lib oidc_identity::tests::, 7 passed).missing_or_corrupt_projection_expires_without_network_fallbackandoriginal_token_and_snapshot_deadlines_never_slideshowverify_atreturnsNoneoncenow >= expires_at. I did not leave an HTTP server up for the 300 second snapshot life. The HTTP mapping is the sameNonepath the table above already shows.Operation admission already answers 503 with
Retry-After: 60(ApiError::admission_closedinlib.rs, header written when the disposition action isretry). That path is a different gate. I confirmed it in source. I did not call it in this run.Root cause
OidcIdentityTrust::verify_atreturnsOption<AuthenticatedActor>(oidc_identity.rslines 308–372). These causes all returnNone:expires_athas passed (lines 313–317)principals(line 368, the?on the map lookup)authenticate_bearer_token(lib.rslines 1083–1106) folds thatOptiontogether with static tokens and data tokens.require_bearer_authhas one failure arm.ChallengerendersRefusedaserror="invalid_token"(challenge.rslines 20–25 and 90–101).The server therefore cannot say "who are you" apart from "you are not on the list" apart from "the list itself has expired". The body makes the split worse.
disposition.actionforunauthorizedisfix: send the same request again and it fails the same way (ErrorCode::action).WWW-Authenticatetells an RFC 6750 client the opposite: the token is invalid, and the client may take a new token and retry./mcpuses the samerequire_bearer_auth(mcp.rsline 97). The MCP integration test groups subjectunknownwith a wrong audience and expectsinvalid_token(mcp.rslines 427–442). That test records the defect as the contract.Recommended fix
Make
verify_atreturn a typed result. Keep one check order: signature and claims first, then the subject lookup, and the snapshot lifetime before either.error="invalid_token"(unchanged)unauthorized, actionfixWWW-Authenticatenot_admitted, actionfixinvalid_tokenservice_unavailable, actionretry,Retry-After: 60not_admittedbelongs in theErrorCoderegistry (omnigraph-api-types). Its action isfix: the same token fails the same way. The message says the subject is not admitted. It names no other subject. The success value should keep theclient_idthat line 345 already checks, so issue #1173 does not parse the token again.actor_idstaysprincipal:<id>until that issue.Why this shape:
resource_metadatahere. A 401 with or withoutinvalid_tokenstarts sign-in. Sign-in issues a token for the same subject.insufficient_scopeis the wrong 403. RFC 6750 §3.1 uses it when a token with more scope would work, and it is HTTP 403. The MCP spec then tells the client to start step-up authorization. This server's OIDC tokens have no scope claim. More scope cannot add a row to the admission file. A step-up loop replaces the sign-in loop.forbiddenis the compatible patch, and it is the wrong code.forbiddenalready means an admitted actor failed the policy gate. Its disposition action isrefresh(read state, then decide whether to send again). An unadmitted subject has no graph state to refresh. After more changes, clients still cannot tell "not on the list" from "Cedar refused this action".Retry-After: 60is the existing constant. A new interval needs its own reason. The message must say the identity snapshot expired, so an operator does not look at workload admission.ErrorCodeis a closed serde enum, and contract0.14has shipped (v0.14.0). A client that decodescodewith the old enum will fail to parse anot_admittedbody. MCP clients that read only the status andWWW-Authenticatestay safe. Ship the enum change with the CLI and regenerateopenapi.jsonin the same change. If the 0.14 code list is frozen, do the 403 with no challenge now and addnot_admittedon the next contract. The status change is what stops the OAuth loop. The new code is what keeps policy denial and admission denial apart.Update the tests and the guides in that change. Split
token("unknown", RESOURCE)out of the MCP test that expectsinvalid_token. Change the sentence indocs/user/operations/mcp.mdthat lists an unknown subject with an expired token and a wrong audience. Aligndocs/user/operations/server.md,docs/user/operations/troubleshooting.md, the OpenAPI challenge text inhttp_contract.rs, and the challenge section of RFC 0003. Add achangelog.dnote. The regression test is the one above: unadmitted subject is 403 with noinvalid_token; a bad signature and an expired token stay 401invalid_token;alicestays 200.Liability
The observable contract at the HTTP boundary is one status for three facts. A standard OAuth or MCP client follows the challenge, not
disposition. It retries a token that cannot succeed. During a publisher outage every OIDC client does that together, and the authorization server sees a sign-in storm that does not restore access. Operators who count 401 responses cannot separate a forged token, an unknown subject, and an expired snapshot.The fix keeps the refusal. A missing row still does not become an actor. The subject lookup stays after the signature check, so a caller who cannot produce a valid signature still receives 401 and learns nothing about the list. The holder of a valid token learns that the signature was accepted. That holder already has the token. The response names no other subject. This is the disclosure we accept. Hiding it forces the retry loop and a false "sign-in failed" report. The work is one map lookup the server already does. The design spends that small disclosure to keep the client contract true and the result typed. Fail-closed behavior stays.
Removing a person from the file then yields 403
not_admittedon the next request. That is the true answer. Clients that honorfixstop. Document that for operators.Lance
Lance does not affect this defect. OmniGraph pins unmodified Lance 13.0.0 (
Cargo.lock,docs/dev/lance.md). The latest stable Lance release is v13.0.0 (2026-10-07). v14.0.0-rc.2 and v15.0.0-beta.1 are prereleases. Their notes do not mention OAuth, OIDC, JWT, or bearer challenges. A search of open Lance issues and pull requests for those terms found nothing.require_bearer_authruns before graph resolution and opens no dataset. No Lance page in the domain map describes this HTTP challenge. A Lance bump cannot change this status.Related work
client_idis checked and then dropped. That is the success path. This issue is the failure path. They can share a typed identity value. Neither one covers the other.WWW-Authenticate, and every refused bearer getsinvalid_token. That is right for a bad token. It is wrong for an unadmitted subject. The MCP test and the guide locked that choice in.verify_at. They relayWWW-Authenticate. A 401 from this bug can still make a renewal client fetch a token the server will refuse again.ErrorCoderegistry that RFC points at, not in a side channel.docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md— admission is an explicit subject-to-principal list, and actor id is the local principal. The RFC requires an admitted subject. It does not say to report a missing row as an invalid token.insufficient_scopefor a scope step-up. https://modelcontextprotocol.io/specification/2025-11-25/basic/authorizationNo open pull request changes
OidcIdentityTrust::verify_ator this 401 mapping.Supersession
Nothing supersedes this issue. #1173 and #1025 are related and narrower or broader in a different way. I did not close this issue.
Limitations
serveuses.verify_atreturnsNone, and the HTTP test shows what the server does withNone.insufficient_scoperules, plus the sentence already indocs/user/operations/mcp.md. The OmniGraphomnigraph mcpbridge relays the challenge. It does not itself start an OAuth sign-in.ragnorc commentedon Oct 26, 2026
Reproduced against
omnigraph-serverv0.14.0.Setup.
--oidc-identity-trust. Its admission file has one key and one principal:{"subject": "user_alice", "principal_id": "alice"}.https://127.0.0.1:8791. The issuer washttps://127.0.0.1:8791/oauthand the resourcehttps://127.0.0.1:8791/mcp.iss,aud,org_idandclient_id, and a 300-second life; onlysubdiffered.GET /graphs/discoverywith each token:subWWW-Authenticateuser_alice{"graphs":[{"graph_id":"knowledge",…},{"graph_id":"notes",…}]}user_malloryBearer error="invalid_token", resource_metadata="https://127.0.0.1:8791/.well-known/oauth-protected-resource/mcp"{"error":"invalid bearer token","code":"unauthorized","disposition":{"effects":"none","action":"fix"}}user_mallory's answer is the same one the server gives a forged or expired token:invalid_token, a pointer back to the sign-in metadata, andaction: "fix". A refresh givesuser_mallorya new, correctly signed token, and the server refuses it the same way. A client that follows RFC 6750 therefore signs in again and is refused again.Omnigraph Studio's new OAuth client (ModernRelay/omnigraph-studio#12) guards against this on its side: it refreshes once, then shows "Not admitted" and stops. Other standard clients won't have that guard.
The test bed is
script/dev-sign-inin that PR. It starts a v0.14.0 server with an admission file, behind a TLS front with its own CA, and a stand-in sign-in service whose current subject can be switched withPOST /oauth/_bed {"subject": "user_mallory"}. A server-side test can do the same with a token for an unmapped subject from the existingoidc_identity_tests.rsfixtures.