Skip to content

Commit e534aeb

Browse files
noahgiftclaude
andcommitted
feat: add OIDC discovery and transport isolation (v0.6.5)
- Add full OpenID Connect discovery support with retry logic - Implement transport response isolation for concurrent connections - Add comprehensive auth client module with token exchange - Include property tests for transport isolation invariants - Add example 20 demonstrating OIDC discovery and OAuth flows - Align with TypeScript SDK v1.17.1 features 🤖 Generated with Claude Code Co-Authored-By: Claude <[email protected]>
1 parent 1f27aca commit e534aeb

File tree

12 files changed

+1398
-17
lines changed

12 files changed

+1398
-17
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.6.5] - 2025-08-06
11+
12+
### Added
13+
- **OIDC Discovery Support** - Full OpenID Connect configuration discovery
14+
- `OidcDiscoveryMetadata` struct for OAuth 2.0/OIDC server metadata
15+
- `OidcDiscoveryClient` with automatic retry on CORS/network errors
16+
- Token exchange client with explicit JSON accept headers
17+
- Comprehensive `client::auth` module with helpers for OAuth flows
18+
- New example: `20_oidc_discovery` demonstrating OIDC discovery and token exchange
19+
20+
- **Transport Response Isolation** - Enhanced safety for concurrent transports
21+
- `TransportId` type for unique transport identification
22+
- Protocol-level request-response correlation per transport
23+
- `complete_request_for_transport` method for transport-specific completion
24+
- Prevents responses being routed to wrong transport instances
25+
- Property tests ensuring transport isolation invariants
26+
27+
- **Enhanced Testing**
28+
- 5 new property tests for transport isolation
29+
- 10+ unit tests for OIDC discovery and auth
30+
- Integration tests for concurrent transport operations
31+
- 135+ doctests with comprehensive examples
32+
33+
### Changed
34+
- Updated to align with TypeScript SDK v1.17.1 features
35+
- Added `reqwest` as a required dependency for HTTP client functionality
36+
- Enhanced error handling with proper retry logic for auth operations
37+
38+
### Fixed
39+
- Token exchange now explicitly sets `Accept: application/json` header
40+
- Improved error messages for authentication failures
41+
- Fixed potential race conditions in multi-transport scenarios
42+
1043
## [0.6.4] - 2025-08-01
1144

1245
### Added

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ tokio-util = { version = "0.7", features = ["rt"] }
4747
# OAuth dependencies
4848
sha2 = "0.10"
4949
base64 = "0.22"
50-
reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true }
50+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = false }
5151

5252
# File watching dependencies
5353
notify = { version = "6.1", optional = true }

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Add to your `Cargo.toml`:
3636

3737
```toml
3838
[dependencies]
39-
pmcp = "0.3.1"
39+
pmcp = "0.6.5"
4040
```
4141

4242
## Examples
@@ -95,6 +95,24 @@ cargo run --example 16_batching
9595

9696
See the [examples directory](examples/) for detailed documentation.
9797

98+
## What's New in v0.6.5
99+
100+
### 🔐 OIDC Discovery Support
101+
- Full OpenID Connect discovery implementation
102+
- Automatic retry on CORS/network errors
103+
- Token exchange with explicit JSON accept headers
104+
- Comprehensive auth client module
105+
106+
### 🔒 Transport Response Isolation
107+
- Unique transport IDs prevent cross-transport response routing
108+
- Enhanced protocol safety for multiple concurrent connections
109+
- Request-response correlation per transport instance
110+
111+
### 📚 Enhanced Documentation
112+
- 135+ doctests with real-world examples
113+
- Complete property test coverage
114+
- New OIDC discovery example (example 20)
115+
98116
## What's New in v0.2.0
99117

100118
### 🆕 WebSocket Transport with Auto-Reconnection

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.6.4
1+
0.6.5

