mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
## Why Enterprise MCP token exchange and sign-in failures can collapse into generic errors, hiding useful diagnostics such as an OAuth `invalid_scope` rejection or a cancelled sign-in. ## What changed Introduce `EmaErrorDetail` to carry static operation messages and sanitized SDK diagnostics through token-source errors and enterprise login completion. Exchange failures can report the stage, HTTP status, and allowlisted OAuth error code, such as `IdentityProvider: HTTP 400: invalid_scope`, without retaining arbitrary provider text or raw error chains. Preserve specific discovery, credential storage, cancellation, and account/configuration mismatch errors instead of replacing them with generic failure messages. ## Testing Extend tests to cover `invalid_scope` diagnostics, specific sign-in completion errors, and exclusion of credentials from diagnostics even when an outer error context contains sensitive text. GitOrigin-RevId: e9280a5db3565ea91da0810bab528da5ee088d64
469 lines
17 KiB
Rust
469 lines
17 KiB
Rust
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::*;
|
|
use crate::EmaCredentialKind;
|
|
use crate::EmaErrorDetail;
|
|
use crate::EmaTokenSourceError;
|
|
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> {
|
|
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!(
|
|
"{}.{}.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"))
|
|
)
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
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 {
|
|
idp_client_authentication: EmaClientAuthentication::None,
|
|
resource_client_authentication: EmaClientAuthentication::None,
|
|
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(),
|
|
},
|
|
(),
|
|
std::convert::identity,
|
|
)
|
|
.await
|
|
.map(|(token, ())| token)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn client_round_trip_preserves_signed_narrowing() -> Result<()> {
|
|
let requested_scopes = ["files.read".to_string(), "files.write".to_string()];
|
|
let client = http_client();
|
|
use codex_config::McpEmaTokenEndpointAuthMethod::ClientSecretBasic;
|
|
use codex_config::McpEmaTokenEndpointAuthMethod::ClientSecretPost;
|
|
for (scopes, echo_scope, refresh_token, idp_method) in [
|
|
(
|
|
requested_scopes.as_slice(),
|
|
true,
|
|
"opaque-refresh-token",
|
|
None,
|
|
),
|
|
(&[], true, "opaque-refresh-token", Some(ClientSecretBasic)),
|
|
(&[], false, "opaque-refresh-token", Some(ClientSecretPost)),
|
|
(requested_scopes.as_slice(), true, "", None),
|
|
(requested_scopes.as_slice(), true, " \t", None),
|
|
] {
|
|
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 resource_method = idp_method.map(|method| match method {
|
|
ClientSecretBasic => ClientSecretPost,
|
|
ClientSecretPost => ClientSecretBasic,
|
|
});
|
|
let authentication = |method: Option<_>, secret| {
|
|
crate::ema_auth_policy::resolve_ema_client_auth(
|
|
Some(&json!([
|
|
"none",
|
|
"client_secret_basic",
|
|
"client_secret_post"
|
|
])),
|
|
method.map(|_| secret),
|
|
method,
|
|
"test issuer",
|
|
)
|
|
};
|
|
let idp_secret = "idp:+ /%secret";
|
|
let resource_secret = "resource:+ /%secret";
|
|
let result = exchange_id_jag(
|
|
EmaIdJagExchangeRequest {
|
|
idp_client_authentication: authentication(idp_method, idp_secret)?,
|
|
resource_client_authentication: authentication(resource_method, resource_secret)?,
|
|
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),
|
|
},
|
|
(),
|
|
std::convert::identity,
|
|
)
|
|
.boxed()
|
|
.await
|
|
.map(|(token, ())| token);
|
|
if !valid {
|
|
assert!(result.is_err(), "invalid subject must fail before HTTP");
|
|
assert!(
|
|
server
|
|
.received_requests()
|
|
.await
|
|
.expect("requests")
|
|
.is_empty()
|
|
);
|
|
continue;
|
|
}
|
|
let token = result?;
|
|
assert_eq!(
|
|
token,
|
|
EmaAccessToken {
|
|
access_token: "resource-token".to_string(),
|
|
expires_in: Some(Duration::from_secs(300)),
|
|
received_at: token.received_at,
|
|
}
|
|
);
|
|
let requests = server.received_requests().await.expect("requests");
|
|
assert_eq!(requests.len(), 2);
|
|
let mut forms = requests
|
|
.iter()
|
|
.map(|request| unique_form_fields(&request.body))
|
|
.collect::<Vec<_>>();
|
|
for (index, (method, client_id, secret)) in [
|
|
(idp_method, "idp-client", idp_secret),
|
|
(resource_method, "mcp-client", resource_secret),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let authorization = requests[index]
|
|
.headers
|
|
.get("authorization")
|
|
.map(|value| value.to_str().unwrap());
|
|
let encoded = url::form_urlencoded::byte_serialize(secret.as_bytes())
|
|
.collect::<String>()
|
|
.replace("%20", "+");
|
|
let basic = format!(
|
|
"Basic {}",
|
|
base64::engine::general_purpose::STANDARD.encode(format!("{client_id}:{encoded}"))
|
|
);
|
|
assert_eq!(
|
|
authorization,
|
|
(method == Some(ClientSecretBasic)).then_some(basic.as_str())
|
|
);
|
|
assert_eq!(
|
|
forms[index].remove("client_secret").as_deref(),
|
|
(method == Some(ClientSecretPost)).then_some(secret)
|
|
);
|
|
assert_eq!(
|
|
forms[index].remove("client_id").as_deref(),
|
|
(method != Some(ClientSecretBasic)).then_some(client_id)
|
|
);
|
|
}
|
|
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()),
|
|
])
|
|
);
|
|
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()
|
|
),
|
|
])
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[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 server = MockServer::builder().start().await;
|
|
let original = claims(
|
|
&format!("{}/idp", server.uri()),
|
|
&format!("{}/as", server.uri()),
|
|
&format!("{}/mcp", server.uri()),
|
|
);
|
|
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");
|
|
}
|
|
jag = jag_response(&changed);
|
|
}
|
|
"IdP response" => jag["authorization_details"] = details,
|
|
"resource response" => bearer["authorization_details"] = details,
|
|
_ => unreachable!(),
|
|
}
|
|
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;
|
|
}
|
|
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]
|
|
fn identity_and_credential_destinations_are_bound() {
|
|
for endpoint in [
|
|
"http://idp.example/token",
|
|
"https://user:pass@idp.example/token",
|
|
"https://idp.example/token#fragment",
|
|
] {
|
|
assert!(
|
|
validate_ema_oauth_endpoint(endpoint, "IdP").is_err(),
|
|
"accepted {endpoint}"
|
|
);
|
|
}
|
|
let original = claims("https://idp.example", "idp-client", "https://mcp.example");
|
|
assert!(
|
|
validate_oidc_identity_assertion(&jwt(&original), "https://idp.example", "idp-client")
|
|
.is_ok()
|
|
);
|
|
for (field, value) in [
|
|
("iss", json!("https://other.example")),
|
|
("aud", json!(["idp-client", "other"])),
|
|
("azp", json!("other")),
|
|
("exp", json!(0)),
|
|
("sub", json!("")),
|
|
] {
|
|
let mut changed = original.clone();
|
|
changed[field] = value;
|
|
assert!(
|
|
validate_oidc_identity_assertion(&jwt(&changed), "https://idp.example", "idp-client")
|
|
.is_err(),
|
|
"accepted changed {field}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_errors_preserve_credential_policy_without_reflecting_credentials() {
|
|
const SENTINEL: &str = "secret-assertion-sentinel";
|
|
for (endpoint, grant_source) in [
|
|
("/idp/token", EmaInvalidGrantSource::EnterpriseIdentity),
|
|
("/as/token", EmaInvalidGrantSource::ResourceAuthorization),
|
|
] {
|
|
for code in [
|
|
SENTINEL,
|
|
"invalid_grant",
|
|
"insufficient_user_authentication",
|
|
"unauthorized_client",
|
|
"invalid_client",
|
|
"invalid_scope",
|
|
"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 { grant_source })
|
|
}
|
|
"unauthorized_client" | "invalid_client"
|
|
if grant_source == EmaInvalidGrantSource::EnterpriseIdentity =>
|
|
{
|
|
Some(EmaAuthFailure::IdentityClientRejected)
|
|
}
|
|
_ => None,
|
|
};
|
|
assert_eq!(error.downcast_ref::<EmaAuthFailure>(), expected.as_ref());
|
|
assert!(
|
|
!format!("{error:#?}").contains(SENTINEL),
|
|
"provider reflected a credential"
|
|
);
|
|
// Even an unsafe outer context must not reach the transport diagnostic.
|
|
let detail = EmaErrorDetail::from_error(&error.context(SENTINEL));
|
|
let transport_error = EmaTokenSourceError::Unexpected {
|
|
credential: EmaCredentialKind::Keyring,
|
|
detail,
|
|
};
|
|
assert!(!format!("{transport_error:#?} {transport_error}").contains(SENTINEL));
|
|
if code == "invalid_scope" {
|
|
let stage = if endpoint == "/idp/token" {
|
|
"IdentityProvider"
|
|
} else {
|
|
"ResourceAuthorizationServer"
|
|
};
|
|
assert_eq!(
|
|
transport_error.to_string(),
|
|
format!("{stage}: HTTP 400: invalid_scope")
|
|
);
|
|
}
|
|
assert_eq!(
|
|
server.received_requests().await.expect("requests").len(),
|
|
if endpoint == "/as/token" { 2 } else { 1 },
|
|
"exchange must not retry a rejected grant"
|
|
);
|
|
}
|
|
}
|
|
}
|