mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Use the rmcp SDK for enterprise-managed token exchanges (#49473)
## What changed Upgrade `rmcp` to a pinned Git revision of version `3.3.0` and enable `auth-enterprise-managed`. Delegate the two-stage ID-JAG exchange and token validation to the SDK, replacing the local implementation while retaining Codex HTTP routing and credential lifecycle policy. Map SDK errors by exchange stage so `invalid_grant` distinguishes enterprise identity failures from resource authorization failures, and preserve handling of `insufficient_user_authentication`. ## Testing Update exchange tests to verify rejection of unsupported `authorization_details` and malformed signed scopes. Exercise errors from both token endpoints to check credential policy, prevent credential reflection in diagnostics, and ensure rejected grants are not retried. GitOrigin-RevId: a56034aa3fd70002754ea552fd2da70bdcd85b31
This commit is contained in:
Generated
+3
-3
File diff suppressed because one or more lines are too long
Generated
+7
-9
@@ -11829,13 +11829,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "process-wrap"
|
||||
version = "9.0.1"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd1395947e69c07400ef4d43db0051d6f773c21f647ad8b97382fc01f0204c60"
|
||||
checksum = "0e3f4237d0e4741eb50bc5584db701f1299c85fa31ff0274dd6445e79dc42d12"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"indexmap 2.14.0",
|
||||
"nix 0.30.1",
|
||||
"nix 0.31.3",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows 0.62.2",
|
||||
@@ -13028,9 +13028,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5"
|
||||
version = "3.3.0"
|
||||
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3e636cab26c013eca5131103c03d20237f12c4df#3e636cab26c013eca5131103c03d20237f12c4df"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.0",
|
||||
@@ -13064,9 +13063,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda"
|
||||
version = "3.3.0"
|
||||
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3e636cab26c013eca5131103c03d20237f12c4df#3e636cab26c013eca5131103c03d20237f12c4df"
|
||||
dependencies = [
|
||||
"darling 0.24.0",
|
||||
"proc-macro2",
|
||||
|
||||
+2
-1
@@ -446,7 +446,8 @@ regex-automata = { version = "0.4.13", default-features = false, features = ["st
|
||||
regex-lite = "0.1.8"
|
||||
regex-syntax = "0.8.8"
|
||||
reqwest = { version = "0.12", features = ["cookies"] }
|
||||
rmcp = { version = "=3.2.0", default-features = false }
|
||||
# rmcp-v3.3.0 includes enterprise authorization helpers (rust-sdk#1234).
|
||||
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "3e636cab26c013eca5131103c03d20237f12c4df", version = "=3.3.0", default-features = false }
|
||||
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
|
||||
rustix = { version = "1.1.4", features = ["net"] }
|
||||
rustls = { version = "0.23.45", default-features = false, features = [
|
||||
|
||||
@@ -317,6 +317,7 @@ allow-git = [
|
||||
"https://github.com/hyperium/h3",
|
||||
"https://github.com/juberti-oai/rust-sdks.git",
|
||||
"https://github.com/microsoft/mxc",
|
||||
"https://github.com/modelcontextprotocol/rust-sdk",
|
||||
"https://github.com/openai-oss-forks/crossterm",
|
||||
"https://github.com/openai-oss-forks/tokio-tungstenite",
|
||||
"https://github.com/openai-oss-forks/tungstenite-rs",
|
||||
|
||||
@@ -34,7 +34,7 @@ keyring = { workspace = true, features = ["crypto-rust"] }
|
||||
memchr = { workspace = true }
|
||||
oauth2 = "5"
|
||||
rmcp = { workspace = true, default-features = false, features = [
|
||||
"auth",
|
||||
"auth-enterprise-managed",
|
||||
"base64",
|
||||
"client",
|
||||
"macros",
|
||||
|
||||
@@ -29,26 +29,6 @@ pub(crate) fn ema_reauthentication_required(message: &'static str) -> anyhow::Er
|
||||
anyhow::Error::new(EmaAuthFailure::ReauthenticationRequired).context(message)
|
||||
}
|
||||
|
||||
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_public_client_auth(
|
||||
advertised_methods: Option<&Value>,
|
||||
issuer_description: &str,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Validate token routing and signed authorization before forwarding an ID-JAG.
|
||||
//! Validate OIDC identity assertion bindings before storing enterprise credentials.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
@@ -13,14 +11,11 @@ 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)]
|
||||
@@ -30,16 +25,7 @@ enum OAuthResource {
|
||||
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)> {
|
||||
fn signed_jwt<T: DeserializeOwned>(token: &str) -> Result<T> {
|
||||
let mut parts = token.split('.');
|
||||
let (Some(header), Some(payload), Some(signature), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
@@ -56,7 +42,7 @@ fn signed_jwt<T: DeserializeOwned>(token: &str) -> Result<(JwtHeader, T)> {
|
||||
}
|
||||
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)
|
||||
.map_err(|_| anyhow!("invalid identity assertion JWT claims"))?;
|
||||
Ok((header, claims))
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -73,7 +59,7 @@ pub(crate) fn oidc_identity(
|
||||
expected_issuer: &str,
|
||||
expected_audience: &str,
|
||||
) -> Result<OidcClaims> {
|
||||
let (_, claims): (_, OidcClaims) = signed_jwt(assertion)?;
|
||||
let claims: OidcClaims = signed_jwt(assertion)?;
|
||||
if claims.iss != expected_issuer || claims.sub.trim().is_empty() {
|
||||
bail!("OIDC identity assertion issuer or subject does not match the enterprise IdP");
|
||||
}
|
||||
@@ -108,143 +94,3 @@ pub fn validate_oidc_identity_assertion(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
//! Non-interactive ID-JAG exchange against explicitly trusted OAuth endpoints.
|
||||
//! Adapt the SDK enterprise exchange to Codex HTTP routing and credential lifecycle errors.
|
||||
|
||||
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 oauth2::RefreshToken;
|
||||
use rmcp::transport::auth::enterprise::EmaAuthorizationServer;
|
||||
use rmcp::transport::auth::enterprise::EmaError;
|
||||
use rmcp::transport::auth::enterprise::EmaExchangeRequest;
|
||||
use rmcp::transport::auth::enterprise::EmaExchangeStage;
|
||||
|
||||
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";
|
||||
#[cfg(test)]
|
||||
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.
|
||||
@@ -63,155 +57,67 @@ pub struct EmaIdJagExchangeRequest<'a> {
|
||||
|
||||
/// 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),
|
||||
let idp_http = OAuthHttpClientAdapter::new_with_redirect_mode(
|
||||
request.idp_http_client,
|
||||
build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?,
|
||||
url,
|
||||
request.idp_token_endpoint,
|
||||
/*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()
|
||||
)));
|
||||
let resource_http = OAuthHttpClientAdapter::new_with_redirect_mode(
|
||||
request.resource_http_client,
|
||||
build_default_headers(/*http_headers*/ None, /*env_http_headers*/ None)?,
|
||||
request.authorization_server_token_endpoint,
|
||||
/*has_configured_headers*/ false,
|
||||
StreamableHttpRedirectMode::Legacy,
|
||||
)?;
|
||||
let refresh_token = RefreshToken::new(request.refresh_token);
|
||||
// Discovery has already verified both public-client registrations. The SDK owns
|
||||
// token validation and the two exchanges; Codex retains routing and credential policy.
|
||||
let token = EmaExchangeRequest::new(
|
||||
EmaAuthorizationServer::new(
|
||||
request.idp_issuer,
|
||||
request.idp_token_endpoint,
|
||||
request.idp_client_id,
|
||||
),
|
||||
EmaAuthorizationServer::new(
|
||||
request.authorization_server_issuer,
|
||||
request.authorization_server_token_endpoint,
|
||||
request.mcp_client_id,
|
||||
),
|
||||
request.resource,
|
||||
&refresh_token,
|
||||
)
|
||||
.with_scopes(request.scopes.iter().cloned())
|
||||
.exchange(&idp_http, &resource_http)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
let failure = match &error {
|
||||
EmaError::InvalidGrant(EmaExchangeStage::IdentityProvider) => {
|
||||
Some(EmaAuthFailure::InvalidGrant {
|
||||
grant_source: EmaInvalidGrantSource::EnterpriseIdentity,
|
||||
})
|
||||
}
|
||||
EmaError::InvalidGrant(EmaExchangeStage::ResourceAuthorizationServer) => {
|
||||
Some(EmaAuthFailure::InvalidGrant {
|
||||
grant_source: EmaInvalidGrantSource::ResourceAuthorization,
|
||||
})
|
||||
}
|
||||
EmaError::InsufficientUserAuthentication(_) => {
|
||||
Some(EmaAuthFailure::InsufficientUserAuthentication)
|
||||
}
|
||||
// Future SDK stages must not invalidate the shared enterprise credential.
|
||||
_ => None,
|
||||
};
|
||||
match failure {
|
||||
Some(failure) => anyhow::Error::new(failure).context(error),
|
||||
None => anyhow::Error::new(error),
|
||||
}
|
||||
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"))
|
||||
})?;
|
||||
Ok(EmaAccessToken {
|
||||
access_token: token.access_token.secret().to_owned(),
|
||||
expires_in: token.expires_in,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -19,6 +19,8 @@ use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
use super::*;
|
||||
use crate::ema_auth_policy::validate_ema_oauth_endpoint;
|
||||
use crate::ema_claims::ID_JAG_TOKEN_TYPE;
|
||||
use crate::ema_claims::validate_oidc_identity_assertion;
|
||||
|
||||
fn http_client() -> Arc<dyn HttpClient> {
|
||||
@@ -42,7 +44,7 @@ fn unique_form_fields(body: &[u8]) -> HashMap<String, String> {
|
||||
|
||||
fn jwt(claims: &Value) -> String {
|
||||
format!(
|
||||
"{}.{}.signature",
|
||||
"{}.{}.c2lnbmF0dXJl",
|
||||
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"))
|
||||
)
|
||||
@@ -70,6 +72,25 @@ fn token_response() -> Value {
|
||||
json!({"access_token":"resource-token","token_type":"Bearer","expires_in":300})
|
||||
}
|
||||
|
||||
async fn exchange(server: &MockServer, refresh_token: &str) -> Result<EmaAccessToken> {
|
||||
let issuer = format!("{}/idp", server.uri());
|
||||
let audience = format!("{}/as", server.uri());
|
||||
exchange_id_jag(EmaIdJagExchangeRequest {
|
||||
resource: &format!("{}/mcp", server.uri()),
|
||||
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: http_client(),
|
||||
resource_http_client: http_client(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_client_round_trip_preserves_signed_narrowing() -> Result<()> {
|
||||
let requested_scopes = ["files.read".to_string(), "files.write".to_string()];
|
||||
@@ -185,168 +206,73 @@ async fn public_client_round_trip_preserves_signed_narrowing() -> Result<()> {
|
||||
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),
|
||||
#[tokio::test]
|
||||
async fn sdk_validation_rejects_unsupported_authorization_before_returning_a_bearer() {
|
||||
for location in [
|
||||
"signed claims",
|
||||
"IdP response",
|
||||
"resource response",
|
||||
"signed scope",
|
||||
] {
|
||||
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:?}"
|
||||
let server = MockServer::builder().start().await;
|
||||
let original = claims(
|
||||
&format!("{}/idp", server.uri()),
|
||||
&format!("{}/as", server.uri()),
|
||||
&format!("{}/mcp", server.uri()),
|
||||
);
|
||||
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 mut jag = jag_response(&original);
|
||||
let mut bearer = token_response();
|
||||
let details = json!([{"type": "payment_initiation"}]);
|
||||
match location {
|
||||
"signed claims" | "signed scope" => {
|
||||
let mut changed = original;
|
||||
if location == "signed claims" {
|
||||
changed["authorization_details"] = details;
|
||||
} else {
|
||||
changed["scope"] = json!("files.read\tfiles.write");
|
||||
}
|
||||
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:?}"
|
||||
);
|
||||
jag = jag_response(&changed);
|
||||
}
|
||||
"IdP response" => jag["authorization_details"] = details,
|
||||
"resource response" => bearer["authorization_details"] = details,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
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)),
|
||||
let resource_requested = location == "resource response";
|
||||
for (endpoint, response, count) in [
|
||||
("/idp/token", jag, 1),
|
||||
("/as/token", bearer, u64::from(resource_requested)),
|
||||
] {
|
||||
Mock::given(method("POST"))
|
||||
.and(path(endpoint))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(response))
|
||||
.expect(count)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
let error = exchange(&server, "refresh-token")
|
||||
.await
|
||||
.expect_err("SDK must reject unsupported authorization");
|
||||
assert_eq!(
|
||||
error.downcast_ref::<EmaError>(),
|
||||
Some(&EmaError::InvalidResponse {
|
||||
stage: if resource_requested {
|
||||
EmaExchangeStage::ResourceAuthorizationServer
|
||||
} else {
|
||||
EmaExchangeStage::IdentityProvider
|
||||
},
|
||||
message: if location == "signed scope" {
|
||||
"malformed or duplicate scopes"
|
||||
} else {
|
||||
"authorization_details is not supported"
|
||||
},
|
||||
}),
|
||||
"{location}"
|
||||
);
|
||||
assert_eq!(
|
||||
server.received_requests().await.expect("requests").len(),
|
||||
if resource_requested { 2 } else { 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,72 +310,65 @@ fn identity_and_credential_destinations_are_bound() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_errors_cannot_reflect_credentials() {
|
||||
async fn provider_errors_preserve_credential_policy_without_reflecting_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",
|
||||
),
|
||||
for (endpoint, grant_source) in [
|
||||
("/idp/token", EmaInvalidGrantSource::EnterpriseIdentity),
|
||||
("/as/token", EmaInvalidGrantSource::ResourceAuthorization),
|
||||
] {
|
||||
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),
|
||||
for code in [
|
||||
SENTINEL,
|
||||
"invalid_grant",
|
||||
"insufficient_user_authentication",
|
||||
"malformed",
|
||||
] {
|
||||
let server = MockServer::builder().start().await;
|
||||
if endpoint == "/as/token" {
|
||||
let jag = jag_response(&claims(
|
||||
&format!("{}/idp", server.uri()),
|
||||
&format!("{}/as", server.uri()),
|
||||
&format!("{}/mcp", server.uri()),
|
||||
));
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/idp/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(jag))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
let response = if code == "malformed" {
|
||||
ResponseTemplate::new(200).set_body_json(json!({"expires_in": SENTINEL}))
|
||||
} else {
|
||||
ResponseTemplate::new(400).set_body_json(json!({
|
||||
"error":code,"error_description":SENTINEL,
|
||||
}))
|
||||
};
|
||||
Mock::given(method("POST"))
|
||||
.and(path(endpoint))
|
||||
.respond_with(response)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let error = exchange(&server, SENTINEL)
|
||||
.await
|
||||
.expect_err("provider error should fail");
|
||||
let expected = match code {
|
||||
"invalid_grant" => Some(EmaAuthFailure::InvalidGrant { grant_source }),
|
||||
"insufficient_user_authentication" => {
|
||||
Some(EmaAuthFailure::InsufficientUserAuthentication)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(error.downcast_ref::<EmaAuthFailure>(), expected.as_ref());
|
||||
assert!(
|
||||
!format!("{error:#?}").contains(SENTINEL),
|
||||
"provider reflected a credential"
|
||||
);
|
||||
assert_eq!(
|
||||
server.received_requests().await.expect("requests").len(),
|
||||
if endpoint == "/as/token" { 2 } else { 1 },
|
||||
"exchange must not retry a rejected grant"
|
||||
);
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user