Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions docs/user/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/tools/mcp/workload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ mod tests {
expected_issuer: origin.into(),
token_endpoint: format!("{origin}/token"),
resource: uri.clone(),
..Default::default()
},
tls_client().build().unwrap(),
)
Expand Down Expand Up @@ -485,6 +486,7 @@ mod tests {
expected_issuer: origin.into(),
token_endpoint: format!("{origin}/token"),
resource: uri.clone(),
..Default::default()
},
tls_client().build().unwrap(),
)
Expand Down
201 changes: 183 additions & 18 deletions src/workload_auth.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -37,26 +38,132 @@ 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<String>,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub extensions: BTreeMap<String, String>,
}

impl Config {
pub fn validate(&self) -> Result<(), String> {
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(())
}
Expand Down Expand Up @@ -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")?;
Expand All @@ -166,15 +307,23 @@ 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())
{
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
Expand Down Expand Up @@ -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<u64>,
issued_token_type: Option<String>,
}

fn optional_expiry<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<u64>, D::Error> {
u64::deserialize(deserializer).map(Some)
}

async fn bounded_body(mut response: reqwest::Response) -> Result<Zeroizing<Vec<u8>>, String> {
Expand All @@ -262,7 +419,15 @@ async fn bounded_body(mut response: reqwest::Response) -> Result<Zeroizing<Vec<u
Ok(bytes)
}

#[cfg(test)]
async fn read_assertion(path: &std::path::Path) -> Result<Zeroizing<String>, String> {
read_assertion_bounded(path, MAX_BYTES).await
}

async fn read_assertion_bounded(
path: &std::path::Path,
limit: usize,
) -> Result<Zeroizing<String>, String> {
let mut options = tokio::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
Expand All @@ -280,11 +445,11 @@ async fn read_assertion(path: &std::path::Path) -> Result<Zeroizing<String>, 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)
Expand Down
Loading
Loading