mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
Add enterprise ID-JAG exchange for MCP OAuth (#40722)
## What changed - Add a non-interactive two-step exchange that obtains an ID-JAG from an enterprise identity provider and trades it for a resource-bound MCP bearer token. - Validate trusted endpoint URLs, request inputs, ID-JAG claims, resource and scope bindings, and token responses before credentials are forwarded or a bearer token is returned. - Expose structured authentication failures while redacting credentials and provider-controlled error details from diagnostics. ## Testing - Cover successful exchanges, signed scope narrowing, invalid claims and token responses, request validation, and error redaction. GitOrigin-RevId: d716e0e1c2dc6b230cecbc0e9cc09afeee81d599
This commit is contained in:
65
codex-rs/rmcp-client/src/ema_auth_policy.rs
Normal file
65
codex-rs/rmcp-client/src/ema_auth_policy.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Credential-destination policy for enterprise MCP OAuth.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use url::Host;
|
||||
use url::Url;
|
||||
|
||||
/// A sanitized enterprise-auth failure that callers may handle without parsing text.
|
||||
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum EmaAuthFailure {
|
||||
#[error("invalid_grant")]
|
||||
InvalidGrant { grant_source: EmaInvalidGrantSource },
|
||||
#[error("insufficient_user_authentication")]
|
||||
InsufficientUserAuthentication,
|
||||
#[error("enterprise identity requires authentication")]
|
||||
ReauthenticationRequired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmaInvalidGrantSource {
|
||||
EnterpriseIdentity,
|
||||
ResourceAuthorization,
|
||||
}
|
||||
|
||||
pub(crate) fn safe_oauth_error_code(code: Option<&str>) -> &str {
|
||||
code.filter(|code| {
|
||||
matches!(
|
||||
*code,
|
||||
"invalid_request"
|
||||
| "invalid_client"
|
||||
| "invalid_grant"
|
||||
| "invalid_scope"
|
||||
| "invalid_target"
|
||||
| "unauthorized_client"
|
||||
| "unsupported_grant_type"
|
||||
| "access_denied"
|
||||
| "temporarily_unavailable"
|
||||
| "server_error"
|
||||
| "insufficient_user_authentication"
|
||||
)
|
||||
})
|
||||
.unwrap_or("OAuth token request rejected")
|
||||
}
|
||||
|
||||
pub(crate) fn validate_ema_oauth_endpoint(endpoint: &str, description: &str) -> Result<()> {
|
||||
let url = Url::parse(endpoint).with_context(|| format!("{description} is not a valid URL"))?;
|
||||
validate_credential_destination(&url, description)
|
||||
}
|
||||
|
||||
fn validate_credential_destination(url: &Url, description: &str) -> Result<()> {
|
||||
let loopback = match url.host() {
|
||||
Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
None => false,
|
||||
};
|
||||
if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
|
||||
bail!("{description} must use HTTPS or an HTTP loopback address");
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
|
||||
bail!("{description} contains disallowed credentials or a URL fragment");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
200
codex-rs/rmcp-client/src/ema_claims.rs
Normal file
200
codex-rs/rmcp-client/src/ema_claims.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
//! Validate token routing and signed authorization before forwarding an ID-JAG.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::ema_exchange::EmaAccessToken;
|
||||
|
||||
pub(crate) const ID_JAG_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:id-jag";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JwtHeader {
|
||||
alg: String,
|
||||
typ: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OAuthResource {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl OAuthResource {
|
||||
fn is_exact(&self, expected: &str) -> bool {
|
||||
match self {
|
||||
Self::Single(value) => value == expected,
|
||||
Self::Multiple(values) => values.as_slice() == [expected],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_jwt<T: DeserializeOwned>(token: &str) -> Result<(JwtHeader, T)> {
|
||||
let mut parts = token.split('.');
|
||||
let (Some(header), Some(payload), Some(signature), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
bail!("identity assertion is not a compact signed JWT");
|
||||
};
|
||||
if header.is_empty() || payload.is_empty() || signature.is_empty() {
|
||||
bail!("identity assertion contains an empty JWT segment");
|
||||
}
|
||||
let header: JwtHeader = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header)?)
|
||||
.map_err(|_| anyhow!("invalid identity assertion JWT header"))?;
|
||||
if header.alg.trim().is_empty() || header.alg.eq_ignore_ascii_case("none") {
|
||||
bail!("identity assertion is unsigned");
|
||||
}
|
||||
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)
|
||||
.map_err(|_| anyhow!("invalid identity assertion JWT claims"))?;
|
||||
Ok((header, claims))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IdJagClaims {
|
||||
iss: String,
|
||||
sub: String,
|
||||
aud: OAuthResource,
|
||||
client_id: String,
|
||||
jti: String,
|
||||
exp: u64,
|
||||
iat: u64,
|
||||
resource: OAuthResource,
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct IdJagBinding<'a> {
|
||||
pub issuer: &'a str,
|
||||
pub audience: &'a str,
|
||||
pub client_id: &'a str,
|
||||
pub resource: &'a str,
|
||||
/// Empty means the scope parameter was omitted, not an empty authorization ceiling.
|
||||
pub requested_scopes: &'a HashSet<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct IdJagResponse {
|
||||
pub access_token: String,
|
||||
issued_token_type: String,
|
||||
token_type: String,
|
||||
resource: Option<OAuthResource>,
|
||||
scope: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
impl IdJagResponse {
|
||||
pub(crate) fn validate(&self, binding: IdJagBinding<'_>) -> Result<HashSet<String>> {
|
||||
if self.issued_token_type != ID_JAG_TOKEN_TYPE
|
||||
|| self.token_type != "N_A"
|
||||
|| self.refresh_token.is_some()
|
||||
{
|
||||
bail!("enterprise IdP returned an unsupported ID-JAG token type or refresh token");
|
||||
}
|
||||
let (header, claims): (_, IdJagClaims) = signed_jwt(&self.access_token)?;
|
||||
if header.typ.as_deref() != Some("oauth-id-jag+jwt")
|
||||
|| claims.iss != binding.issuer
|
||||
|| !claims.aud.is_exact(binding.audience)
|
||||
|| claims.client_id != binding.client_id
|
||||
|| claims.sub.trim().is_empty()
|
||||
|| claims.jti.trim().is_empty()
|
||||
{
|
||||
bail!("ID-JAG type, issuer, audience, client, subject, or JWT ID is invalid");
|
||||
}
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
if claims.exp <= now || claims.iat > now.saturating_add(60) {
|
||||
bail!("enterprise IdP returned an expired or future-issued ID-JAG");
|
||||
}
|
||||
if !claims.resource.is_exact(binding.resource)
|
||||
|| self
|
||||
.resource
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_exact(binding.resource))
|
||||
{
|
||||
bail!("ID-JAG must authorize exactly the configured MCP resource");
|
||||
}
|
||||
let granted = match claims.scope.as_deref() {
|
||||
Some(scope) => parse_scope(scope)?,
|
||||
None if binding.requested_scopes.is_empty() => HashSet::new(),
|
||||
None => bail!("ID-JAG is missing the requested scope authorization"),
|
||||
};
|
||||
if !binding.requested_scopes.is_empty() && !granted.is_subset(binding.requested_scopes) {
|
||||
bail!("ID-JAG contains a scope outside the enterprise authorization request");
|
||||
}
|
||||
match self.scope.as_deref() {
|
||||
Some(scope) if parse_scope(scope)? != granted => {
|
||||
bail!("enterprise IdP token response scope does not match the signed ID-JAG")
|
||||
}
|
||||
None if !binding.requested_scopes.is_empty()
|
||||
&& granted != *binding.requested_scopes =>
|
||||
{
|
||||
bail!("enterprise IdP token response omitted its narrowed scope")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(granted.into_iter().map(str::to_string).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scope(scope: &str) -> Result<HashSet<&str>> {
|
||||
let scopes = scope.split_ascii_whitespace().collect::<HashSet<_>>();
|
||||
if scopes.is_empty() || scopes.len() != scope.split_ascii_whitespace().count() {
|
||||
bail!("enterprise authorization contains malformed or duplicate scopes");
|
||||
}
|
||||
Ok(scopes)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct McpAccessTokenResponse {
|
||||
access_token: String,
|
||||
token_type: String,
|
||||
expires_in: Option<u64>,
|
||||
resource: Option<OAuthResource>,
|
||||
scope: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
impl McpAccessTokenResponse {
|
||||
pub(crate) fn validate(
|
||||
self,
|
||||
resource: &str,
|
||||
id_jag_scopes: &HashSet<String>,
|
||||
) -> Result<EmaAccessToken> {
|
||||
if !self.token_type.eq_ignore_ascii_case("bearer") || self.access_token.trim().is_empty() {
|
||||
bail!("MCP authorization server returned an invalid bearer token");
|
||||
}
|
||||
if self.refresh_token.is_some() || self.expires_in == Some(0) {
|
||||
bail!("MCP authorization server returned a refresh token or zero token lifetime");
|
||||
}
|
||||
// The stable EMA response does not require the resource to be echoed;
|
||||
// when present, it must agree with the resource bound in the ID-JAG.
|
||||
if self
|
||||
.resource
|
||||
.as_ref()
|
||||
.is_some_and(|returned| !returned.is_exact(resource))
|
||||
{
|
||||
bail!("MCP access token must authorize exactly the configured MCP resource");
|
||||
}
|
||||
// RFC 6749 defines an omitted scope as unchanged from the request. Here
|
||||
// that authority is the scope carried by the validated ID-JAG.
|
||||
if let Some(scope) = self.scope.as_deref()
|
||||
&& !parse_scope(scope)?
|
||||
.iter()
|
||||
.all(|scope| id_jag_scopes.contains(*scope))
|
||||
{
|
||||
bail!("MCP authorization server granted a scope outside the ID-JAG authorization");
|
||||
}
|
||||
Ok(EmaAccessToken {
|
||||
access_token: self.access_token,
|
||||
expires_in: self.expires_in.map(Duration::from_secs),
|
||||
})
|
||||
}
|
||||
}
|
||||
219
codex-rs/rmcp-client/src/ema_exchange.rs
Normal file
219
codex-rs/rmcp-client/src/ema_exchange.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! Non-interactive ID-JAG exchange against explicitly trusted OAuth endpoints.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use codex_exec_server::HttpClient;
|
||||
use rmcp::transport::auth::OAuthHttpRedirectPolicy;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::ema_auth_policy::EmaAuthFailure;
|
||||
use crate::ema_auth_policy::EmaInvalidGrantSource;
|
||||
use crate::ema_auth_policy::safe_oauth_error_code;
|
||||
use crate::ema_auth_policy::validate_ema_oauth_endpoint;
|
||||
use crate::ema_claims::ID_JAG_TOKEN_TYPE;
|
||||
use crate::ema_claims::IdJagBinding;
|
||||
use crate::ema_claims::IdJagResponse;
|
||||
use crate::ema_claims::McpAccessTokenResponse;
|
||||
use crate::http_client_adapter::StreamableHttpRedirectMode;
|
||||
use crate::oauth_http_client::OAuthHttpClientAdapter;
|
||||
use crate::utils::build_default_headers;
|
||||
|
||||
pub(crate) const TOKEN_EXCHANGE_GRANT_TYPE: &str =
|
||||
"urn:ietf:params:oauth:grant-type:token-exchange";
|
||||
pub(crate) const JWT_BEARER_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
||||
|
||||
/// A resource-bound bearer and its server-reported lifetime, with redacted diagnostics.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct EmaAccessToken {
|
||||
pub access_token: String,
|
||||
pub expires_in: Option<Duration>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EmaAccessToken {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("EmaAccessToken")
|
||||
.field("access_token", &"[REDACTED]")
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller supplies trusted authorization-server metadata and an IdP credential.
|
||||
/// This primitive does not perform resource discovery or interactive login.
|
||||
pub struct EmaIdJagExchangeRequest<'a> {
|
||||
pub resource: &'a str,
|
||||
pub scopes: &'a [String],
|
||||
pub mcp_client_id: &'a str,
|
||||
pub authorization_server_issuer: &'a str,
|
||||
pub authorization_server_token_endpoint: &'a str,
|
||||
pub idp_token_endpoint: &'a str,
|
||||
pub idp_issuer: &'a str,
|
||||
pub idp_client_id: &'a str,
|
||||
pub refresh_token: String,
|
||||
pub idp_http_client: Arc<dyn HttpClient>,
|
||||
pub resource_http_client: Arc<dyn HttpClient>,
|
||||
}
|
||||
|
||||
/// Exchanges an enterprise IdP credential for a resource-bound MCP bearer token.
|
||||
pub async fn exchange_id_jag(request: EmaIdJagExchangeRequest<'_>) -> Result<EmaAccessToken> {
|
||||
for (endpoint, description) in [
|
||||
(request.resource, "enterprise MCP resource"),
|
||||
(
|
||||
request.authorization_server_issuer,
|
||||
"MCP authorization server issuer",
|
||||
),
|
||||
(
|
||||
request.authorization_server_token_endpoint,
|
||||
"MCP token endpoint",
|
||||
),
|
||||
(request.idp_issuer, "enterprise IdP issuer"),
|
||||
(request.idp_token_endpoint, "enterprise IdP token endpoint"),
|
||||
] {
|
||||
validate_ema_oauth_endpoint(endpoint, description)?;
|
||||
}
|
||||
if request.authorization_server_issuer == request.idp_issuer {
|
||||
bail!("enterprise IdP and MCP authorization server issuers must be different for ID-JAG");
|
||||
}
|
||||
if request.mcp_client_id.trim().is_empty() || request.idp_client_id.trim().is_empty() {
|
||||
bail!("enterprise authorization requires the registered IdP and MCP client IDs");
|
||||
}
|
||||
if request.refresh_token.trim().is_empty() {
|
||||
bail!("enterprise IdP refresh token must not be empty");
|
||||
}
|
||||
let requested_scopes = request
|
||||
.scopes
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
if requested_scopes.len() != request.scopes.len()
|
||||
|| request
|
||||
.scopes
|
||||
.iter()
|
||||
.any(|scope| scope.is_empty() || scope.chars().any(char::is_whitespace))
|
||||
{
|
||||
bail!("enterprise MCP authorization scopes must be distinct, non-empty scope tokens");
|
||||
}
|
||||
let scope = (!request.scopes.is_empty()).then(|| request.scopes.join(" "));
|
||||
let mut params = vec![
|
||||
("grant_type", TOKEN_EXCHANGE_GRANT_TYPE),
|
||||
("requested_token_type", ID_JAG_TOKEN_TYPE),
|
||||
("audience", request.authorization_server_issuer),
|
||||
("resource", request.resource),
|
||||
("subject_token", request.refresh_token.as_str()),
|
||||
(
|
||||
"subject_token_type",
|
||||
"urn:ietf:params:oauth:token-type:refresh_token",
|
||||
),
|
||||
];
|
||||
if let Some(scope) = scope.as_deref() {
|
||||
params.push(("scope", scope));
|
||||
}
|
||||
let id_jag: IdJagResponse = post_form(
|
||||
&request.idp_http_client,
|
||||
request.idp_token_endpoint,
|
||||
¶ms,
|
||||
request.idp_client_id,
|
||||
EmaInvalidGrantSource::EnterpriseIdentity,
|
||||
"enterprise IdP ID-JAG exchange",
|
||||
)
|
||||
.await?;
|
||||
let granted_scopes = id_jag.validate(IdJagBinding {
|
||||
issuer: request.idp_issuer,
|
||||
audience: request.authorization_server_issuer,
|
||||
client_id: request.mcp_client_id,
|
||||
resource: request.resource,
|
||||
requested_scopes: &requested_scopes,
|
||||
})?;
|
||||
// Only the signed assertion carries authority to the Resource AS. Repeating
|
||||
// the requested resource or scopes could undo enterprise policy narrowing.
|
||||
let access_token: McpAccessTokenResponse = post_form(
|
||||
&request.resource_http_client,
|
||||
request.authorization_server_token_endpoint,
|
||||
&[
|
||||
("grant_type", JWT_BEARER_GRANT_TYPE),
|
||||
("assertion", id_jag.access_token.as_str()),
|
||||
],
|
||||
request.mcp_client_id,
|
||||
EmaInvalidGrantSource::ResourceAuthorization,
|
||||
"MCP JWT bearer exchange",
|
||||
)
|
||||
.await?;
|
||||
access_token.validate(request.resource, &granted_scopes)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OAuthErrorResponse {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn post_form<T: DeserializeOwned>(
|
||||
http_client: &Arc<dyn HttpClient>,
|
||||
url: &str,
|
||||
params: &[(&str, &str)],
|
||||
client_id: &str,
|
||||
invalid_grant_source: EmaInvalidGrantSource,
|
||||
operation: &str,
|
||||
) -> Result<T> {
|
||||
let client = OAuthHttpClientAdapter::new_with_redirect_mode(
|
||||
Arc::clone(http_client),
|
||||
build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?,
|
||||
url,
|
||||
/*has_configured_headers*/ false,
|
||||
StreamableHttpRedirectMode::Legacy,
|
||||
)?;
|
||||
let body = {
|
||||
let mut form = url::form_urlencoded::Serializer::new(String::new());
|
||||
form.extend_pairs(params.iter().copied());
|
||||
form.append_pair("client_id", client_id);
|
||||
form.finish().into_bytes()
|
||||
};
|
||||
let builder = oauth2::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(url)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.header("accept", "application/json");
|
||||
let response = client
|
||||
.execute_request(
|
||||
builder.body(body)?,
|
||||
OAuthHttpRedirectPolicy::Stop,
|
||||
Some(Duration::from_secs(30)),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow!("{operation} request failed: {error}"))?;
|
||||
if !response.status().is_success() {
|
||||
let error = serde_json::from_slice::<OAuthErrorResponse>(response.body()).ok();
|
||||
// Provider-controlled text may reflect the submitted assertion or secret.
|
||||
// Only known OAuth codes may reach callers.
|
||||
let code = safe_oauth_error_code(error.as_ref().and_then(|error| error.error.as_deref()));
|
||||
if code == "invalid_grant" {
|
||||
return Err(anyhow::Error::new(EmaAuthFailure::InvalidGrant {
|
||||
grant_source: invalid_grant_source,
|
||||
})
|
||||
.context(format!(
|
||||
"{operation} returned HTTP {}: invalid_grant",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
if code == "insufficient_user_authentication" {
|
||||
return Err(
|
||||
anyhow::Error::new(EmaAuthFailure::InsufficientUserAuthentication).context(
|
||||
format!("{operation} returned HTTP {}: {code}", response.status()),
|
||||
),
|
||||
);
|
||||
}
|
||||
bail!("{operation} returned HTTP {}: {code}", response.status());
|
||||
}
|
||||
serde_json::from_slice(response.body())
|
||||
.map_err(|_| anyhow!("failed to parse {operation} response"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ema_exchange_tests.rs"]
|
||||
mod tests;
|
||||
420
codex-rs/rmcp-client/src/ema_exchange_tests.rs
Normal file
420
codex-rs/rmcp-client/src/ema_exchange_tests.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use codex_exec_server::RouteAwareHttpClient;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use futures::FutureExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn http_client() -> Arc<dyn HttpClient> {
|
||||
Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
|
||||
OutboundProxyPolicy::ReqwestDefault,
|
||||
)))
|
||||
}
|
||||
|
||||
fn unique_form_fields(body: &[u8]) -> HashMap<String, String> {
|
||||
let pairs = url::form_urlencoded::parse(body)
|
||||
.into_owned()
|
||||
.collect::<Vec<_>>();
|
||||
let fields = pairs.iter().cloned().collect::<HashMap<_, _>>();
|
||||
assert_eq!(
|
||||
pairs.len(),
|
||||
fields.len(),
|
||||
"OAuth form must not contain duplicate fields"
|
||||
);
|
||||
fields
|
||||
}
|
||||
|
||||
fn jwt(claims: &Value) -> String {
|
||||
format!(
|
||||
"{}.{}.signature",
|
||||
URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"oauth-id-jag+jwt"}"#),
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("serialize claims"))
|
||||
)
|
||||
}
|
||||
|
||||
fn claims(issuer: &str, audience: &str, resource: &str) -> Value {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("current time")
|
||||
.as_secs();
|
||||
json!({"iss":issuer,"aud":audience,"sub":"user","client_id":"mcp-client",
|
||||
"jti":"unique-jag","iat":now,"exp":now + 3600,"resource":resource,"scope":"files.read"})
|
||||
}
|
||||
|
||||
fn jag_response(claims: &Value) -> Value {
|
||||
let mut response = json!({"access_token":jwt(claims),"issued_token_type":ID_JAG_TOKEN_TYPE,
|
||||
"token_type":"N_A","resource":claims["resource"]});
|
||||
if let Some(scope) = claims.get("scope") {
|
||||
response["scope"] = scope.clone();
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn token_response() -> Value {
|
||||
json!({"access_token":"resource-token","token_type":"Bearer","expires_in":300})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_client_round_trip_preserves_signed_narrowing() -> Result<()> {
|
||||
let requested_scopes = ["files.read".to_string(), "files.write".to_string()];
|
||||
let client = http_client();
|
||||
for (scopes, echo_scope, refresh_token) in [
|
||||
(requested_scopes.as_slice(), true, "opaque-refresh-token"),
|
||||
(&[], true, "opaque-refresh-token"),
|
||||
(&[], false, "opaque-refresh-token"),
|
||||
(requested_scopes.as_slice(), true, ""),
|
||||
(requested_scopes.as_slice(), true, " \t"),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
let issuer = format!("{}/idp", server.uri());
|
||||
let audience = format!("{}/as", server.uri());
|
||||
let resource = format!("{}/mcp", server.uri());
|
||||
let mut jag = jag_response(&claims(&issuer, &audience, &resource));
|
||||
if !echo_scope {
|
||||
jag.as_object_mut()
|
||||
.expect("ID-JAG response")
|
||||
.remove("scope");
|
||||
}
|
||||
let valid = !refresh_token.trim().is_empty();
|
||||
for (endpoint, response) in [("/idp/token", jag.clone()), ("/as/token", token_response())] {
|
||||
Mock::given(method("POST"))
|
||||
.and(path(endpoint))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(response))
|
||||
.expect(u64::from(valid))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
let expected_subject = refresh_token.to_string();
|
||||
let result = exchange_id_jag(EmaIdJagExchangeRequest {
|
||||
resource: &resource,
|
||||
scopes,
|
||||
mcp_client_id: "mcp-client",
|
||||
authorization_server_issuer: &audience,
|
||||
authorization_server_token_endpoint: &format!("{audience}/token"),
|
||||
idp_token_endpoint: &format!("{issuer}/token"),
|
||||
idp_issuer: &issuer,
|
||||
idp_client_id: "idp-client",
|
||||
refresh_token: refresh_token.to_string(),
|
||||
idp_http_client: Arc::clone(&client),
|
||||
resource_http_client: Arc::clone(&client),
|
||||
})
|
||||
.boxed()
|
||||
.await;
|
||||
if !valid {
|
||||
assert!(result.is_err(), "invalid subject must fail before HTTP");
|
||||
assert!(
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests")
|
||||
.is_empty()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
result?,
|
||||
EmaAccessToken {
|
||||
access_token: "resource-token".to_string(),
|
||||
expires_in: Some(Duration::from_secs(300)),
|
||||
}
|
||||
);
|
||||
let requests = server.received_requests().await.expect("requests");
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| request.headers.get("authorization").is_none())
|
||||
);
|
||||
let mut forms = requests
|
||||
.iter()
|
||||
.map(|request| unique_form_fields(&request.body))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
forms[0].remove("scope"),
|
||||
(!scopes.is_empty()).then(|| scopes.join(" "))
|
||||
);
|
||||
assert_eq!(
|
||||
forms[0],
|
||||
HashMap::from([
|
||||
(
|
||||
"grant_type".to_string(),
|
||||
TOKEN_EXCHANGE_GRANT_TYPE.to_string()
|
||||
),
|
||||
(
|
||||
"requested_token_type".to_string(),
|
||||
ID_JAG_TOKEN_TYPE.to_string(),
|
||||
),
|
||||
("subject_token".to_string(), expected_subject),
|
||||
(
|
||||
"subject_token_type".to_string(),
|
||||
"urn:ietf:params:oauth:token-type:refresh_token".to_string(),
|
||||
),
|
||||
("audience".to_string(), audience.clone()),
|
||||
("resource".to_string(), resource.clone()),
|
||||
("client_id".to_string(), "idp-client".to_string()),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
forms[1],
|
||||
HashMap::from([
|
||||
("grant_type".to_string(), JWT_BEARER_GRANT_TYPE.to_string()),
|
||||
(
|
||||
"assertion".to_string(),
|
||||
jag["access_token"].as_str().expect("JAG").to_string()
|
||||
),
|
||||
("client_id".to_string(), "mcp-client".to_string()),
|
||||
])
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_claims_and_resource_tokens_cannot_widen_authority() -> Result<()> {
|
||||
let requested = HashSet::from(["files.read", "files.write"]);
|
||||
let original = claims(
|
||||
"https://idp.example",
|
||||
"https://as.example",
|
||||
"https://mcp.example",
|
||||
);
|
||||
let binding = || IdJagBinding {
|
||||
issuer: "https://idp.example",
|
||||
audience: "https://as.example",
|
||||
client_id: "mcp-client",
|
||||
resource: "https://mcp.example",
|
||||
requested_scopes: &requested,
|
||||
};
|
||||
let valid: IdJagResponse = serde_json::from_value(jag_response(&original))?;
|
||||
let granted = valid.validate(binding())?;
|
||||
assert_eq!(granted, HashSet::from(["files.read".to_string()]));
|
||||
for (requested_scopes, signed_scope, response_scope, valid) in [
|
||||
("", Some("files.read"), Some("files.read"), true),
|
||||
("", Some("files.read"), None, true),
|
||||
("", None, None, true),
|
||||
("files.read", Some("files.read"), None, true),
|
||||
("files.read files.write", Some("files.read"), None, false),
|
||||
(
|
||||
"files.read",
|
||||
Some("files.read files.write"),
|
||||
Some("files.read files.write"),
|
||||
false,
|
||||
),
|
||||
("files.read", None, None, false),
|
||||
("", Some("files.read"), Some("files.write"), false),
|
||||
("", Some(" \t"), None, false),
|
||||
("", Some("files.read files.read"), Some("files.read"), false),
|
||||
("", Some("files.read"), Some(" \t"), false),
|
||||
("", Some("files.read"), Some("files.read files.read"), false),
|
||||
] {
|
||||
let requested_scopes = requested_scopes.split_ascii_whitespace().collect();
|
||||
let mut scoped_claims = original.clone();
|
||||
scoped_claims
|
||||
.as_object_mut()
|
||||
.expect("ID-JAG claims")
|
||||
.remove("scope");
|
||||
if let Some(scope) = signed_scope {
|
||||
scoped_claims["scope"] = json!(scope);
|
||||
}
|
||||
let mut response = jag_response(&scoped_claims);
|
||||
response
|
||||
.as_object_mut()
|
||||
.expect("ID-JAG response")
|
||||
.remove("scope");
|
||||
if let Some(scope) = response_scope {
|
||||
response["scope"] = json!(scope);
|
||||
}
|
||||
let response: IdJagResponse = serde_json::from_value(response)?;
|
||||
let result = response.validate(IdJagBinding {
|
||||
requested_scopes: &requested_scopes,
|
||||
..binding()
|
||||
});
|
||||
assert_eq!(
|
||||
result.is_ok(),
|
||||
valid,
|
||||
"requested {requested_scopes:?}, signed {signed_scope:?}, response {response_scope:?}"
|
||||
);
|
||||
if let Ok(granted) = result {
|
||||
assert_eq!(
|
||||
granted,
|
||||
signed_scope
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<HashSet<_>>()
|
||||
);
|
||||
for (scope, valid_token) in [
|
||||
(None, true),
|
||||
(Some("files.read"), signed_scope.is_some()),
|
||||
(Some("files.read files.write"), false),
|
||||
] {
|
||||
let mut response = token_response();
|
||||
if let Some(scope) = scope {
|
||||
response["scope"] = json!(scope);
|
||||
}
|
||||
let response: McpAccessTokenResponse = serde_json::from_value(response)?;
|
||||
assert_eq!(
|
||||
response.validate("https://mcp.example", &granted).is_ok(),
|
||||
valid_token,
|
||||
"ID-JAG {signed_scope:?}, bearer {scope:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rotated = jag_response(&original);
|
||||
rotated["refresh_token"] = json!("unsupported-jag-refresh-token");
|
||||
let response: IdJagResponse = serde_json::from_value(rotated)?;
|
||||
assert!(response.validate(binding()).is_err());
|
||||
for header in [
|
||||
json!({"alg": "ES256", "typ": "JWT"}),
|
||||
json!({"alg": "ES256"}),
|
||||
json!({"alg": "none", "typ": "oauth-id-jag+jwt"}),
|
||||
] {
|
||||
let mut changed = jag_response(&original);
|
||||
changed["access_token"] = json!(format!(
|
||||
"{}.{}.signature",
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?),
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&original)?),
|
||||
));
|
||||
let response: IdJagResponse = serde_json::from_value(changed)?;
|
||||
assert!(
|
||||
response.validate(binding()).is_err(),
|
||||
"accepted invalid ID-JAG header {header}"
|
||||
);
|
||||
}
|
||||
for (field, value) in [
|
||||
("iss", json!("https://attacker.example")),
|
||||
("aud", json!("https://attacker.example")),
|
||||
("client_id", json!("other-client")),
|
||||
("sub", json!("")),
|
||||
("jti", json!("")),
|
||||
("exp", json!(0)),
|
||||
("iat", json!(u64::MAX)),
|
||||
("scope", json!("files.admin")),
|
||||
(
|
||||
"resource",
|
||||
json!(["https://mcp.example", "https://other.example"]),
|
||||
),
|
||||
] {
|
||||
let mut changed = original.clone();
|
||||
changed[field] = value;
|
||||
let response: IdJagResponse = serde_json::from_value(jag_response(&changed))?;
|
||||
assert!(
|
||||
response.validate(binding()).is_err(),
|
||||
"accepted changed {field}"
|
||||
);
|
||||
}
|
||||
for (field, value) in [
|
||||
("scope", json!("files.read files.write")),
|
||||
("scope", json!("files.read files.read")),
|
||||
("resource", json!("https://other.example")),
|
||||
("expires_in", json!(0)),
|
||||
("refresh_token", json!("refresh")),
|
||||
("token_type", json!("N_A")),
|
||||
("access_token", json!("")),
|
||||
] {
|
||||
let mut changed = token_response();
|
||||
changed[field] = value;
|
||||
let response: McpAccessTokenResponse = serde_json::from_value(changed)?;
|
||||
assert!(
|
||||
response.validate("https://mcp.example", &granted).is_err(),
|
||||
"accepted changed {field}"
|
||||
);
|
||||
}
|
||||
let mut explicit_binding = token_response();
|
||||
explicit_binding["resource"] = json!("https://mcp.example");
|
||||
explicit_binding["scope"] = json!("files.read");
|
||||
let response: McpAccessTokenResponse = serde_json::from_value(explicit_binding)?;
|
||||
assert_eq!(
|
||||
response.validate("https://mcp.example", &granted)?,
|
||||
EmaAccessToken {
|
||||
access_token: "resource-token".to_string(),
|
||||
expires_in: Some(Duration::from_secs(300)),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_errors_cannot_reflect_credentials() {
|
||||
const SENTINEL: &str = "secret-assertion-sentinel";
|
||||
for (code, expected) in [
|
||||
(SENTINEL, "OAuth token request rejected"),
|
||||
("invalid_grant", "invalid_grant"),
|
||||
(
|
||||
"insufficient_user_authentication",
|
||||
"insufficient_user_authentication",
|
||||
),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
|
||||
"error":code,"error_description":SENTINEL,
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let error = post_form::<Value>(
|
||||
&http_client(),
|
||||
&format!("{}/token", server.uri()),
|
||||
&[("subject_token", SENTINEL)],
|
||||
"test-client",
|
||||
EmaInvalidGrantSource::EnterpriseIdentity,
|
||||
"test token exchange",
|
||||
)
|
||||
.await
|
||||
.expect_err("provider error should fail");
|
||||
match code {
|
||||
"invalid_grant" => assert_eq!(
|
||||
error.downcast_ref::<EmaAuthFailure>(),
|
||||
Some(&EmaAuthFailure::InvalidGrant {
|
||||
grant_source: EmaInvalidGrantSource::EnterpriseIdentity,
|
||||
})
|
||||
),
|
||||
"insufficient_user_authentication" => assert_eq!(
|
||||
error.downcast_ref::<EmaAuthFailure>(),
|
||||
Some(&EmaAuthFailure::InsufficientUserAuthentication)
|
||||
),
|
||||
_ => assert_eq!(error.downcast_ref::<EmaAuthFailure>(), None),
|
||||
}
|
||||
let error = error.to_string();
|
||||
assert!(!error.contains(SENTINEL), "provider reflected a credential");
|
||||
assert!(error.ends_with(expected), "{error}");
|
||||
}
|
||||
let server = MockServer::start().await;
|
||||
let mut malformed = token_response();
|
||||
malformed["expires_in"] = json!(SENTINEL);
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(malformed))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let error = post_form::<McpAccessTokenResponse>(
|
||||
&http_client(),
|
||||
&format!("{}/token", server.uri()),
|
||||
&[],
|
||||
"test-client",
|
||||
EmaInvalidGrantSource::EnterpriseIdentity,
|
||||
"test token exchange",
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("malformed response should fail");
|
||||
assert!(
|
||||
!format!("{error:#}").contains(SENTINEL),
|
||||
"parser reflected a credential"
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
mod auth_status;
|
||||
mod elicitation_client_service;
|
||||
mod ema_auth_policy;
|
||||
mod ema_claims;
|
||||
mod ema_exchange;
|
||||
mod event_notification_transport;
|
||||
mod executor_process_transport;
|
||||
mod http_client_adapter;
|
||||
@@ -28,6 +31,11 @@ pub use auth_status::determine_streamable_http_auth_status;
|
||||
pub use auth_status::determine_streamable_http_auth_status_from_credentials;
|
||||
pub use auth_status::discover_streamable_http_oauth;
|
||||
pub use codex_protocol::protocol::McpAuthStatus;
|
||||
pub use ema_auth_policy::EmaAuthFailure;
|
||||
pub use ema_auth_policy::EmaInvalidGrantSource;
|
||||
pub use ema_exchange::EmaAccessToken;
|
||||
pub use ema_exchange::EmaIdJagExchangeRequest;
|
||||
pub use ema_exchange::exchange_id_jag;
|
||||
pub use event_notification_transport::EventNotificationReceiver;
|
||||
pub use http_client_adapter::StreamableHttpRedirectMode;
|
||||
pub use http_headers::with_http_headers_helper;
|
||||
|
||||
@@ -120,7 +120,7 @@ impl OAuthHttpClientAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_request(
|
||||
pub(crate) async fn execute_request(
|
||||
&self,
|
||||
request: HttpRequest,
|
||||
redirect_policy: OAuthHttpRedirectPolicy,
|
||||
|
||||
Reference in New Issue
Block a user