examples/20_oidc_discovery.rs

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
//! Example demonstrating OIDC discovery and OAuth 2.0 token exchange.
2+
//!
3+
//! This example shows how to:
4+
//! 1. Discover OIDC configuration from a provider
5+
//! 2. Exchange authorization codes for tokens
6+
//! 3. Refresh access tokens
7+
//! 4. Handle CORS and network errors with retries
8+
9+
use pmcp::client::auth::{OidcDiscoveryClient, TokenResponse};
10+
use pmcp::server::auth::oauth2::{
11+
InMemoryOAuthProvider, OAuthClient, OAuthProvider, OidcDiscoveryMetadata,
12+
};
13+
use pmcp::Result;
14+
use std::time::Duration;
15+
use tokio::time::sleep;
16+
17+
/// Mock OIDC server for testing.
18+
struct MockOidcServer {
19+
metadata: OidcDiscoveryMetadata,
20+
}
21+
22+
impl MockOidcServer {
23+
fn new() -> Self {
24+
Self {
25+
metadata: OidcDiscoveryMetadata {
26+
issuer: "https://auth.example.com".to_string(),
27+
authorization_endpoint: "https://auth.example.com/authorize".to_string(),
28+
token_endpoint: "https://auth.example.com/token".to_string(),
29+
jwks_uri: Some("https://auth.example.com/jwks".to_string()),
30+
userinfo_endpoint: Some("https://auth.example.com/userinfo".to_string()),
31+
registration_endpoint: Some("https://auth.example.com/register".to_string()),
32+
revocation_endpoint: Some("https://auth.example.com/revoke".to_string()),
33+
introspection_endpoint: Some("https://auth.example.com/introspect".to_string()),
34+
response_types_supported: vec![pmcp::server::auth::oauth2::ResponseType::Code],
35+
grant_types_supported: vec![
36+
pmcp::server::auth::oauth2::GrantType::AuthorizationCode,
37+
pmcp::server::auth::oauth2::GrantType::RefreshToken,
38+
],
39+
scopes_supported: vec![
40+
"openid".to_string(),
41+
"profile".to_string(),
42+
"email".to_string(),
43+
],
44+
token_endpoint_auth_methods_supported: vec![
45+
"client_secret_basic".to_string(),
46+
"client_secret_post".to_string(),
47+
],
48+
code_challenge_methods_supported: vec!["plain".to_string(), "S256".to_string()],
49+
},
50+
}
51+
}
52+
}
53+
54+
/// Simulate OIDC discovery with retry logic.
55+
async fn discover_with_retries(issuer_url: &str) -> Result<OidcDiscoveryMetadata> {
56+
println!("🔍 Discovering OIDC configuration for: {}", issuer_url);
57+
58+
// Create discovery client with custom retry settings
59+
let _client = OidcDiscoveryClient::with_settings(
60+
5, // max retries
61+
Duration::from_millis(500), // retry delay
62+
);
63+
64+
// Simulate network issues for demonstration
65+
let mut attempt = 0;
66+
loop {
67+
attempt += 1;
68+
println!(" Attempt {}/5...", attempt);
69+
70+
// Simulate occasional CORS/network errors
71+
if attempt < 3 {
72+
println!(" ❌ Simulated CORS error");
73+
sleep(Duration::from_millis(500)).await;
74+
continue;
75+
}
76+
77+
// Return mock metadata on success
78+
println!(" ✅ Discovery successful!");
79+
return Ok(MockOidcServer::new().metadata);
80+
}
81+
}
82+
83+
/// Simulate token exchange.
84+
async fn exchange_authorization_code(
85+
token_endpoint: &str,
86+
auth_code: &str,
87+
client_id: &str,
88+
client_secret: Option<&str>,
89+
) -> Result<TokenResponse> {
90+
println!("\n🔄 Exchanging authorization code for tokens");
91+
println!(" Token endpoint: {}", token_endpoint);
92+
println!(" Auth code: {}...", &auth_code[..8.min(auth_code.len())]);
93+
println!(" Client ID: {}", client_id);
94+
println!(" Using client secret: {}", client_secret.is_some());
95+
96+
// Simulate token response
97+
sleep(Duration::from_millis(100)).await;
98+
99+
Ok(TokenResponse {
100+
access_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...".to_string(),
101+
token_type: "Bearer".to_string(),
102+
expires_in: Some(3600),
103+
refresh_token: Some("refresh_token_abc123".to_string()),
104+
scope: Some("openid profile email".to_string()),
105+
})
106+
}
107+
108+
/// Simulate token refresh.
109+
async fn refresh_access_token(
110+
token_endpoint: &str,
111+
refresh_token: &str,
112+
client_id: &str,
113+
) -> Result<TokenResponse> {
114+
println!("\n🔄 Refreshing access token");
115+
println!(" Token endpoint: {}", token_endpoint);
116+
println!(
117+
" Refresh token: {}...",
118+
&refresh_token[..10.min(refresh_token.len())]
119+
);
120+
println!(" Client ID: {}", client_id);
121+
122+
// Simulate token response
123+
sleep(Duration::from_millis(100)).await;
124+
125+
Ok(TokenResponse {
126+
access_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.new...".to_string(),
127+
token_type: "Bearer".to_string(),
128+
expires_in: Some(3600),
129+
refresh_token: Some("refresh_token_xyz789".to_string()),
130+
scope: Some("openid profile email".to_string()),
131+
})
132+
}
133+
134+
/// Demonstrate OAuth provider setup.
135+
async fn setup_oauth_provider() -> Result<()> {
136+
println!("\n📦 Setting up OAuth provider");
137+
138+
let provider = InMemoryOAuthProvider::new("https://auth.example.com");
139+
140+
// Register a client
141+
let client = OAuthClient {
142+
client_id: "example-client".to_string(),
143+
client_secret: Some("super-secret".to_string()),
144+
client_name: "Example Client".to_string(),
145+
redirect_uris: vec!["https://app.example.com/callback".to_string()],
146+
grant_types: vec![
147+
pmcp::server::auth::oauth2::GrantType::AuthorizationCode,
148+
pmcp::server::auth::oauth2::GrantType::RefreshToken,
149+
],
150+
response_types: vec![pmcp::server::auth::oauth2::ResponseType::Code],
151+
scopes: vec!["openid".to_string(), "profile".to_string()],
152+
metadata: std::collections::HashMap::new(),
153+
};
154+
155+
let registered = provider.register_client(client).await?;
156+
println!(" ✅ Registered client: {}", registered.client_id);
157+
158+
// Get provider metadata
159+
let metadata = provider.metadata().await?;
160+
println!(" 📋 Provider metadata:");
161+
println!(" - Issuer: {}", metadata.issuer);
162+
println!(" - Auth endpoint: {}", metadata.authorization_endpoint);
163+
println!(" - Token endpoint: {}", metadata.token_endpoint);
164+
165+
// Try OIDC discovery (will fail with default implementation)
166+
match provider.discover("https://auth.example.com").await {
167+
Ok(discovered) => {
168+
println!(" ✅ Discovery succeeded: {}", discovered.issuer);
169+
},
170+
Err(e) => {
171+
println!(" ℹ️ Discovery not implemented (expected): {}", e);
172+
},
173+
}
174+
175+
Ok(())
176+
}
177+
178+
/// Demonstrate transport isolation.
179+
async fn demonstrate_transport_isolation() -> Result<()> {
180+
use pmcp::shared::protocol::{Protocol, ProtocolOptions, TransportId};
181+
use pmcp::types::{JSONRPCResponse, RequestId};
182+
183+
println!("\n🔒 Demonstrating transport isolation");
184+
185+
// Create two separate transports
186+
let transport1 = TransportId::from_string("websocket-1".to_string());
187+
let transport2 = TransportId::from_string("http-sse-1".to_string());
188+
189+
let mut protocol1 = Protocol::with_transport_id(ProtocolOptions::default(), transport1.clone());
190+
let mut protocol2 = Protocol::with_transport_id(ProtocolOptions::default(), transport2.clone());
191+
192+
println!(" Created transport 1: {:?}", transport1);
193+
println!(" Created transport 2: {:?}", transport2);
194+
195+
// Register the same request ID on both transports
196+
let request_id = RequestId::from("test-request");
197+
let mut rx1 = protocol1.register_request(request_id.clone());
198+
let mut rx2 = protocol2.register_request(request_id.clone());
199+
200+
println!(" Registered request '{}' on both transports", request_id);
201+
202+
// Complete request for transport 1
203+
let response1 = JSONRPCResponse::success(
204+
request_id.clone(),
205+
serde_json::json!({"source": "transport1"}),
206+
);
207+
protocol1.complete_request(&request_id, response1).unwrap();
208+
209+
// Complete request for transport 2
210+
let response2 = JSONRPCResponse::success(
211+
request_id.clone(),
212+
serde_json::json!({"source": "transport2"}),
213+
);
214+
protocol2.complete_request(&request_id, response2).unwrap();
215+
216+
// Wait for async completion
217+
sleep(Duration::from_millis(50)).await;
218+
219+
// Verify isolation
220+
match rx1.try_recv() {
221+
Ok(resp) => {
222+
println!(" ✅ Transport 1 received: {:?}", resp.result());
223+
},
224+
Err(_) => {
225+
println!(" ❌ Transport 1 didn't receive response");
226+
},
227+
}
228+
229+
match rx2.try_recv() {
230+
Ok(resp) => {
231+
println!(" ✅ Transport 2 received: {:?}", resp.result());
232+
},
233+
Err(_) => {
234+
println!(" ❌ Transport 2 didn't receive response");
235+
},
236+
}
237+
238+
Ok(())
239+
}
240+
241+
#[tokio::main]
242+
#[allow(clippy::result_large_err)]
243+
async fn main() -> Result<()> {
244+
println!("🚀 OIDC Discovery and OAuth 2.0 Example\n");
245+
println!("{}", "=".repeat(50));
246+
247+
// 1. Discover OIDC configuration
248+
let metadata = discover_with_retries("https://auth.example.com").await?;
249+
250+
println!("\n📋 Discovered Configuration:");
251+
println!(" Issuer: {}", metadata.issuer);
252+
println!(" Authorization: {}", metadata.authorization_endpoint);
253+
println!(" Token: {}", metadata.token_endpoint);
254+
if let Some(jwks) = &metadata.jwks_uri {
255+
println!(" JWKS: {}", jwks);
256+
}
257+
if let Some(userinfo) = &metadata.userinfo_endpoint {
258+
println!(" UserInfo: {}", userinfo);
259+
}
260+
println!(" Supported scopes: {:?}", metadata.scopes_supported);
261+
262+
// 2. Exchange authorization code for tokens
263+
let tokens = exchange_authorization_code(
264+
&metadata.token_endpoint,
265+
"auth_code_from_callback",
266+
"example-client",
267+
Some("client-secret"),
268+
)
269+
.await?;
270+
271+
println!("\n🎫 Received Tokens:");
272+
println!(" Access token: {}...", &tokens.access_token[..20]);
273+
println!(" Token type: {}", tokens.token_type);
274+
println!(" Expires in: {:?} seconds", tokens.expires_in);
275+
if let Some(refresh) = &tokens.refresh_token {
276+
println!(" Refresh token: {}...", &refresh[..10]);
277+
}
278+
if let Some(scope) = &tokens.scope {
279+
println!(" Scope: {}", scope);
280+
}
281+
282+
// 3. Refresh the access token
283+
if let Some(refresh_token) = tokens.refresh_token {
284+
let new_tokens =
285+
refresh_access_token(&metadata.token_endpoint, &refresh_token, "example-client")
286+
.await?;
287+
288+
println!("\n🔄 Refreshed Tokens:");
289+
println!(" New access token: {}...", &new_tokens.access_token[..20]);
290+
println!(" Expires in: {:?} seconds", new_tokens.expires_in);
291+
}
292+
293+
// 4. Setup OAuth provider
294+
setup_oauth_provider().await?;
295+
296+
// 5. Demonstrate transport isolation
297+
demonstrate_transport_isolation().await?;
298+
299+
println!("\n✅ Example completed successfully!");
300+
301+
Ok(())
302+
}

0 commit comments

Comments
 (0)