From d739db6943e47cb681d3d2bf71188f7590ea2b42 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 27 Oct 2026 17:06:11 +0800 Subject: [PATCH] fix(server): refuse an unadmitted OIDC subject without a sign-in challenge --- changelog.d/oidc-not-admitted.fixed.md | 12 ++ crates/omnigraph-api-types/src/lib.rs | 5 + crates/omnigraph-cli/src/command_outcome.rs | 4 +- crates/omnigraph-server/src/handlers.rs | 19 ++- crates/omnigraph-server/src/lib.rs | 64 ++++++-- crates/omnigraph-server/src/oidc_identity.rs | 150 +++++++++++------- .../src/oidc_identity_tests.rs | 47 ++++++ .../tests/integration/auth_policy.rs | 92 +++++++++++ .../omnigraph-server/tests/integration/mcp.rs | 14 +- .../tests/integration/openapi.rs | 1 + docs/rfcs/0003-mcp-server-surface.md | 15 ++ docs/user/operations/mcp.md | 17 +- docs/user/operations/server.md | 4 +- docs/user/operations/troubleshooting.md | 4 +- openapi.json | 3 +- 15 files changed, 365 insertions(+), 86 deletions(-) create mode 100644 changelog.d/oidc-not-admitted.fixed.md diff --git a/changelog.d/oidc-not-admitted.fixed.md b/changelog.d/oidc-not-admitted.fixed.md new file mode 100644 index 000000000..fe163dfed --- /dev/null +++ b/changelog.d/oidc-not-admitted.fixed.md @@ -0,0 +1,12 @@ +- A server with an OIDC resource identity no longer answers 401 + `error="invalid_token"` to a valid access token whose subject the admission + file does not name. That answer told OAuth and MCP clients such as Claude to + sign in again, which issues a token for the same subject, so they looped or + reported a failed sign-in. The server now answers 403 with the new error + code `not_admitted` and no challenge. A valid token while the admission + snapshot is expired gets 503 `service_unavailable` with `Retry-After`, + instead of the same 401. A forged, expired or misaddressed token still gets + 401 `invalid_token`, and the token's own checks still run first. See + [MCP][oidc-not-admitted-guide]. + +[oidc-not-admitted-guide]: ../docs/user/operations/mcp.md diff --git a/crates/omnigraph-api-types/src/lib.rs b/crates/omnigraph-api-types/src/lib.rs index f07f23307..4eeeb4f87 100644 --- a/crates/omnigraph-api-types/src/lib.rs +++ b/crates/omnigraph-api-types/src/lib.rs @@ -2290,6 +2290,10 @@ error_codes! { /// The credential's actor may not do this: policy denies the action, or /// the action needs a policy permit the server has not been given. Forbidden = "forbidden", [403]; + /// The OIDC access token is valid, but the server admits no principal for + /// its subject. Signing in again issues a token for the same subject and + /// cannot help; the server's operator must admit the identity. + NotAdmitted = "not_admitted", [403]; /// The request is malformed or invalid: a body, query string, path /// parameter or header that does not decode, a query the compiler /// refuses (see `diagnostic`), or a value refused before any effect. @@ -2426,6 +2430,7 @@ impl ErrorCode { } // The request itself is wrong; no state it could read changes that. Self::Unauthorized + | Self::NotAdmitted | Self::BadRequest | Self::ApiContractMismatch | Self::ActorMismatch diff --git a/crates/omnigraph-cli/src/command_outcome.rs b/crates/omnigraph-cli/src/command_outcome.rs index d84d47857..480665a27 100644 --- a/crates/omnigraph-cli/src/command_outcome.rs +++ b/crates/omnigraph-cli/src/command_outcome.rs @@ -455,7 +455,9 @@ fn admission_refusal(status: StatusCode, code: Option<&ErrorCode>) -> Option Some(code.action()), + | (StatusCode::FORBIDDEN, code @ (ErrorCode::Forbidden | ErrorCode::NotAdmitted)) => { + Some(code.action()) + } _ => None, } } diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 67e7eb35b..287024862 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -422,11 +422,20 @@ pub(crate) async fn require_bearer_auth( } let provided_token = crate::challenge::bearer_token(request.headers())?; - let Some(actor) = state.authenticate_bearer_token(provided_token) else { - return Err(ApiError::unauthorized( - Unauthenticated::Refused, - "invalid bearer token", - )); + let actor = match state.authenticate_bearer_token(provided_token) { + Ok(actor) => actor, + Err(crate::oidc_identity::OidcRefusal::Invalid) => { + return Err(ApiError::unauthorized( + Unauthenticated::Refused, + "invalid bearer token", + )); + } + Err(crate::oidc_identity::OidcRefusal::NotAdmitted) => { + return Err(ApiError::not_admitted()); + } + Err(crate::oidc_identity::OidcRefusal::Unavailable) => { + return Err(ApiError::identity_admission_unavailable()); + } }; request.extensions_mut().insert(actor.actor().clone()); request.extensions_mut().insert(actor); diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index a0829806b..b2cc75db3 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -1080,7 +1080,14 @@ impl AppState { snapshot.any_per_graph_policy } - fn authenticate_bearer_token(&self, provided_token: &str) -> Option { + /// The actor a bearer token resolves to: a static token, then a signed + /// data token, then OIDC. Only OIDC tells a refused token from an + /// unadmitted or temporarily unverifiable identity; every other miss is + /// an invalid token. + fn authenticate_bearer_token( + &self, + provided_token: &str, + ) -> std::result::Result { // Hash the incoming token and compare against every stored digest in // constant time. Iterate all entries unconditionally so total work — // and therefore response timing — doesn't depend on which slot matches. @@ -1091,20 +1098,27 @@ impl AppState { matched = Some(Arc::clone(actor)); } } - matched.map(AuthenticatedActor::cluster_static).or_else(|| { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok()? - .as_secs(); - self.data_token_trust - .as_ref() - .and_then(|trust| trust.verify_authenticated_at(provided_token, now)) - .or_else(|| { - self.oidc_identity_trust - .as_ref()? - .verify_at(provided_token, i64::try_from(now).ok()?) - }) - }) + if let Some(actor) = matched { + return Ok(AuthenticatedActor::cluster_static(actor)); + } + let Some(now) = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|elapsed| elapsed.as_secs()) + else { + return Err(oidc_identity::OidcRefusal::Invalid); + }; + if let Some(actor) = self + .data_token_trust + .as_ref() + .and_then(|trust| trust.verify_authenticated_at(provided_token, now)) + { + return Ok(actor); + } + match (self.oidc_identity_trust.as_ref(), i64::try_from(now)) { + (Some(trust), Ok(now)) => trust.check_at(provided_token, now), + _ => Err(oidc_identity::OidcRefusal::Invalid), + } } } @@ -1152,6 +1166,25 @@ impl ApiError { Self::new(ErrorCode::Forbidden, message) } + /// HTTP 403 for a valid OIDC token whose subject this server does not + /// admit. No challenge: signing in again cannot help. + pub(crate) fn not_admitted() -> Self { + Self::new( + ErrorCode::NotAdmitted, + "the signed-in identity is not admitted to this server", + ) + } + + /// HTTP 503 for a valid OIDC token while the identity admission snapshot + /// is expired or the clock lies outside it. Access returns with the next + /// publication of the snapshot; signing in again cannot help. + pub(crate) fn identity_admission_unavailable() -> Self { + Self::new( + ErrorCode::ServiceUnavailable, + "OIDC identity admission is unavailable: the server's identity snapshot has expired; retry after its next publication", + ) + } + pub fn bad_request(message: impl Into) -> Self { Self::new(ErrorCode::BadRequest, message) } @@ -1445,6 +1478,7 @@ impl ApiError { let refused_before_start = matches!( self.code, ErrorCode::Unauthorized + | ErrorCode::NotAdmitted | ErrorCode::ApiContractMismatch | ErrorCode::ActorMismatch | ErrorCode::Forbidden diff --git a/crates/omnigraph-server/src/oidc_identity.rs b/crates/omnigraph-server/src/oidc_identity.rs index 00dd4f2d1..78d20d943 100644 --- a/crates/omnigraph-server/src/oidc_identity.rs +++ b/crates/omnigraph-server/src/oidc_identity.rs @@ -305,73 +305,113 @@ impl OidcIdentityTrust { }); } + #[cfg(test)] pub(crate) fn verify_at(&self, token: &str, now: i64) -> Option { - if token.is_empty() || token.len() > MAX_TOKEN_BYTES { - return None; - } + self.check_at(token, now).ok() + } + + /// Admit a bearer token, or say why not. The token's own checks come + /// first, against the snapshot's published keys and binding; only a token + /// that passes them learns whether the snapshot is current and whether its + /// subject is admitted. A stale snapshot's keys classify a refusal and + /// never admit. + pub(crate) fn check_at( + &self, + token: &str, + now: i64, + ) -> Result { let snapshot = self.current.load(); + let subject = accepted_subject(&snapshot, token, now).ok_or(OidcRefusal::Invalid)?; if now < 0 || snapshot.expires_at <= now || snapshot.generated_at > now.saturating_add(CLOCK_SKEW_SECONDS) { - return None; - } - let header = decode_header(token).ok()?; - if header.alg != Algorithm::RS256 - || header.crit.is_some() - || header.enc.is_some() - || header.zip.is_some() - || header - .typ - .as_deref() - .is_some_and(|t| !matches!(t, "JWT" | "at+jwt")) - { - return None; + return Err(OidcRefusal::Unavailable); } - let key = snapshot.keys.get(header.kid.as_deref()?)?; - let mut validation = Validation::new(Algorithm::RS256); - validation.leeway = 0; - // Explicit clock makes expiry tests deterministic and shares one instant - // with the snapshot check; signature/issuer/audience still use the library. - validation.validate_exp = false; - validation.validate_nbf = false; - validation.set_required_spec_claims(&["iss", "sub", "aud", "iat", "exp"]); - validation.set_issuer(&[&snapshot.binding.issuer]); - validation.set_audience(&[&snapshot.binding.audience]); - let claims = decode::(token, key, &validation).ok()?.claims; - if claims.get("iss")?.as_str()? != snapshot.binding.issuer - || claims.get("aud")?.as_str()? != snapshot.binding.audience - || claims.get("org_id")?.as_str()? != snapshot.binding.organization_id - || !bounded(claims.get("client_id")?.as_str()?, 512) - || claims.get("act").is_some_and(|v| !v.is_null()) - || claims.get("impersonator").is_some_and(|v| !v.is_null()) - || claims - .get("sub_profile") - .is_some_and(|v| v.as_str() != Some("user")) - { - return None; - } - let iat = claims.get("iat")?.as_i64()?; - let exp = claims.get("exp")?.as_i64()?; - if iat < 0 - || iat > now.saturating_add(CLOCK_SKEW_SECONDS) - || exp <= now - || exp <= iat - || exp.checked_sub(iat)? > MAX_LIFETIME_SECONDS - || claims - .get("nbf") - .is_some_and(|v| v.as_i64().is_none_or(|n| n > now)) - { - return None; - } - let subject = claims.get("sub")?.as_str()?; - let principal_id = snapshot.principals.get(subject)?.clone(); - Some(AuthenticatedActor::oidc_identity(VerifiedIdentity { + let principal_id = snapshot + .principals + .get(&subject) + .ok_or(OidcRefusal::NotAdmitted)? + .clone(); + Ok(AuthenticatedActor::oidc_identity(VerifiedIdentity { principal_id, })) } } +/// Why an OIDC bearer token admits no actor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OidcRefusal { + /// The token fails its own checks: signature, issuer, audience, + /// organization, client, delegation or lifetime. A new token can help + /// (RFC 6750 `invalid_token`). + Invalid, + /// The token passes, but the admission snapshot names no principal for + /// its subject. A new token for the same subject cannot help. + NotAdmitted, + /// The token passes, but the admission snapshot has expired or the + /// server's clock lies outside it. Access returns with the next + /// publication; a new token cannot help. + Unavailable, +} + +/// The subject of a token that passes every check of its own against the +/// snapshot's keys and binding, whatever the snapshot's lifetime. +fn accepted_subject(snapshot: &Snapshot, token: &str, now: i64) -> Option { + if token.is_empty() || token.len() > MAX_TOKEN_BYTES { + return None; + } + let header = decode_header(token).ok()?; + if header.alg != Algorithm::RS256 + || header.crit.is_some() + || header.enc.is_some() + || header.zip.is_some() + || header + .typ + .as_deref() + .is_some_and(|t| !matches!(t, "JWT" | "at+jwt")) + { + return None; + } + let key = snapshot.keys.get(header.kid.as_deref()?)?; + let mut validation = Validation::new(Algorithm::RS256); + validation.leeway = 0; + // Explicit clock makes expiry tests deterministic and shares one instant + // with the snapshot check; signature/issuer/audience still use the library. + validation.validate_exp = false; + validation.validate_nbf = false; + validation.set_required_spec_claims(&["iss", "sub", "aud", "iat", "exp"]); + validation.set_issuer(&[&snapshot.binding.issuer]); + validation.set_audience(&[&snapshot.binding.audience]); + let claims = decode::(token, key, &validation).ok()?.claims; + if claims.get("iss")?.as_str()? != snapshot.binding.issuer + || claims.get("aud")?.as_str()? != snapshot.binding.audience + || claims.get("org_id")?.as_str()? != snapshot.binding.organization_id + || !bounded(claims.get("client_id")?.as_str()?, 512) + || claims.get("act").is_some_and(|v| !v.is_null()) + || claims.get("impersonator").is_some_and(|v| !v.is_null()) + || claims + .get("sub_profile") + .is_some_and(|v| v.as_str() != Some("user")) + { + return None; + } + let iat = claims.get("iat")?.as_i64()?; + let exp = claims.get("exp")?.as_i64()?; + if iat < 0 + || iat > now.saturating_add(CLOCK_SKEW_SECONDS) + || exp <= now + || exp <= iat + || exp.checked_sub(iat)? > MAX_LIFETIME_SECONDS + || claims + .get("nbf") + .is_some_and(|v| v.as_i64().is_none_or(|n| n > now)) + { + return None; + } + Some(claims.get("sub")?.as_str()?.to_owned()) +} + #[cfg(test)] #[path = "oidc_identity_tests.rs"] mod tests; diff --git a/crates/omnigraph-server/src/oidc_identity_tests.rs b/crates/omnigraph-server/src/oidc_identity_tests.rs index 948d43e0b..3b853695e 100644 --- a/crates/omnigraph-server/src/oidc_identity_tests.rs +++ b/crates/omnigraph-server/src/oidc_identity_tests.rs @@ -296,3 +296,50 @@ fn public_snapshot_bounds_and_ambiguous_admission_refuse_before_boot() { let bad = encode(&h, &claims(), &EncodingKey::from_secret(b"not an RSA key")).unwrap(); assert!(trust.verify_at(&bad, NOW).is_none()); } + +#[test] +fn refusals_say_whether_a_new_token_can_help_issue_1172() { + let (_dir, trust) = setup(&document()); + assert!(trust.check_at(&sign(&claims()), NOW).is_ok()); + // A token that fails its own checks is invalid: a new one can help. + let mut expired = claims(); + expired["exp"] = json!(NOW); + let tampered = tamper_signature(&sign(&claims())); + for token in [sign(&expired), tampered, "not-a-jwt".into()] { + assert_eq!( + trust.check_at(&token, NOW).err(), + Some(OidcRefusal::Invalid) + ); + } + // A valid token for a subject the snapshot does not name is not admitted. + let mut stranger = claims(); + stranger["sub"] = json!("user_unadmitted"); + assert_eq!( + trust.check_at(&sign(&stranger), NOW).err(), + Some(OidcRefusal::NotAdmitted) + ); + // Past the snapshot's expiry a valid token is unverifiable, not invalid; + // a token that fails its own checks stays invalid even then. + let mut late = claims(); + late["iat"] = json!(NOW + 250); + late["exp"] = json!(NOW + 400); + assert_eq!( + trust.check_at(&sign(&late), NOW + 300).err(), + Some(OidcRefusal::Unavailable) + ); + assert_eq!( + trust.check_at("not-a-jwt", NOW + 300).err(), + Some(OidcRefusal::Invalid) + ); +} + +/// The token with one character changed in the middle of its signature. The +/// last base64url character can carry only padding bits, so changing it may +/// leave the signature bytes unchanged. +fn tamper_signature(token: &str) -> String { + let (head, signature) = token.rsplit_once('.').unwrap(); + let mut signature: Vec = signature.chars().collect(); + let middle = signature.len() / 2; + signature[middle] = if signature[middle] == 'A' { 'B' } else { 'A' }; + format!("{head}.{}", signature.into_iter().collect::()) +} diff --git a/crates/omnigraph-server/tests/integration/auth_policy.rs b/crates/omnigraph-server/tests/integration/auth_policy.rs index d2b9f452b..103273158 100644 --- a/crates/omnigraph-server/tests/integration/auth_policy.rs +++ b/crates/omnigraph-server/tests/integration/auth_policy.rs @@ -1214,6 +1214,87 @@ async fn issue_1020_every_401_carries_the_one_bearer_challenge() { assert_eq!(response.headers()[WWW_AUTHENTICATE], "Bearer"); } +/// A valid OIDC token whose subject the admission file does not name is not an +/// invalid token: signing in again issues a token for the same subject, so a +/// challenge would send an OAuth client round a sign-in loop. Tokens that fail +/// their own checks keep the `invalid_token` challenge. +#[tokio::test] +async fn issue_1172_an_unadmitted_subject_is_refused_without_a_sign_in_challenge() { + let temp = init_loaded_graph().await; + let graph = graph_path(temp.path()).to_string_lossy().to_string(); + let policy = temp.path().join("policy.yaml"); + fs::write(&policy, permit_all_policy_yaml(&["default"])).unwrap(); + let app = build_app( + AppState::open_with_bearer_tokens_and_policy( + graph, + vec![("default".into(), "secret".into())], + Some(&policy), + ) + .await + .unwrap() + .with_oidc_identity_trust(oidc::trust(temp.path())), + ); + let send = |token: String, method: &str, uri: &str| { + let app = app.clone(); + let request = Request::builder() + .method(Method::from_bytes(method.as_bytes()).unwrap()) + .uri(uri) + .header(HTTP_API_CONTRACT_HEADER, HTTP_API_CONTRACT) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("host", "localhost") + .header(AUTHORIZATION, format!("Bearer {token}")) + .body(Body::from( + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#, + )) + .unwrap(); + async move { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let challenge = response.headers().get(WWW_AUTHENTICATE).cloned(); + let body = to_bytes(response.into_body(), 1 << 20).await.unwrap(); + ( + status, + challenge, + serde_json::from_slice::(&body).ok(), + ) + } + }; + let stranger = oidc::token("user_unadmitted", oidc::RESOURCE); + for (method, uri) in [("GET", "/graphs/discovery"), ("POST", "/mcp")] { + let (status, challenge, body) = send(stranger.clone(), method, uri).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{method} {uri}"); + assert!(challenge.is_none(), "{method} {uri}: {challenge:?}"); + let body = body.unwrap(); + assert_eq!(body["code"], "not_admitted", "{method} {uri}: {body}"); + assert_eq!(body["disposition"]["action"], "fix", "{body}"); + assert_eq!(body["disposition"]["effects"], "none", "{body}"); + assert!(!body.to_string().contains("user_unadmitted"), "{body}"); + + let (status, _, _) = send(oidc::token("alice", oidc::RESOURCE), method, uri).await; + assert!( + status.is_success(), + "{method} {uri}: admitted subject got {status}" + ); + + let tampered = tamper_signature(&oidc::token("alice", oidc::RESOURCE)); + for refused in [ + tampered, + oidc::token("alice", "https://data.example/clusters/B/incarnations/one"), + ] { + let (status, challenge, _) = send(refused, method, uri).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{method} {uri}"); + assert_eq!( + challenge.unwrap(), + format!( + "Bearer error=\"invalid_token\", resource_metadata=\"{}\"", + oidc::RESOURCE_METADATA + ), + ); + } + } +} + #[tokio::test(flavor = "multi_thread")] async fn protected_routes_accept_valid_bearer_token_while_healthz_stays_open() { let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await; @@ -4428,3 +4509,14 @@ rules: ]) ); } + +/// The token with one character changed in the middle of its signature. The +/// last base64url character can carry only padding bits, so changing it may +/// leave the signature bytes unchanged. +fn tamper_signature(token: &str) -> String { + let (head, signature) = token.rsplit_once('.').unwrap(); + let mut signature: Vec = signature.chars().collect(); + let middle = signature.len() / 2; + signature[middle] = if signature[middle] == 'A' { 'B' } else { 'A' }; + format!("{head}.{}", signature.into_iter().collect::()) +} diff --git a/crates/omnigraph-server/tests/integration/mcp.rs b/crates/omnigraph-server/tests/integration/mcp.rs index 13e97489e..02aa7b673 100644 --- a/crates/omnigraph-server/tests/integration/mcp.rs +++ b/crates/omnigraph-server/tests/integration/mcp.rs @@ -424,9 +424,21 @@ async fn mcp_authenticates_every_request_and_enforces_resource_and_http_bounds() ); let alice = token("alice", RESOURCE); + // A valid token for a subject the admission file does not name is refused + // without a sign-in challenge: a new token for that subject cannot help. + let response = app + .clone() + .oneshot(rpc( + Some(&token("unknown", RESOURCE)), + "tools/list", + json!({}), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(!response.headers().contains_key("www-authenticate")); for wrong in [ token("alice", "https://data.example/clusters/B/incarnations/one"), - token("unknown", RESOURCE), "ogc_retired".into(), ] { let response = app diff --git a/crates/omnigraph-server/tests/integration/openapi.rs b/crates/omnigraph-server/tests/integration/openapi.rs index b2a29aa7e..d6186fbd5 100644 --- a/crates/omnigraph-server/tests/integration/openapi.rs +++ b/crates/omnigraph-server/tests/integration/openapi.rs @@ -1787,6 +1787,7 @@ fn error_code_schema_has_expected_variants() { HashSet::from([ "unauthorized", "forbidden", + "not_admitted", "bad_request", "api_contract_mismatch", "actor_mismatch", diff --git a/docs/rfcs/0003-mcp-server-surface.md b/docs/rfcs/0003-mcp-server-surface.md index 0af33f8e3..bb4a56a18 100644 --- a/docs/rfcs/0003-mcp-server-surface.md +++ b/docs/rfcs/0003-mcp-server-surface.md @@ -370,6 +370,15 @@ path, and never reflect the credential or a refusal reason. `/mcp` keeps only its own `Cache-Control: no-store`. The OpenAPI document records the header on every protected operation's 401. +A token that passes its own checks is not a refused one, so it never gets +`invalid_token`: when the OIDC admission file names no principal for its +subject the answer is 403 `not_admitted`, and while the admission snapshot is +expired it is 503 `service_unavailable` with `Retry-After`. Neither carries a +challenge, since signing in again issues a token for the same subject and +cannot help; a challenge there sends a standard OAuth client round a sign-in +loop. The token's own checks run first, so a caller without a validly signed +token learns nothing about the admission file. + ## Invariants - **Trust at the boundary, enforcement at the engine (invariant 10).** Actors come @@ -498,6 +507,12 @@ a deferred item or the existing allowed-origin rule. ## Decision log +- 2026-10-27: An unadmitted subject and an expired admission snapshot no + longer answer 401 `invalid_token` (issue + [#1172](https://github.com/ModernRelay/omnigraph/issues/1172)); they answer + 403 `not_admitted` and 503 `service_unavailable` without a challenge. This + narrows "a bearer token that does not authenticate is a refused one" to a + token that fails its own checks. - 2026-10-22: Amended for operation parity (phase 3), from the one set of operations of [Graph access and cross-process writes](2026-10-16-graph-access-serving-and-cross-process-writes.md). diff --git a/docs/user/operations/mcp.md b/docs/user/operations/mcp.md index 88cf9da8b..db87f072a 100644 --- a/docs/user/operations/mcp.md +++ b/docs/user/operations/mcp.md @@ -58,10 +58,19 @@ With OIDC, the challenge names the protected-resource metadata: WWW-Authenticate: Bearer resource_metadata="https://graphs.example.com/.well-known/oauth-protected-resource/mcp" ``` -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. Without OIDC the challenge is `Bearer`, -which advertises no sign-in flow. +When a bearer credential was presented and refused (bad signature, expired, +wrong audience or organization), the challenge also carries +`error="invalid_token"`, which tells a client to refresh or sign in again. +Without OIDC the challenge is `Bearer`, which advertises no sign-in flow. + +Two refusals of a valid token carry no challenge, because signing in again +issues a token for the same person and cannot help: + +- the admission file names no principal for the token's subject: 403 + `not_admitted`. The server's operator must admit the identity; +- the admission snapshot has expired, for example during a publisher outage: + 503 `service_unavailable` with `Retry-After`. Access returns with the next + publication. The server has one resource identifier, so every route's challenge names the same metadata. RFC 9728 §3.3 has a client that followed a challenge require the diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index 3e0a88103..05d06e339 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -106,8 +106,8 @@ Choose one static token source: Send `Authorization: Bearer secret-a` (the scheme is case-insensitive). The token selects the actor for authorization and commit attribution; clients cannot claim another actor. See -[Authorization and actors](policy.md). Every 401 carries a `WWW-Authenticate: Bearer` challenge, -with `error="invalid_token"` when the server refused the token sent. +[Authorization and actors](policy.md). Every 401 carries a `WWW-Authenticate: Bearer` challenge, with `error="invalid_token"` when +the server refused the token sent; a valid OIDC token it does not admit gets 403 ([MCP](mcp.md)). A server with neither static tokens, signed-token trust, nor policy refuses to start unless you explicitly pass `--unauthenticated` (or set `OMNIGRAPH_UNAUTHENTICATED=1`). Use that only diff --git a/docs/user/operations/troubleshooting.md b/docs/user/operations/troubleshooting.md index a055363e7..5386a5b89 100644 --- a/docs/user/operations/troubleshooting.md +++ b/docs/user/operations/troubleshooting.md @@ -29,7 +29,7 @@ for `not_retained`. |---:|---|---| | 400 | Invalid request, query, schema, configuration, or external-Blob policy, including an update that must carry a stored external Blob reference the policy does not admit | Correct the request; retrying unchanged will fail again. For a stored reference, assign that property in the same update (a new value or null), or admit its base in the policy | | 401 | Missing or invalid bearer token. `WWW-Authenticate` says which: `error="invalid_token"` when the server refused the token sent, and `resource_metadata` when the server accepts OAuth sign-in | Supply a token configured by the server. The CLI sends a token only to the server it is keyed to; see [server credentials](../cli/index.md#use-a-server) | -| 403 | The resolved actor is not authorized | Change policy or use an authorized identity | +| 403 | The resolved actor is not authorized (`forbidden`), or a valid OIDC token's subject is not in the server's admission file (`not_admitted`) | For `forbidden`, change policy or use an authorized identity. For `not_admitted`, signing in again cannot help: the server's operator must admit the identity | | 404 | Graph, query, branch, entity, or route is unavailable | Check the name and applied cluster revision; stored-query denials may also appear as 404 | | 405 | The path is served, but not with this method (`method_not_allowed`) | Use a method the `Allow` header lists | | 408 | The request body did not arrive before its deadline (`request_timeout`) | Send the request again; nothing was admitted | @@ -42,7 +42,7 @@ for `not_retained`. | 424 | An allowed external Blob source could not be read | Restore source availability or correct its URI/credentials | | 429 | Server or per-actor admission limit reached | Use the whole-command disposition below before retrying; preserve `Retry-After` | | 500 | Server or stored-data integrity failure | Check server logs; do not assume partial success. A Blob delivery, change-route or streamed-response failure logs its error class (`error_variant`, and `storage_kind` for a storage failure), never its storage path; see below | -| 503 | Admission is closed or the graph is not served (`service_unavailable`, `graph_unavailable`, both with `Retry-After`), or a published schema change requires completion (`recovery_required`) | Inspect the structured error; generic 503 is not permission to repeat a write | +| 503 | Admission is closed, the server's OIDC identity snapshot has expired, or the graph is not served (`service_unavailable`, `graph_unavailable`, both with `Retry-After`), or a published schema change requires completion (`recovery_required`) | Inspect the structured error; generic 503 is not permission to repeat a write | A `GET` or `HEAD /blob` 500 logs `error_kind="blob_pre_header_internal"` with a `stage`. `target` is a failure resolving a snapshot target for a policy-gated diff --git a/openapi.json b/openapi.json index 4ff6a2afb..196fe27a0 100644 --- a/openapi.json +++ b/openapi.json @@ -14241,10 +14241,11 @@ }, "ErrorCode": { "type": "string", - "description": "The registry of error codes. A server sends each code under the HTTP statuses named with it; a code without one never appears in an HTTP response.\n\n- `unauthorized` (401): The request carries no credential the server accepts: a bearer token is missing, unknown or expired. Supply one the server is configured to verify.\n- `forbidden` (403): The credential's actor may not do this: policy denies the action, or the action needs a policy permit the server has not been given.\n- `bad_request` (400): The request is malformed or invalid: a body, query string, path parameter or header that does not decode, a query the compiler refuses (see `diagnostic`), or a value refused before any effect.\n- `api_contract_mismatch` (400): The request lacks exactly one `Omnigraph-Http-Api` header naming the contract this server speaks. Authentication and this check precede graph access and effects.\n- `actor_mismatch` (412): The request's `Omnigraph-If-Actor` header names another actor than its credential resolves to. Nothing ran.\n- `outcome_unknown` (500): A write whose completion the server could not settle: it may or may not have published. Read the target before writing again; never replay the write blindly. An MCP write call that was cancelled or passed its deadline answers it too.\n- `precondition_failed` (412): A request precondition no longer holds: the branch head moved past `Omnigraph-If-Graph-Commit` (see `precondition_failure`), or a Blob's `If-Match` names a value the cell no longer holds (a write's `blob_precondition_failure` names the current one). Nothing was written.\n- `recovery_required` (503): An overlapping durable recovery intent must be resolved first; see `recovery_required`.\n- `external_blob_source` (424): An admitted external Blob source could not be probed or read; see `external_blob_source`.\n- `change_feed_gap` (410): Retained history can no longer reconstruct a change continuation; see `change_feed_gap`. Resume from a new baseline.\n- `not_found` (404): The graph, branch, commit, query, entity or route the request names does not exist, or the caller may not learn that it does.\n- `method_not_allowed` (405): The request path is served, but not with the request's method.\n- `request_timeout` (408): The request body did not arrive before its deadline; nothing was admitted.\n- `conflict` (409): The request conflicts with the graph's current state: a concurrent change, an existing id, a merge conflict, an index that must be built first, or a commit that cannot be diffed. The details say which.\n- `branch_start_refusal` (409, 410): `branch create` could not start the branch at the commit `from_commit` named, and nothing was created; see `branch_start_refusal`, whose `not_retained` reason answers 410.\n- `unattached_cannot_publish` (409): The commit is an unattached snapshot, a kind that never publishes. Distinct from `conflict`, the authority-moved refusal of a publishable proposal.\n- `payload_too_large` (413): The request body, or the work it asks for, exceeds a bounded limit; `resource_limit` names the limit when it is the operation's. Split or reduce the request.\n- `unsupported_media_type` (415): The request body's content type is not one the route accepts.\n- `range_not_satisfiable` (416): The requested Blob byte range lies outside the value; see `blob_range`.\n- `too_many_requests` (429): Admission capacity is exhausted: an actor's, the server's, or the MCP endpoint's concurrent tool calls. Nothing started; retry after `Retry-After`.\n- `service_unavailable` (503): Operation admission is closed, or a deployment observation raced an owner change. Nothing started; retry after `Retry-After`, reconciling any earlier write first.\n- `graph_unavailable` (503): A known graph is not being served. The request did not start, so it may be retried once the graph is served again.\n- `internal` (500): The server failed. An error before any write took no effect; see the server log.\n- `cancelled` (no HTTP status): MCP only: the client cancelled a read tool call. A cancelled write answers `outcome_unknown`.\n- `deadline_exceeded` (no HTTP status): MCP only: a read tool call passed its deadline. A write that does answers `outcome_unknown`.\n- `result_too_large` (no HTTP status): MCP only: a tool result exceeds the result limit. Narrow the read.", + "description": "The registry of error codes. A server sends each code under the HTTP statuses named with it; a code without one never appears in an HTTP response.\n\n- `unauthorized` (401): The request carries no credential the server accepts: a bearer token is missing, unknown or expired. Supply one the server is configured to verify.\n- `forbidden` (403): The credential's actor may not do this: policy denies the action, or the action needs a policy permit the server has not been given.\n- `not_admitted` (403): The OIDC access token is valid, but the server admits no principal for its subject. Signing in again issues a token for the same subject and cannot help; the server's operator must admit the identity.\n- `bad_request` (400): The request is malformed or invalid: a body, query string, path parameter or header that does not decode, a query the compiler refuses (see `diagnostic`), or a value refused before any effect.\n- `api_contract_mismatch` (400): The request lacks exactly one `Omnigraph-Http-Api` header naming the contract this server speaks. Authentication and this check precede graph access and effects.\n- `actor_mismatch` (412): The request's `Omnigraph-If-Actor` header names another actor than its credential resolves to. Nothing ran.\n- `outcome_unknown` (500): A write whose completion the server could not settle: it may or may not have published. Read the target before writing again; never replay the write blindly. An MCP write call that was cancelled or passed its deadline answers it too.\n- `precondition_failed` (412): A request precondition no longer holds: the branch head moved past `Omnigraph-If-Graph-Commit` (see `precondition_failure`), or a Blob's `If-Match` names a value the cell no longer holds (a write's `blob_precondition_failure` names the current one). Nothing was written.\n- `recovery_required` (503): An overlapping durable recovery intent must be resolved first; see `recovery_required`.\n- `external_blob_source` (424): An admitted external Blob source could not be probed or read; see `external_blob_source`.\n- `change_feed_gap` (410): Retained history can no longer reconstruct a change continuation; see `change_feed_gap`. Resume from a new baseline.\n- `not_found` (404): The graph, branch, commit, query, entity or route the request names does not exist, or the caller may not learn that it does.\n- `method_not_allowed` (405): The request path is served, but not with the request's method.\n- `request_timeout` (408): The request body did not arrive before its deadline; nothing was admitted.\n- `conflict` (409): The request conflicts with the graph's current state: a concurrent change, an existing id, a merge conflict, an index that must be built first, or a commit that cannot be diffed. The details say which.\n- `branch_start_refusal` (409, 410): `branch create` could not start the branch at the commit `from_commit` named, and nothing was created; see `branch_start_refusal`, whose `not_retained` reason answers 410.\n- `unattached_cannot_publish` (409): The commit is an unattached snapshot, a kind that never publishes. Distinct from `conflict`, the authority-moved refusal of a publishable proposal.\n- `payload_too_large` (413): The request body, or the work it asks for, exceeds a bounded limit; `resource_limit` names the limit when it is the operation's. Split or reduce the request.\n- `unsupported_media_type` (415): The request body's content type is not one the route accepts.\n- `range_not_satisfiable` (416): The requested Blob byte range lies outside the value; see `blob_range`.\n- `too_many_requests` (429): Admission capacity is exhausted: an actor's, the server's, or the MCP endpoint's concurrent tool calls. Nothing started; retry after `Retry-After`.\n- `service_unavailable` (503): Operation admission is closed, or a deployment observation raced an owner change. Nothing started; retry after `Retry-After`, reconciling any earlier write first.\n- `graph_unavailable` (503): A known graph is not being served. The request did not start, so it may be retried once the graph is served again.\n- `internal` (500): The server failed. An error before any write took no effect; see the server log.\n- `cancelled` (no HTTP status): MCP only: the client cancelled a read tool call. A cancelled write answers `outcome_unknown`.\n- `deadline_exceeded` (no HTTP status): MCP only: a read tool call passed its deadline. A write that does answers `outcome_unknown`.\n- `result_too_large` (no HTTP status): MCP only: a tool result exceeds the result limit. Narrow the read.", "enum": [ "unauthorized", "forbidden", + "not_admitted", "bad_request", "api_contract_mismatch", "actor_mismatch",