Skip to content

server: a signed-in person the admission file does not name gets 401 invalid_token, so OAuth clients sign in again #1172

Open

Description

@ragnorc

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_at returns None when the subject has no mapping (oidc_identity.rs:368). That is the same None it returns for a bad signature.
  • require_bearer_auth turns every None into Unauthenticated::Refused (handlers.rs:425), so the challenge carries error="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_scope means "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

  1. ragnorc commented on Oct 26, 2026

    @ragnorc
    ContributorAuthor

    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 main at da1c46368c33876d61615add0ceb6778f9ce2b58.

    Environment: Linux, rustc 1.97.1 (the pin in rust-toolchain.toml). The check uses the server integration router from build_app. That is the router serve installs. 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 sends GET /graphs/discovery with four locally signed RS256 tokens. The route returns 200 for any admitted identity, so the success case does not depend on a Cedar grant.

    cargo test -p omnigraph-server --test integration \
      auth_policy::issue_1172_unadmitted_subject_is_not_an_invalid_token \
      -- --exact --nocapture
    

    Result: failed, 0 passed, 1 failed. The failure is the unadmitted subject. The other three cases match the current contract.

    Token Status WWW-Authenticate Body code disposition.action
    Admitted subject alice 200 absent — —
    Valid token, subject user_unadmitted 401 Bearer error="invalid_token", resource_metadata="https://data.example/.well-known/oauth-protected-resource/clusters/A/incarnations/one" unauthorized fix
    alice, last signature character changed 401 same header unauthorized fix
    alice, exp already past 401 same header unauthorized fix

    The 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_at returns None for a missing subject (oidc_identity.rs line 368). require_bearer_auth maps every None to Unauthenticated::Refused (handlers.rs lines 425–429). The guide says an unknown subject gets error="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_at with 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_fallback and original_token_and_snapshot_deadlines_never_slide show verify_at returns None once now >= expires_at. I did not leave an HTTP server up for the 300 second snapshot life. The HTTP mapping is the same None path the table above already shows.

    Operation admission already answers 503 with Retry-After: 60 (ApiError::admission_closed in lib.rs, header written when the disposition action is retry). That path is a different gate. I confirmed it in source. I did not call it in this run.

    Root cause

    OidcIdentityTrust::verify_at returns Option<AuthenticatedActor> (oidc_identity.rs lines 308–372). These causes all return None:

    • a bad signature, issuer, audience, organization, lifetime, or delegation claim
    • a snapshot whose expires_at has passed (lines 313–317)
    • a subject with no row in principals (line 368, the ? on the map lookup)

    authenticate_bearer_token (lib.rs lines 1083–1106) folds that Option together with static tokens and data tokens. require_bearer_auth has one failure arm. Challenge renders Refused as error="invalid_token" (challenge.rs lines 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.action for unauthorized is fix: send the same request again and it fails the same way (ErrorCode::action). WWW-Authenticate tells an RFC 6750 client the opposite: the token is invalid, and the client may take a new token and retry.

    /mcp uses the same require_bearer_auth (mcp.rs line 97). The MCP integration test groups subject unknown with a wrong audience and expects invalid_token (mcp.rs lines 427–442). That test records the defect as the contract.

    Recommended fix

    Make verify_at return a typed result. Keep one check order: signature and claims first, then the subject lookup, and the snapshot lifetime before either.

    Result HTTP Challenge Body
    Signature, issuer, audience, organization, lifetime, or delegation claim failed 401 error="invalid_token" (unchanged) unauthorized, action fix
    Claims accepted, subject absent 403 no WWW-Authenticate new code not_admitted, action fix
    Admission snapshot expired 503 no invalid_token service_unavailable, action retry, Retry-After: 60

    not_admitted belongs in the ErrorCode registry (omnigraph-api-types). Its action is fix: 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 the client_id that line 345 already checks, so issue #1173 does not parse the token again. actor_id stays principal:<id> until that issue.

    Why this shape:

    • 401 is the wrong status for an admitted-signature miss. The MCP authorization spec (2025-11-25) says clients must handle HTTP 401, and a 401 carries resource_metadata here. A 401 with or without invalid_token starts sign-in. Sign-in issues a token for the same subject.
    • insufficient_scope is 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.
    • Reuse of forbidden is the compatible patch, and it is the wrong code. forbidden already means an admitted actor failed the policy gate. Its disposition action is refresh (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".
    • 503 for an expired snapshot reuses a code the client already retries. Sign-in does not publish a new snapshot. The identity RFC already says a publication failure closes OAuth access after at most 300 seconds while the process keeps running. The refresh task runs every 5 seconds. Retry-After: 60 is 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.

    ErrorCode is a closed serde enum, and contract 0.14 has shipped (v0.14.0). A client that decodes code with the old enum will fail to parse a not_admitted body. MCP clients that read only the status and WWW-Authenticate stay safe. Ship the enum change with the CLI and regenerate openapi.json in the same change. If the 0.14 code list is frozen, do the 403 with no challenge now and add not_admitted on 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 expects invalid_token. Change the sentence in docs/user/operations/mcp.md that lists an unknown subject with an expired token and a wrong audience. Align docs/user/operations/server.md, docs/user/operations/troubleshooting.md, the OpenAPI challenge text in http_contract.rs, and the challenge section of RFC 0003. Add a changelog.d note. The regression test is the one above: unadmitted subject is 403 with no invalid_token; a bad signature and an expired token stay 401 invalid_token; alice stays 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_admitted on the next request. That is the true answer. Clients that honor fix stop. 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_auth runs 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

    • #1173 — same function. After admission, client_id is 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.
    • #1025 — track for standard OAuth clients. Those clients are the ones this 401 misleads. The track does not specify this status split.
    • #1020 / #1122 — every 401 now carries WWW-Authenticate, and every refused bearer gets invalid_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.
    • #1154 and #1146 — CLI OAuth client-credentials profiles and managed MCP credential renewal. They do not change verify_at. They relay WWW-Authenticate. A 401 from this bug can still make a renewal client fetch a token the server will refuse again.
    • #1175 — policy gates for maintenance. An unadmitted subject never reaches policy.
    • #951 — granular permissioning RFC. It assumes an admitted actor.
    • #1149 — client-contract RFC. A new code goes in the ErrorCode registry that RFC points at, not in a side channel.
    • Identity RFC 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.
    • MCP authorization spec, revision 2025-11-25, Error Handling: 401 for an invalid token, 403 insufficient_scope for a scope step-up. https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
    • RFC 6750 §3.1: https://www.rfc-editor.org/rfc/rfc6750#section-3.1

    No open pull request changes OidcIdentityTrust::verify_at or 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

    • The HTTP proof is the in-process router, not a live TCP listener. The middleware stack is the one serve uses.
    • I did not wait 300 seconds for snapshot expiry on that router. The unit tests show verify_at returns None, and the HTTP test shows what the server does with None.
    • I did not run Claude, Cursor, or the MCP TypeScript SDK against the server. The loop follows from RFC 6750 §3.1 and the MCP spec's 401 and insufficient_scope rules, plus the sentence already in docs/user/operations/mcp.md. The OmniGraph omnigraph mcp bridge relays the challenge. It does not itself start an OAuth sign-in.
    • I did not send a request through the 503 operation-admission path in this run.
  2. ragnorc commented on Oct 26, 2026

    @ragnorc
    ContributorAuthor

    Reproduced against omnigraph-server v0.14.0.

    Setup.

    • The server was started with --oidc-identity-trust. Its admission file has one key and one principal: {"subject": "user_alice", "principal_id": "alice"}.
    • The server sat behind TLS at https://127.0.0.1:8791. The issuer was https://127.0.0.1:8791/oauth and the resource https://127.0.0.1:8791/mcp.
    • Two access tokens were signed with that admitted key. They had the same iss, aud, org_id and client_id, and a 300-second life; only sub differed.

    GET /graphs/discovery with each token:

    sub Status WWW-Authenticate Body
    user_alice 200 none {"graphs":[{"graph_id":"knowledge",…},{"graph_id":"notes",…}]}
    user_mallory 401 Bearer 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, and action: "fix". A refresh gives user_mallory a 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-in in 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 with POST /oauth/_bed {"subject": "user_mallory"}. A server-side test can do the same with a token for an unmapped subject from the existing oidc_identity_tests.rs fixtures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageAwaiting maintainer triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions