fix: use issuer for JWT client audience (#1031)
This commit is contained in:
parent
e903341136
commit
190ea44218
7 changed files with 163 additions and 119 deletions
|
|
@ -30,7 +30,7 @@ runs them in separate server and client steps with
|
||||||
`conformance/expected-failures-extensions.yaml`.
|
`conformance/expected-failures-extensions.yaml`.
|
||||||
|
|
||||||
- SEP-2663 Tasks server: 9 expected failures; `tasks-status-notifications` is currently skipped by the upstream harness; tracked in #868
|
- SEP-2663 Tasks server: 9 expected failures; `tasks-status-notifications` is currently skipped by the upstream harness; tracked in #868
|
||||||
- Client extensions: `auth/client-credentials-basic` passes; `auth/client-credentials-jwt` and `auth/enterprise-managed-authorization` are expected failures
|
- Client extensions: `auth/client-credentials-basic` and `auth/client-credentials-jwt` pass; `auth/enterprise-managed-authorization` is an expected failure
|
||||||
|
|
||||||
### Spec features without conformance scenarios
|
### Spec features without conformance scenarios
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ These extension scenarios are tracked but do not count toward tier advancement:
|
||||||
|
|
||||||
| Scenario | Tag | Status |
|
| Scenario | Tag | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `auth/client-credentials-jwt` | extension | ❌ Failed — JWT `aud` claim verification error |
|
| `auth/client-credentials-jwt` | extension | ✅ Passed |
|
||||||
| `auth/client-credentials-basic` | extension | ✅ Passed |
|
| `auth/client-credentials-basic` | extension | ✅ Passed |
|
||||||
| `auth/enterprise-managed-authorization` | extension | ❌ Failed — scenario is not implemented by the conformance client |
|
| `auth/enterprise-managed-authorization` | extension | ❌ Failed — scenario is not implemented by the conformance client |
|
||||||
| `tasks-*` | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped scenario |
|
| `tasks-*` | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped scenario |
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [
|
||||||
"client",
|
"client",
|
||||||
"elicitation",
|
"elicitation",
|
||||||
"auth",
|
"auth",
|
||||||
|
"auth-client-credentials-jwt",
|
||||||
"request-state",
|
"request-state",
|
||||||
"transport-streamable-http-server",
|
"transport-streamable-http-server",
|
||||||
"transport-streamable-http-client-reqwest",
|
"transport-streamable-http-client-reqwest",
|
||||||
|
|
@ -33,5 +34,3 @@ anyhow = "1"
|
||||||
reqwest = { version = "0.13", features = ["json"] }
|
reqwest = { version = "0.13", features = ["json"] }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
p256 = { version = "0.14", features = ["ecdsa"] }
|
|
||||||
base64 = "0.22"
|
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,4 @@ server: []
|
||||||
|
|
||||||
client:
|
client:
|
||||||
# Informational OAuth extension scenarios.
|
# Informational OAuth extension scenarios.
|
||||||
- auth/client-credentials-jwt
|
|
||||||
- auth/enterprise-managed-authorization
|
- auth/enterprise-managed-authorization
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ use rmcp::{
|
||||||
service::RequestContext,
|
service::RequestContext,
|
||||||
transport::{
|
transport::{
|
||||||
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
|
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
|
||||||
auth::{AuthorizationCallback, AuthorizationRequest, InMemoryCredentialStore, OAuthState},
|
auth::{
|
||||||
|
AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig,
|
||||||
|
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState,
|
||||||
|
},
|
||||||
streamable_http_client::StreamableHttpClientTransportConfig,
|
streamable_http_client::StreamableHttpClientTransportConfig,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -651,49 +654,43 @@ async fn run_client_credentials_jwt(
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let client_id = ctx
|
let client_id = ctx
|
||||||
.client_id
|
.client_id
|
||||||
.as_deref()
|
.clone()
|
||||||
.unwrap_or("conformance-test-client");
|
.unwrap_or_else(|| "conformance-test-client".to_string());
|
||||||
let _pem = ctx
|
let signing_key = ctx
|
||||||
.private_key_pem
|
.private_key_pem
|
||||||
.as_deref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?
|
||||||
let _alg = ctx
|
.as_bytes()
|
||||||
|
.to_vec();
|
||||||
|
let signing_algorithm = match ctx
|
||||||
.signing_algorithm
|
.signing_algorithm
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?
|
||||||
|
{
|
||||||
// Discover metadata to get token endpoint
|
"RS256" => JwtSigningAlgorithm::RS256,
|
||||||
let mut manager = AuthorizationManager::new(server_url).await?;
|
"RS384" => JwtSigningAlgorithm::RS384,
|
||||||
let metadata = manager.discover_metadata().await?;
|
"RS512" => JwtSigningAlgorithm::RS512,
|
||||||
let token_endpoint = metadata.token_endpoint.clone();
|
"ES256" => JwtSigningAlgorithm::ES256,
|
||||||
manager.set_metadata(metadata);
|
"ES384" => JwtSigningAlgorithm::ES384,
|
||||||
|
algorithm => anyhow::bail!("Unsupported signing_algorithm: {algorithm}"),
|
||||||
// Build JWT assertion
|
};
|
||||||
// Parse the PEM private key
|
let config = ClientCredentialsConfig::PrivateKeyJwt {
|
||||||
let key = openssl_free_ec_sign(_pem, client_id, &token_endpoint)?;
|
client_id,
|
||||||
|
signing_key,
|
||||||
let http = reqwest::Client::new();
|
signing_algorithm,
|
||||||
let form_body = format!(
|
token_endpoint_audience: None,
|
||||||
"grant_type=client_credentials&client_assertion_type={}&client_assertion={}",
|
scopes: vec![],
|
||||||
urlencoding::encode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
|
resource: Some(server_url.to_string()),
|
||||||
urlencoding::encode(&key),
|
};
|
||||||
);
|
let mut oauth_state = OAuthState::new(server_url, None).await?;
|
||||||
let resp = http
|
oauth_state.authenticate_client_credentials(config).await?;
|
||||||
.post(&token_endpoint)
|
let manager = oauth_state
|
||||||
.header("content-type", "application/x-www-form-urlencoded")
|
.into_authorization_manager()
|
||||||
.body(form_body)
|
.ok_or_else(|| anyhow::anyhow!("Client credentials flow did not authorize"))?;
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let token_resp: serde_json::Value = resp.json().await?;
|
|
||||||
let access_token = token_resp["access_token"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("No access_token: {}", token_resp))?;
|
|
||||||
|
|
||||||
let transport = StreamableHttpClientTransport::with_client(
|
let transport = StreamableHttpClientTransport::with_client(
|
||||||
reqwest::Client::default(),
|
AuthClient::new(reqwest::Client::default(), manager),
|
||||||
StreamableHttpClientTransportConfig::with_uri(server_url)
|
StreamableHttpClientTransportConfig::with_uri(server_url),
|
||||||
.auth_header(access_token.to_string()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let client = BasicClientHandler.serve(transport).await?;
|
let client = BasicClientHandler.serve(transport).await?;
|
||||||
|
|
@ -709,73 +706,6 @@ async fn run_client_credentials_jwt(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal ES256 JWT signing without heavy deps.
|
|
||||||
/// We use ring or pure-Rust approach. For simplicity, use the p256 + base64 crates
|
|
||||||
/// that are already transitive deps of oauth2.
|
|
||||||
fn openssl_free_ec_sign(pem: &str, client_id: &str, audience: &str) -> anyhow::Result<String> {
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
// Decode PEM → DER
|
|
||||||
let pem_body = pem
|
|
||||||
.lines()
|
|
||||||
.filter(|l| !l.starts_with("-----"))
|
|
||||||
.collect::<String>();
|
|
||||||
let der = base64_decode(&pem_body)?;
|
|
||||||
|
|
||||||
// Parse PKCS#8 DER to get the raw EC private key bytes
|
|
||||||
// PKCS#8 for EC P-256: the raw 32-byte key is at the end of the structure
|
|
||||||
let raw_key = extract_ec_private_key(&der)?;
|
|
||||||
|
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
|
||||||
let header = base64url_encode(br#"{"alg":"ES256","typ":"JWT"}"#);
|
|
||||||
let payload_json = serde_json::json!({
|
|
||||||
"iss": client_id,
|
|
||||||
"sub": client_id,
|
|
||||||
"aud": audience,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + 300,
|
|
||||||
"jti": format!("jti-{}", now),
|
|
||||||
});
|
|
||||||
let payload = base64url_encode(payload_json.to_string().as_bytes());
|
|
||||||
let signing_input = format!("{}.{}", header, payload);
|
|
||||||
|
|
||||||
// Sign with p256
|
|
||||||
let secret_key = p256::ecdsa::SigningKey::from_slice(raw_key.as_slice())
|
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid EC key: {}", e))?;
|
|
||||||
use p256::ecdsa::signature::Signer;
|
|
||||||
let sig: p256::ecdsa::Signature = secret_key.sign(signing_input.as_bytes());
|
|
||||||
let sig_bytes = sig.to_bytes();
|
|
||||||
let sig_b64 = base64url_encode(&sig_bytes);
|
|
||||||
|
|
||||||
Ok(format!("{}.{}", signing_input, sig_b64))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base64url_encode(data: &[u8]) -> String {
|
|
||||||
use base64::Engine;
|
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base64_decode(s: &str) -> anyhow::Result<Vec<u8>> {
|
|
||||||
use base64::Engine;
|
|
||||||
Ok(base64::engine::general_purpose::STANDARD.decode(s.trim())?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the raw 32-byte EC private key from a PKCS#8 DER blob.
|
|
||||||
fn extract_ec_private_key(der: &[u8]) -> anyhow::Result<Vec<u8>> {
|
|
||||||
// PKCS#8 wraps an ECPrivateKey. We look for the octet string containing
|
|
||||||
// the 32-byte private key. A simple heuristic: find 0x04 0x20 (OCTET STRING, len 32)
|
|
||||||
// followed by exactly 32 bytes that form the key.
|
|
||||||
// More robust: parse ASN.1. But for conformance testing this suffices.
|
|
||||||
for i in 0..der.len().saturating_sub(33) {
|
|
||||||
if der[i] == 0x04 && der[i + 1] == 0x20 && i + 34 <= der.len() {
|
|
||||||
return Ok(der[i + 2..i + 34].to_vec());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"Could not extract 32-byte EC private key from PKCS#8 DER"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cross-app access flow (SEP-1046 extension).
|
/// Cross-app access flow (SEP-1046 extension).
|
||||||
async fn run_cross_app_access_client(
|
async fn run_cross_app_access_client(
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
- **BREAKING**: rename `StreamableHttpServerConfig::stateful_mode` to `legacy_session_mode` (and the builder `with_stateful_mode` to `with_legacy_session_mode`) to clarify that the option only affects legacy protocol versions (`< 2026-07-28`); per SEP-2567 the `2026-07-28` draft version is always served statelessly ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999))
|
- **BREAKING**: rename `StreamableHttpServerConfig::stateful_mode` to `legacy_session_mode` (and the builder `with_stateful_mode` to `with_legacy_session_mode`) to clarify that the option only affects legacy protocol versions (`< 2026-07-28`); per SEP-2567 the `2026-07-28` draft version is always served statelessly ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- use the authorization server issuer as the default `private_key_jwt` audience
|
||||||
|
|
||||||
## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08
|
## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ pastey = { version = "0.2.0", optional = true }
|
||||||
# oauth2 support
|
# oauth2 support
|
||||||
oauth2 = { version = "5.0", optional = true, default-features = false }
|
oauth2 = { version = "5.0", optional = true, default-features = false }
|
||||||
# JWT signing for client credentials (private_key_jwt)
|
# JWT signing for client credentials (private_key_jwt)
|
||||||
jsonwebtoken = { version = "10", optional = true }
|
jsonwebtoken = { version = "10", optional = true, features = ["aws_lc_rs"] }
|
||||||
|
|
||||||
# for auto generate schema
|
# for auto generate schema
|
||||||
schemars = { version = "1.0", optional = true, features = ["chrono04"] }
|
schemars = { version = "1.0", optional = true, features = ["chrono04"] }
|
||||||
|
|
|
||||||
|
|
@ -862,13 +862,35 @@ pub enum ClientCredentialsConfig {
|
||||||
client_id: String,
|
client_id: String,
|
||||||
signing_key: Vec<u8>,
|
signing_key: Vec<u8>,
|
||||||
signing_algorithm: JwtSigningAlgorithm,
|
signing_algorithm: JwtSigningAlgorithm,
|
||||||
/// Override the `aud` claim in the JWT assertion; defaults to token_endpoint
|
/// Overrides the authorization server issuer used for the JWT `aud` claim.
|
||||||
token_endpoint_audience: Option<String>,
|
token_endpoint_audience: Option<String>,
|
||||||
scopes: Vec<String>,
|
scopes: Vec<String>,
|
||||||
resource: Option<String>,
|
resource: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
fn client_authentication_audience<'a>(
|
||||||
|
metadata: &'a AuthorizationMetadata,
|
||||||
|
configured_audience: Option<&'a str>,
|
||||||
|
) -> Result<&'a str, AuthError> {
|
||||||
|
configured_audience
|
||||||
|
.or(metadata.issuer.as_deref())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AuthError::ClientCredentialsError(
|
||||||
|
"Authorization server metadata is missing the issuer required for private_key_jwt"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
fn client_authentication_header(algorithm: JwtSigningAlgorithm) -> jsonwebtoken::Header {
|
||||||
|
let mut header = jsonwebtoken::Header::new(algorithm.to_jsonwebtoken_algorithm());
|
||||||
|
header.typ = Some("client-authentication+jwt".to_string());
|
||||||
|
header
|
||||||
|
}
|
||||||
|
|
||||||
impl ClientCredentialsConfig {
|
impl ClientCredentialsConfig {
|
||||||
fn client_id(&self) -> &str {
|
fn client_id(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
|
|
@ -989,6 +1011,18 @@ fn is_https_url(value: &str) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
fn is_allowed_client_credentials_endpoint(resource: &Url, token_endpoint: &Url) -> bool {
|
||||||
|
token_endpoint.scheme() == "https"
|
||||||
|
|| (token_endpoint.scheme() == "http"
|
||||||
|
&& resource
|
||||||
|
.host_str()
|
||||||
|
.is_some_and(AuthorizationManager::is_loopback_metadata_host)
|
||||||
|
&& token_endpoint
|
||||||
|
.host_str()
|
||||||
|
.is_some_and(AuthorizationManager::is_loopback_metadata_host))
|
||||||
|
}
|
||||||
|
|
||||||
impl AuthorizationManager {
|
impl AuthorizationManager {
|
||||||
fn is_http_url(url: &Url) -> bool {
|
fn is_http_url(url: &Url) -> bool {
|
||||||
matches!(url.scheme(), "http" | "https") && url.host_str().is_some()
|
matches!(url.scheme(), "http" | "https") && url.host_str().is_some()
|
||||||
|
|
@ -2872,22 +2906,20 @@ impl AuthorizationManager {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(AuthError::NoAuthorizationSupport)?;
|
.ok_or(AuthError::NoAuthorizationSupport)?;
|
||||||
|
|
||||||
// Validate that the token endpoint uses HTTPS before transmitting sensitive credentials.
|
|
||||||
let token_endpoint_url = url::Url::parse(&metadata.token_endpoint).map_err(|e| {
|
let token_endpoint_url = url::Url::parse(&metadata.token_endpoint).map_err(|e| {
|
||||||
AuthError::ClientCredentialsError(format!(
|
AuthError::ClientCredentialsError(format!(
|
||||||
"Invalid token endpoint URL in authorization metadata: {e}"
|
"Invalid token endpoint URL in authorization metadata: {e}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
if token_endpoint_url.scheme() != "https" {
|
if !is_allowed_client_credentials_endpoint(&self.base_url, &token_endpoint_url) {
|
||||||
return Err(AuthError::ClientCredentialsError(
|
return Err(AuthError::ClientCredentialsError(
|
||||||
"Insecure token endpoint URL: HTTPS is required for client credentials flow"
|
"Insecure token endpoint URL: HTTPS is required for client credentials flow"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let audience = token_endpoint_audience
|
let audience =
|
||||||
.as_deref()
|
client_authentication_audience(metadata, token_endpoint_audience.as_deref())?;
|
||||||
.unwrap_or(&metadata.token_endpoint);
|
|
||||||
|
|
||||||
let assertion =
|
let assertion =
|
||||||
Self::build_jwt_assertion(client_id, audience, signing_key, *signing_algorithm)?;
|
Self::build_jwt_assertion(client_id, audience, signing_key, *signing_algorithm)?;
|
||||||
|
|
@ -2993,7 +3025,7 @@ impl AuthorizationManager {
|
||||||
"jti": jti,
|
"jti": jti,
|
||||||
});
|
});
|
||||||
|
|
||||||
let header = jsonwebtoken::Header::new(algorithm.to_jsonwebtoken_algorithm());
|
let header = client_authentication_header(algorithm);
|
||||||
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(signing_key).or_else(|_| {
|
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(signing_key).or_else(|_| {
|
||||||
jsonwebtoken::EncodingKey::from_ec_pem(signing_key).map_err(|e| {
|
jsonwebtoken::EncodingKey::from_ec_pem(signing_key).map_err(|e| {
|
||||||
AuthError::JwtSigningError(format!("Failed to parse signing key: {}", e))
|
AuthError::JwtSigningError(format!("Failed to parse signing key: {}", e))
|
||||||
|
|
@ -6476,6 +6508,86 @@ mod tests {
|
||||||
|
|
||||||
// -- client credentials (SEP-1046) --
|
// -- client credentials (SEP-1046) --
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
#[test]
|
||||||
|
fn client_authentication_audience_defaults_to_metadata_issuer() {
|
||||||
|
let metadata = AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "https://auth.example.com/authorize".to_string(),
|
||||||
|
token_endpoint: "https://auth.example.com/token".to_string(),
|
||||||
|
issuer: Some("https://auth.example.com".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let audience = super::client_authentication_audience(&metadata, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(audience, "https://auth.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
#[test]
|
||||||
|
fn client_authentication_audience_honors_explicit_override() {
|
||||||
|
let metadata = AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "https://auth.example.com/authorize".to_string(),
|
||||||
|
token_endpoint: "https://auth.example.com/token".to_string(),
|
||||||
|
issuer: Some("https://auth.example.com".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let audience = super::client_authentication_audience(
|
||||||
|
&metadata,
|
||||||
|
Some("https://legacy.example.com/token"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(audience, "https://legacy.example.com/token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
#[test]
|
||||||
|
fn client_authentication_audience_rejects_missing_metadata_issuer() {
|
||||||
|
let metadata = AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "https://auth.example.com/authorize".to_string(),
|
||||||
|
token_endpoint: "https://auth.example.com/token".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = super::client_authentication_audience(&metadata, None).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, AuthError::ClientCredentialsError(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
#[test]
|
||||||
|
fn client_authentication_header_sets_explicit_type() {
|
||||||
|
let header = super::client_authentication_header(super::JwtSigningAlgorithm::ES256);
|
||||||
|
|
||||||
|
assert_eq!(header.typ.as_deref(), Some("client-authentication+jwt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
#[test]
|
||||||
|
fn client_credentials_endpoint_allows_http_between_loopback_hosts() {
|
||||||
|
let resource = Url::parse("http://localhost:8000/mcp").unwrap();
|
||||||
|
let token_endpoint = Url::parse("http://127.0.0.1:9000/token").unwrap();
|
||||||
|
|
||||||
|
assert!(super::is_allowed_client_credentials_endpoint(
|
||||||
|
&resource,
|
||||||
|
&token_endpoint
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||||
|
#[test]
|
||||||
|
fn client_credentials_endpoint_rejects_http_to_non_loopback_host() {
|
||||||
|
let resource = Url::parse("http://localhost:8000/mcp").unwrap();
|
||||||
|
let token_endpoint = Url::parse("http://auth.example.com/token").unwrap();
|
||||||
|
|
||||||
|
assert!(!super::is_allowed_client_credentials_endpoint(
|
||||||
|
&resource,
|
||||||
|
&token_endpoint
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn configure_client_credentials_uses_request_body_auth_for_client_secret() {
|
async fn configure_client_credentials_uses_request_body_auth_for_client_secret() {
|
||||||
let mut mgr = manager_with_metadata(None).await;
|
let mut mgr = manager_with_metadata(None).await;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue