From e442059c00653b4a4014206dc3346b8da7c5db26 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 23 Oct 2026 23:09:06 +0100 Subject: [PATCH 1/2] feat(mcp): add generic workload token exchange grants --- docs/user/mcp.md | 31 ++++- src/tools/mcp/workload.rs | 2 + src/workload_auth.rs | 201 ++++++++++++++++++++++++++++--- src/workload_auth/https_tests.rs | 170 ++++++++++++++++++++++++++ src/workload_auth/tests.rs | 55 ++++++++- 5 files changed, 436 insertions(+), 23 deletions(-) diff --git a/docs/user/mcp.md b/docs/user/mcp.md index 7297fa89..e7964a46 100644 --- a/docs/user/mcp.md +++ b/docs/user/mcp.md @@ -31,9 +31,34 @@ mounted JWT without browser login, client registration, or a client secret: Keep this configuration and token mount under deployment control. `tokenFile` must be absolute; symlink rotation is supported. The resource must match the HTTPS -MCP URL, and the token endpoint must share the expected issuer's origin. Kit checks -issuer metadata before sending the assertion, disables redirects, and refreshes -before expiry. No browser or saved-credential fallback occurs. +MCP URL. The defaults are `grant: "jwtBearer"` (RFC 7523) and +`endpointMode: "discovery"`: the endpoint must share `expectedIssuer`'s origin, +and Kit checks RFC 8414 metadata against both before sending an assertion. + +For RFC 8693 token exchange, set `grant: "tokenExchange"`. Kit sends the JWT as +`subject_token`, declares its JWT type, and requests an OAuth access token. +The response must declare the access-token `issued_token_type` and a Bearer +`token_type`. A missing `expires_in` is allowed for this grant but disables caching; +zero, negative, or non-integer lifetimes are rejected. JWT bearer still requires +a positive lifetime. Kit renews cached tokens before expiry. + +For deployments without discovery, set `endpointMode: "explicit"`, omit +`expectedIssuer`, and configure the trusted HTTPS `tokenEndpoint` directly. +This URL cannot contain credentials, a query, or a fragment. This explicitly +trusts the operator-selected endpoint without metadata verification. The token +file limit is 16 KiB in explicit mode; discovery retains its 8 KiB limit. + +Both grants accept optional `audience`, `scope`, and an `extensions` object for +non-secret deployment parameters, for example `{"project_id":"example"}`. +Extension names use lowercase ASCII letters, digits, and underscores (1–64 bytes); +values are 1–1024 bytes without control characters, with at most 16 entries. +OAuth grant, token, authorization, and client-authentication fields are reserved +and cannot be overridden. Do not put credentials in extensions. Audience and +scope are each limited to 2048 bytes without control characters. + +Redirects, token-POST retries, browser fallback, and saved-credential fallback +are disabled. Rejected MCP calls are not replayed. Tokens remain in process +memory and each configured server has its own acquisition cache. Do not add static authorization or Host headers. Reusing an unchanged assertion requires the issuer's explicit replay-policy support. After a process-memory diff --git a/src/tools/mcp/workload.rs b/src/tools/mcp/workload.rs index 49cbf093..250c4a0a 100644 --- a/src/tools/mcp/workload.rs +++ b/src/tools/mcp/workload.rs @@ -371,6 +371,7 @@ mod tests { expected_issuer: origin.into(), token_endpoint: format!("{origin}/token"), resource: uri.clone(), + ..Default::default() }, tls_client().build().unwrap(), ) @@ -485,6 +486,7 @@ mod tests { expected_issuer: origin.into(), token_endpoint: format!("{origin}/token"), resource: uri.clone(), + ..Default::default() }, tls_client().build().unwrap(), ) diff --git a/src/workload_auth.rs b/src/workload_auth.rs index a58cb702..c16be44d 100644 --- a/src/workload_auth.rs +++ b/src/workload_auth.rs @@ -1,5 +1,6 @@ -//! Clientless RFC 7523 workload authentication. Assertions never enter credential storage. +//! Clientless RFC 7523 and RFC 8693 workload authentication. Assertions never enter credential storage. use std::{ + collections::BTreeMap, path::PathBuf, sync::Arc, time::{Duration, SystemTime}, @@ -37,14 +38,44 @@ pub(crate) mod test_support { } } +const ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token"; + +#[derive(Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum Grant { + #[default] + JwtBearer, + TokenExchange, +} + +#[derive(Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum EndpointMode { + #[default] + Discovery, + Explicit, +} + /// Trusted deployment configuration, not input supplied by an MCP server or model. -#[derive(Clone, Deserialize)] +#[derive(Clone, Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Config { pub token_file: PathBuf, + #[serde(default)] pub expected_issuer: String, pub token_endpoint: String, + #[serde(default)] pub resource: String, + #[serde(default)] + pub grant: Grant, + #[serde(default)] + pub endpoint_mode: EndpointMode, + #[serde(default)] + pub audience: Option, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub extensions: BTreeMap, } impl Config { @@ -52,11 +83,87 @@ impl Config { if !self.token_file.is_absolute() { return Err("workload tokenFile must be an absolute path".into()); } - let issuer = https_url(&self.expected_issuer)?; let endpoint = https_url(&self.token_endpoint)?; - https_url(&self.resource)?; - if issuer.query().is_some() || issuer.origin() != endpoint.origin() { - return Err("workload token endpoint must share the expected issuer origin".into()); + if self.endpoint_mode == EndpointMode::Discovery { + let issuer = https_url(&self.expected_issuer)?; + https_url(&self.resource)?; + if issuer.query().is_some() || issuer.origin() != endpoint.origin() { + return Err("workload token endpoint must share the expected issuer origin".into()); + } + } else { + if !self.expected_issuer.is_empty() || endpoint.query().is_some() { + return Err("explicit workload endpoints cannot specify an issuer or query".into()); + } + if !self.resource.is_empty() { + if self.resource.chars().any(char::is_whitespace) { + return Err("invalid workload resource".into()); + } + let resource = url::Url::parse(&self.resource) + .map_err(|_| "workload resource must be an absolute URI")?; + if resource.fragment().is_some() + || !resource.username().is_empty() + || resource.password().is_some() + { + return Err("invalid workload resource".into()); + } + } + } + for value in self.audience.iter().chain(self.scope.iter()) { + if value.is_empty() || value.len() > 2048 || value.chars().any(char::is_control) { + return Err("invalid workload audience or scope".into()); + } + } + if self.scope.as_ref().is_some_and(|scope| { + scope.split(' ').any(|part| { + part.is_empty() + || !part.bytes().all(|c| { + c == 0x21 || (0x23..=0x5b).contains(&c) || (0x5d..=0x7e).contains(&c) + }) + }) + }) { + return Err("invalid workload scope".into()); + } + // Extensions are non-secret deployment parameters, never alternative credentials. + const RESERVED: &[&str] = &[ + "grant_type", + "assertion", + "resource", + "audience", + "scope", + "subject_token", + "subject_token_type", + "requested_token_type", + "actor_token", + "actor_token_type", + "client_id", + "client_secret", + "client_assertion", + "client_assertion_type", + "refresh_token", + "access_token", + "token", + "token_type", + "username", + "password", + "authorization", + "code", + "redirect_uri", + "code_verifier", + ]; + if self.extensions.len() > 16 + || self.extensions.iter().any(|(key, value)| { + key.is_empty() + || key.len() > 64 + || !key + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_') + || RESERVED.contains(&key.as_str()) + || value.is_empty() + || value.len() > 1024 + || value.chars().any(char::is_control) + }) + { + return Err("invalid workload extension parameters".into()); } Ok(()) } @@ -142,20 +249,54 @@ impl Session { return Ok(token.value.clone()); } *cached = None; - self.verify_issuer().await?; + if self.config.endpoint_mode == EndpointMode::Discovery { + self.verify_issuer().await?; + } // Reopen on every exchange: projected files rotate through symlinks. - let assertion = read_assertion(&self.config.token_file).await?; + let limit = if self.config.endpoint_mode == EndpointMode::Explicit { + 16384 + } else { + MAX_BYTES + }; + let assertion = read_assertion_bounded(&self.config.token_file, limit).await?; + let mut form = Vec::new(); + match self.config.grant { + Grant::JwtBearer => { + form.push(("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")); + form.push(("assertion", assertion.as_str())); + } + Grant::TokenExchange => { + form.push(( + "grant_type", + "urn:ietf:params:oauth:grant-type:token-exchange", + )); + form.push(("subject_token", assertion.as_str())); + form.push(("subject_token_type", "urn:ietf:params:oauth:token-type:jwt")); + form.push(("requested_token_type", ACCESS_TOKEN)); + } + } + if !self.config.resource.is_empty() { + form.push(("resource", self.config.resource.as_str())); + } + if let Some(value) = &self.config.audience { + form.push(("audience", value)); + } + if let Some(value) = &self.config.scope { + form.push(("scope", value)); + } + form.extend( + self.config + .extensions + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())), + ); let started = SystemTime::now(); // Never automatically retry a POST: the server may already have consumed // this assertion's replay identifier, even if the response was lost. let response = self .client .post(&self.config.token_endpoint) - .form(&[ - ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), - ("assertion", assertion.as_str()), - ("resource", self.config.resource.as_str()), - ]) + .form(&form) .send() .await .map_err(|_| "workload token exchange failed")?; @@ -166,7 +307,10 @@ impl Session { let token: Token = serde_json::from_slice(&bytes).map_err(|_| "invalid workload token response")?; if !token.token_type.eq_ignore_ascii_case("bearer") - || token.expires_in == 0 + || token.expires_in == Some(0) + || (self.config.grant == Grant::JwtBearer && token.expires_in.is_none()) + || (self.config.grant == Grant::TokenExchange + && token.issued_token_type.as_deref() != Some(ACCESS_TOKEN)) || token.access_token.is_empty() || token.access_token.len() > MAX_BYTES || !token.access_token.bytes().all(|c| c.is_ascii_graphic()) @@ -174,7 +318,12 @@ impl Session { return Err("invalid workload token response".into()); } // Cap long issuer lifetimes; subtract a proportional margin for short ones. - let lifetime = token.expires_in.min(86400); + let Some(expires_in) = token.expires_in else { + // RFC 8693 section 2.2.1 recommends, but does not require, expires_in. + // Without a lifetime, return this acquisition without caching it. + return Ok(Acquisition(Arc::new(Zeroizing::new(token.access_token)))); + }; + let lifetime = expires_in.min(86400); let usable = Duration::from_secs(lifetime) .saturating_sub(Duration::from_millis((lifetime * 100).min(30000))); let refresh_at = started @@ -244,7 +393,15 @@ struct Metadata { struct Token { access_token: String, token_type: String, - expires_in: u64, + #[serde(default, deserialize_with = "optional_expiry")] + expires_in: Option, + issued_token_type: Option, +} + +fn optional_expiry<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + u64::deserialize(deserializer).map(Some) } async fn bounded_body(mut response: reqwest::Response) -> Result>, String> { @@ -262,7 +419,15 @@ async fn bounded_body(mut response: reqwest::Response) -> Result Result, String> { + read_assertion_bounded(path, MAX_BYTES).await +} + +async fn read_assertion_bounded( + path: &std::path::Path, + limit: usize, +) -> Result, String> { let mut options = tokio::fs::OpenOptions::new(); options.read(true); #[cfg(unix)] @@ -280,11 +445,11 @@ async fn read_assertion(path: &std::path::Path) -> Result, Str return Err("workload token must be a regular file".into()); } let mut bytes = Zeroizing::new(Vec::new()); - file.take((MAX_BYTES + 1) as u64) + file.take((limit + 1) as u64) .read_to_end(&mut bytes) .await .map_err(|_| "could not read workload token file")?; - if bytes.len() > MAX_BYTES { + if bytes.len() > limit { return Err("workload assertion exceeds size limit".into()); } let value = std::str::from_utf8(&bytes) diff --git a/src/workload_auth/https_tests.rs b/src/workload_auth/https_tests.rs index fc308611..07ffc5c5 100644 --- a/src/workload_auth/https_tests.rs +++ b/src/workload_auth/https_tests.rs @@ -164,6 +164,7 @@ impl Server { expected_issuer: format!("{}/tenant", self.origin), token_endpoint: format!("{}/token", self.origin), resource: resource.into(), + ..Default::default() }; test_support::session( config, @@ -431,6 +432,7 @@ async fn https_invalid_responses_are_rejected_without_caching_or_leaks() { let mut bodies = vec![format!("not-json {ASSERTION}"), "{}".into()]; for (field, value) in [ ("token_type", serde_json::json!("Basic")), + ("expires_in", serde_json::Value::Null), ("expires_in", serde_json::json!(0)), ("expires_in", serde_json::json!(-1)), ("expires_in", serde_json::json!("3600")), @@ -458,3 +460,171 @@ async fn https_invalid_responses_are_rejected_without_caching_or_leaks() { assert_eq!(server.drain().len(), 2); } } + +fn exchange_reply(expiry: Option) -> Reply { + let mut value = serde_json::json!({ + "access_token":"exchange-access", "token_type":"bEaReR", "issued_token_type": ACCESS_TOKEN + }); + if let Some(expiry) = expiry { + value["expires_in"] = expiry.into(); + } + Reply::json(value) +} + +#[tokio::test] +async fn https_explicit_grants_exact_fields_and_rotation() { + for grant in [Grant::JwtBearer, Grant::TokenExchange] { + let mut server = Server::start().await; + let (_dir, path) = source(); + let mut session = server.session(path.clone(), "https://api.example/mcp"); + session.config.endpoint_mode = EndpointMode::Explicit; + session.config.expected_issuer.clear(); + session.config.grant = grant; + session.config.resource = "urn:example:inference".into(); + session.config.scope = Some("inference".into()); + session.config.audience = Some("backend".into()); + session + .config + .extensions + .insert("federation_policy_id".into(), "example".into()); + session.config.validate().unwrap(); + server.enqueue(exchange_reply(Some(3600))); + let first = session.acquisition().await.unwrap(); + assert_eq!(first.token(), "exchange-access"); + assert_eq!(session.token().await.unwrap(), first.token()); + let requests = server.drain(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/token"); + let mut expected = BTreeMap::from([ + ("resource".into(), "urn:example:inference".into()), + ("scope".into(), "inference".into()), + ("audience".into(), "backend".into()), + ("federation_policy_id".into(), "example".into()), + ]); + let source_field = if grant == Grant::JwtBearer { + expected.insert( + "grant_type".into(), + "urn:ietf:params:oauth:grant-type:jwt-bearer".into(), + ); + "assertion" + } else { + expected.insert( + "grant_type".into(), + "urn:ietf:params:oauth:grant-type:token-exchange".into(), + ); + expected.insert( + "subject_token_type".into(), + "urn:ietf:params:oauth:token-type:jwt".into(), + ); + expected.insert("requested_token_type".into(), ACCESS_TOKEN.into()); + "subject_token" + }; + expected.insert(source_field.into(), ASSERTION.into()); + assert_eq!(requests[0].form(), expected); + session.invalidate_acquisition(&first).await; + let large = format!("{}.bbb.ccc", "a".repeat(12000)); + std::fs::write(&path, &large).unwrap(); + server.enqueue(exchange_reply(Some(3600))); + let renewed = session.acquisition().await.unwrap(); + assert_eq!(server.drain()[0].form()[source_field], large); + session.invalidate_acquisition(&first).await; + assert!(Arc::ptr_eq( + &renewed.0, + &session.acquisition().await.unwrap().0 + )); + assert!(server.drain().is_empty()); + } +} + +#[tokio::test] +async fn https_exchange_validates_response_and_does_not_cache_unknown_expiry() { + let mut server = Server::start().await; + let (_dir, path) = source(); + let mut session = server.session(path, "https://api.example/mcp"); + session.config.endpoint_mode = EndpointMode::Explicit; + session.config.expected_issuer.clear(); + session.config.grant = Grant::TokenExchange; + for (field, value) in [ + ("issued_token_type", serde_json::Value::Null), + ( + "issued_token_type", + serde_json::json!("urn:ietf:params:oauth:token-type:jwt"), + ), + ("token_type", serde_json::json!("N_A")), + ("expires_in", serde_json::json!(0)), + ("expires_in", serde_json::json!(-1)), + ("expires_in", serde_json::json!(0.5)), + ] { + let mut response = serde_json::json!({"access_token":"secret-access", "token_type":"Bearer", "issued_token_type":ACCESS_TOKEN, "expires_in":3600}); + response[field] = value; + server.enqueue(Reply::json(response)); + assert_eq!( + session.token().await.unwrap_err(), + "invalid workload token response" + ); + assert_eq!(server.drain().len(), 1); + } + for _ in 0..2 { + server.enqueue(exchange_reply(None)); + assert_eq!(session.token().await.unwrap(), "exchange-access"); + assert!(session.cached.lock().await.is_none()); + assert_eq!(server.drain().len(), 1); + } +} + +#[tokio::test] +async fn https_exchange_discovery_single_flight_and_expiry() { + let mut server = Server::start().await; + let (_dir, path) = source(); + let mut session = server.session(path.clone(), "https://api.example/mcp"); + session.config.grant = Grant::TokenExchange; + let session = Arc::new(session); + server.discovery(); + server.enqueue(exchange_reply(Some(1))); + let mut jobs = tokio::task::JoinSet::new(); + for _ in 0..16 { + let session = session.clone(); + jobs.spawn(async move { session.acquisition().await }); + } + while let Some(result) = jobs.join_next().await { + assert_eq!(result.unwrap().unwrap().token(), "exchange-access"); + } + let requests = server.drain(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[1].form()["subject_token"], ASSERTION); + // Force the existing cache past expiry without timing-dependent sleeps. + session.cached.lock().await.as_mut().unwrap().refresh_at = SystemTime::UNIX_EPOCH; + std::fs::write(path, "rotated.jwt.signature").unwrap(); + server.discovery(); + server.enqueue(exchange_reply(Some(3600))); + assert_eq!(session.token().await.unwrap(), "exchange-access"); + assert_eq!( + server.drain()[1].form()["subject_token"], + "rotated.jwt.signature" + ); +} + +#[tokio::test] +async fn https_explicit_rejection_never_retries_or_retains_stale_token() { + let mut server = Server::start().await; + let (_dir, path) = source(); + let mut session = server.session(path, "https://api.example/mcp"); + session.config.endpoint_mode = EndpointMode::Explicit; + session.config.expected_issuer.clear(); + session.config.grant = Grant::TokenExchange; + for status in [302, 401, 503] { + server.enqueue(exchange_reply(Some(3600))); + let old = session.acquisition().await.unwrap(); + session.invalidate_acquisition(&old).await; + server.drain(); + server.enqueue(Reply::status(status)); + assert_eq!( + session.token().await.unwrap_err(), + "workload token exchange refused" + ); + assert!(session.cached.lock().await.is_none()); + assert_eq!(server.drain().len(), 1); + } +} diff --git a/src/workload_auth/tests.rs b/src/workload_auth/tests.rs index b3e144c3..3e08a34a 100644 --- a/src/workload_auth/tests.rs +++ b/src/workload_auth/tests.rs @@ -16,6 +16,7 @@ fn config() -> Config { expected_issuer: "https://issuer.example/tenant".into(), token_endpoint: "https://issuer.example/tenant/token".into(), resource: "https://resource.example/mcp".into(), + ..Default::default() } } @@ -91,9 +92,8 @@ async fn projected_symlink_rotation_is_supported() { } #[test] -fn response_requires_expiry_and_bearer_fields() { +fn response_requires_bearer_fields_and_unsigned_expiry() { for body in [ - r#"{"access_token":"secret","token_type":"Bearer"}"#, r#"{"access_token":"secret","expires_in":60}"#, r#"{"access_token":"secret","token_type":"Bearer","expires_in":-1}"#, ] { @@ -148,3 +148,54 @@ async fn caches_are_resource_local_and_invalidatable() { .await; assert!(first.cached.lock().await.is_none()); } + +#[test] +fn explicit_configuration_and_extensions_are_strict() { + let mut candidate = config(); + candidate.endpoint_mode = EndpointMode::Explicit; + candidate.expected_issuer.clear(); + candidate.resource = "urn:example:inference".into(); + assert!(candidate.validate().is_ok()); + candidate + .extensions + .insert("federation_policy_id".into(), "example".into()); + assert!(candidate.validate().is_ok()); + for key in [ + "grant_type", + "subject_token", + "assertion", + "requested_token_type", + "client_secret", + "client_assertion", + "resource", + "scope", + "Authorization", + "actor_token", + ] { + candidate.extensions.insert(key.into(), "no".into()); + assert!(candidate.validate().is_err(), "{key}"); + candidate.extensions.remove(key); + } + for endpoint in [ + "http://issuer.example/token", + "https://issuer.example/token?secret=value", + "https://user@issuer.example/token", + "https://issuer.example/token#part", + ] { + candidate.token_endpoint = endpoint.into(); + assert!(candidate.validate().is_err()); + } + let decoded: Config = serde_json::from_value(serde_json::json!({ + "tokenFile":"/run/identity/token", "tokenEndpoint":"https://issuer.example/token", + "endpointMode":"explicit", "grant":"tokenExchange" + })) + .unwrap(); + assert!(decoded.validate().is_ok()); + assert!(decoded.grant == Grant::TokenExchange); + assert!(config().grant == Grant::JwtBearer); + candidate = decoded; + candidate + .extensions + .insert("custom".into(), "x".repeat(1025)); + assert!(candidate.validate().is_err()); +} From 00624916aca4cccb855f638cf894cd646b156e4d Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 23 Oct 2026 23:11:23 +0100 Subject: [PATCH 2/2] test(mcp): cover explicit assertion limits and optional resources --- src/workload_auth/https_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/workload_auth/https_tests.rs b/src/workload_auth/https_tests.rs index 07ffc5c5..3044b571 100644 --- a/src/workload_auth/https_tests.rs +++ b/src/workload_auth/https_tests.rs @@ -534,6 +534,13 @@ async fn https_explicit_grants_exact_fields_and_rotation() { &session.acquisition().await.unwrap().0 )); assert!(server.drain().is_empty()); + session.invalidate_acquisition(&renewed).await; + std::fs::write(&path, format!("{}.bbb.ccc", "a".repeat(16384))).unwrap(); + assert_eq!( + session.token().await.unwrap_err(), + "workload assertion exceeds size limit" + ); + assert!(server.drain().is_empty()); } } @@ -565,11 +572,14 @@ async fn https_exchange_validates_response_and_does_not_cache_unknown_expiry() { ); assert_eq!(server.drain().len(), 1); } + session.config.resource.clear(); for _ in 0..2 { server.enqueue(exchange_reply(None)); assert_eq!(session.token().await.unwrap(), "exchange-access"); assert!(session.cached.lock().await.is_none()); - assert_eq!(server.drain().len(), 1); + let requests = server.drain(); + assert_eq!(requests.len(), 1); + assert!(!requests[0].form().contains_key("resource")); } }