Skip to content

Commit 190ea44

Browse files
authored
fix: use issuer for JWT client audience (#1031)
1 parent e903341 commit 190ea44

7 files changed

Lines changed: 163 additions & 119 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ runs them in separate server and client steps with
3030
`conformance/expected-failures-extensions.yaml`.
3131

3232
- SEP-2663 Tasks server: 9 expected failures; `tasks-status-notifications` is currently skipped by the upstream harness; tracked in #868
33-
- Client extensions: `auth/client-credentials-basic` passes; `auth/client-credentials-jwt` and `auth/enterprise-managed-authorization` are expected failures
33+
- Client extensions: `auth/client-credentials-basic` and `auth/client-credentials-jwt` pass; `auth/enterprise-managed-authorization` is an expected failure
3434

3535
### Spec features without conformance scenarios
3636

@@ -110,7 +110,7 @@ These extension scenarios are tracked but do not count toward tier advancement:
110110

111111
| Scenario | Tag | Status |
112112
|---|---|---|
113-
| `auth/client-credentials-jwt` | extension | ❌ Failed — JWT `aud` claim verification error |
113+
| `auth/client-credentials-jwt` | extension | ✅ Passed |
114114
| `auth/client-credentials-basic` | extension | ✅ Passed |
115115
| `auth/enterprise-managed-authorization` | extension | ❌ Failed — scenario is not implemented by the conformance client |
116116
| `tasks-*` | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped scenario |

conformance/Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [
1818
"client",
1919
"elicitation",
2020
"auth",
21+
"auth-client-credentials-jwt",
2122
"request-state",
2223
"transport-streamable-http-server",
2324
"transport-streamable-http-client-reqwest",
@@ -33,5 +34,3 @@ anyhow = "1"
3334
reqwest = { version = "0.13", features = ["json"] }
3435
urlencoding = "2"
3536
url = "2"
36-
p256 = { version = "0.14", features = ["ecdsa"] }
37-
base64 = "0.22"

conformance/expected-failures-extensions.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,4 @@ server: []
2020

2121
client:
2222
# Informational OAuth extension scenarios.
23-
- auth/client-credentials-jwt
2423
- auth/enterprise-managed-authorization

conformance/src/bin/client.rs

Lines changed: 36 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ use rmcp::{
44
service::RequestContext,
55
transport::{
66
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
7-
auth::{AuthorizationCallback, AuthorizationRequest, InMemoryCredentialStore, OAuthState},
7+
auth::{
8+
AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig,
9+
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState,
10+
},
811
streamable_http_client::StreamableHttpClientTransportConfig,
912
},
1013
};
@@ -651,49 +654,43 @@ async fn run_client_credentials_jwt(
651654
) -> anyhow::Result<()> {
652655
let client_id = ctx
653656
.client_id
654-
.as_deref()
655-
.unwrap_or("conformance-test-client");
656-
let _pem = ctx
657+
.clone()
658+
.unwrap_or_else(|| "conformance-test-client".to_string());
659+
let signing_key = ctx
657660
.private_key_pem
658-
.as_deref()
659-
.ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?;
660-
let _alg = ctx
661+
.as_ref()
662+
.ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?
663+
.as_bytes()
664+
.to_vec();
665+
let signing_algorithm = match ctx
661666
.signing_algorithm
662667
.as_deref()
663-
.ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?;
664-
665-
// Discover metadata to get token endpoint
666-
let mut manager = AuthorizationManager::new(server_url).await?;
667-
let metadata = manager.discover_metadata().await?;
668-
let token_endpoint = metadata.token_endpoint.clone();
669-
manager.set_metadata(metadata);
670-
671-
// Build JWT assertion
672-
// Parse the PEM private key
673-
let key = openssl_free_ec_sign(_pem, client_id, &token_endpoint)?;
674-
675-
let http = reqwest::Client::new();
676-
let form_body = format!(
677-
"grant_type=client_credentials&client_assertion_type={}&client_assertion={}",
678-
urlencoding::encode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
679-
urlencoding::encode(&key),
680-
);
681-
let resp = http
682-
.post(&token_endpoint)
683-
.header("content-type", "application/x-www-form-urlencoded")
684-
.body(form_body)
685-
.send()
686-
.await?;
687-
688-
let token_resp: serde_json::Value = resp.json().await?;
689-
let access_token = token_resp["access_token"]
690-
.as_str()
691-
.ok_or_else(|| anyhow::anyhow!("No access_token: {}", token_resp))?;
668+
.ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?
669+
{
670+
"RS256" => JwtSigningAlgorithm::RS256,
671+
"RS384" => JwtSigningAlgorithm::RS384,
672+
"RS512" => JwtSigningAlgorithm::RS512,
673+
"ES256" => JwtSigningAlgorithm::ES256,
674+
"ES384" => JwtSigningAlgorithm::ES384,
675+
algorithm => anyhow::bail!("Unsupported signing_algorithm: {algorithm}"),
676+
};
677+
let config = ClientCredentialsConfig::PrivateKeyJwt {
678+
client_id,
679+
signing_key,
680+
signing_algorithm,
681+
token_endpoint_audience: None,
682+
scopes: vec![],
683+
resource: Some(server_url.to_string()),
684+
};
685+
let mut oauth_state = OAuthState::new(server_url, None).await?;
686+
oauth_state.authenticate_client_credentials(config).await?;
687+
let manager = oauth_state
688+
.into_authorization_manager()
689+
.ok_or_else(|| anyhow::anyhow!("Client credentials flow did not authorize"))?;
692690

693691
let transport = StreamableHttpClientTransport::with_client(
694-
reqwest::Client::default(),
695-
StreamableHttpClientTransportConfig::with_uri(server_url)
696-
.auth_header(access_token.to_string()),
692+
AuthClient::new(reqwest::Client::default(), manager),
693+
StreamableHttpClientTransportConfig::with_uri(server_url),
697694
);
698695

699696
let client = BasicClientHandler.serve(transport).await?;
@@ -709,73 +706,6 @@ async fn run_client_credentials_jwt(
709706
Ok(())
710707
}
711708

712-
/// Minimal ES256 JWT signing without heavy deps.
713-
/// We use ring or pure-Rust approach. For simplicity, use the p256 + base64 crates
714-
/// that are already transitive deps of oauth2.
715-
fn openssl_free_ec_sign(pem: &str, client_id: &str, audience: &str) -> anyhow::Result<String> {
716-
use std::time::{SystemTime, UNIX_EPOCH};
717-
718-
// Decode PEM → DER
719-
let pem_body = pem
720-
.lines()
721-
.filter(|l| !l.starts_with("-----"))
722-
.collect::<String>();
723-
let der = base64_decode(&pem_body)?;
724-
725-
// Parse PKCS#8 DER to get the raw EC private key bytes
726-
// PKCS#8 for EC P-256: the raw 32-byte key is at the end of the structure
727-
let raw_key = extract_ec_private_key(&der)?;
728-
729-
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
730-
let header = base64url_encode(br#"{"alg":"ES256","typ":"JWT"}"#);
731-
let payload_json = serde_json::json!({
732-
"iss": client_id,
733-
"sub": client_id,
734-
"aud": audience,
735-
"iat": now,
736-
"exp": now + 300,
737-
"jti": format!("jti-{}", now),
738-
});
739-
let payload = base64url_encode(payload_json.to_string().as_bytes());
740-
let signing_input = format!("{}.{}", header, payload);
741-
742-
// Sign with p256
743-
let secret_key = p256::ecdsa::SigningKey::from_slice(raw_key.as_slice())
744-
.map_err(|e| anyhow::anyhow!("Invalid EC key: {}", e))?;
745-
use p256::ecdsa::signature::Signer;
746-
let sig: p256::ecdsa::Signature = secret_key.sign(signing_input.as_bytes());
747-
let sig_bytes = sig.to_bytes();
748-
let sig_b64 = base64url_encode(&sig_bytes);
749-
750-
Ok(format!("{}.{}", signing_input, sig_b64))
751-
}
752-
753-
fn base64url_encode(data: &[u8]) -> String {
754-
use base64::Engine;
755-
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
756-
}
757-
758-
fn base64_decode(s: &str) -> anyhow::Result<Vec<u8>> {
759-
use base64::Engine;
760-
Ok(base64::engine::general_purpose::STANDARD.decode(s.trim())?)
761-
}
762-
763-
/// Extract the raw 32-byte EC private key from a PKCS#8 DER blob.
764-
fn extract_ec_private_key(der: &[u8]) -> anyhow::Result<Vec<u8>> {
765-
// PKCS#8 wraps an ECPrivateKey. We look for the octet string containing
766-
// the 32-byte private key. A simple heuristic: find 0x04 0x20 (OCTET STRING, len 32)
767-
// followed by exactly 32 bytes that form the key.
768-
// More robust: parse ASN.1. But for conformance testing this suffices.
769-
for i in 0..der.len().saturating_sub(33) {
770-
if der[i] == 0x04 && der[i + 1] == 0x20 && i + 34 <= der.len() {
771-
return Ok(der[i + 2..i + 34].to_vec());
772-
}
773-
}
774-
Err(anyhow::anyhow!(
775-
"Could not extract 32-byte EC private key from PKCS#8 DER"
776-
))
777-
}
778-
779709
/// Cross-app access flow (SEP-1046 extension).
780710
async fn run_cross_app_access_client(
781711
server_url: &str,

crates/rmcp/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **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))
1313

14+
### Fixed
15+
16+
- use the authorization server issuer as the default `private_key_jwt` audience
17+
1418
## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08
1519

1620
### Added

crates/rmcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pastey = { version = "0.2.0", optional = true }
5757
# oauth2 support
5858
oauth2 = { version = "5.0", optional = true, default-features = false }
5959
# JWT signing for client credentials (private_key_jwt)
60-
jsonwebtoken = { version = "10", optional = true }
60+
jsonwebtoken = { version = "10", optional = true, features = ["aws_lc_rs"] }
6161

6262
# for auto generate schema
6363
schemars = { version = "1.0", optional = true, features = ["chrono04"] }

0 commit comments

Comments
 (0)