turbomcp_auth/oauth2/dcr.rs
1//! OAuth 2.0 Dynamic Client Registration (RFC 7591)
2//!
3//! Enables OAuth clients to register themselves with authorization servers
4//! without manual pre-registration.
5//!
6//! # MCP Specification
7//!
8//! Per MCP spec (2025-06-18):
9//! > MCP auth implementations SHOULD support the OAuth 2.0 Dynamic Client
10//! > Registration Protocol (RFC7591).
11//!
12//! # Why Dynamic Client Registration?
13//!
14//! - **Seamless Integration**: No manual client registration needed
15//! - **Developer Experience**: Auto-configuration for CLI tools, SDKs
16//! - **Scalability**: Programmatic client creation
17//! - **Security**: Cryptographically secure client secrets
18//!
19//! # Example
20//!
21//! ```rust,no_run
22//! use turbomcp_auth::oauth2::dcr::{DcrClient, DcrBuilder};
23//!
24//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
25//! // Create a DCR client
26//! let dcr_client = DcrClient::new(
27//! "https://auth.example.com/register".to_string(),
28//! None, // No initial access token required
29//! );
30//!
31//! // Build registration request for MCP client
32//! let request = DcrBuilder::mcp_client(
33//! "My MCP Client",
34//! "http://localhost:3000/callback"
35//! )
36//! .with_scopes(vec!["mcp:tools".to_string(), "mcp:resources".to_string()])
37//! .with_client_uri("https://my-app.example.com".to_string())
38//! .build();
39//!
40//! // Register the client
41//! let response = dcr_client.register(request).await?;
42//!
43//! println!("Client ID: {}", response.client_id);
44//! println!("Client Secret: {:?}", response.client_secret);
45//! # Ok(())
46//! # }
47//! ```
48
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51use turbomcp_protocol::{Error as McpError, Result as McpResult};
52
53/// Client registration request per RFC 7591 Section 2
54///
55/// This structure represents the metadata that a client sends to the
56/// authorization server when requesting dynamic registration.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RegistrationRequest {
59 /// Redirect URIs (REQUIRED for authorization code flow)
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub redirect_uris: Option<Vec<String>>,
62
63 /// Token endpoint authentication method
64 ///
65 /// Common values:
66 /// - `client_secret_basic` - HTTP Basic authentication
67 /// - `client_secret_post` - Client credentials in POST body
68 /// - `none` - Public client (no authentication)
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub token_endpoint_auth_method: Option<String>,
71
72 /// Grant types supported by the client
73 ///
74 /// Common values:
75 /// - `authorization_code`
76 /// - `refresh_token`
77 /// - `client_credentials`
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub grant_types: Option<Vec<String>>,
80
81 /// Response types the client will use
82 ///
83 /// Common values:
84 /// - `code` - Authorization code flow
85 /// - `token` - Implicit flow (deprecated)
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub response_types: Option<Vec<String>>,
88
89 /// Human-readable client name
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub client_name: Option<String>,
92
93 /// Client homepage URI
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub client_uri: Option<String>,
96
97 /// Logo URI for the client
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub logo_uri: Option<String>,
100
101 /// Space-separated list of OAuth scopes
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub scope: Option<String>,
104
105 /// Contact email addresses
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub contacts: Option<Vec<String>>,
108
109 /// Terms of service URI
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub tos_uri: Option<String>,
112
113 /// Privacy policy URI
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub policy_uri: Option<String>,
116
117 /// Software identifier (for version tracking)
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub software_id: Option<String>,
120
121 /// Software version
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub software_version: Option<String>,
124
125 /// JWKS URI for public key retrieval
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub jwks_uri: Option<String>,
128
129 /// Application type (web, native)
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub application_type: Option<String>,
132}
133
134/// Client registration response per RFC 7591 Section 3.2
135///
136/// Contains the registered client credentials and metadata returned
137/// by the authorization server.
138#[derive(Debug, Clone, Deserialize, Serialize)]
139pub struct RegistrationResponse {
140 /// Client identifier (REQUIRED)
141 pub client_id: String,
142
143 /// Client secret (if confidential client)
144 ///
145 /// This will be None for public clients (e.g., native apps, SPAs)
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub client_secret: Option<String>,
148
149 /// Client secret expiration time (seconds since epoch, 0 = never)
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub client_secret_expires_at: Option<u64>,
152
153 /// Registration access token (for updating/deleting registration)
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub registration_access_token: Option<String>,
156
157 /// Registration client URI (for PUT/DELETE operations)
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub registration_client_uri: Option<String>,
160
161 /// Client ID issued at timestamp
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub client_id_issued_at: Option<u64>,
164
165 /// All registered metadata (echo of request + server additions)
166 #[serde(flatten)]
167 pub metadata: HashMap<String, serde_json::Value>,
168}
169
170/// Dynamic Client Registration client
171///
172/// # Example
173///
174/// ```rust,no_run
175/// use turbomcp_auth::oauth2::dcr::{DcrClient, DcrBuilder};
176///
177/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
178/// let client = DcrClient::new(
179/// "https://auth.example.com/register".to_string(),
180/// None,
181/// );
182///
183/// let request = DcrBuilder::mcp_client("My App", "http://localhost:3000/callback")
184/// .with_scopes(vec!["mcp:tools".to_string()])
185/// .build();
186///
187/// let response = client.register(request).await?;
188/// println!("Registered! Client ID: {}", response.client_id);
189/// # Ok(())
190/// # }
191/// ```
192#[derive(Debug, Clone)]
193pub struct DcrClient {
194 /// Registration endpoint URL
195 endpoint: String,
196
197 /// Initial access token (if required by server)
198 ///
199 /// Some authorization servers require an initial access token
200 /// to prevent unauthorized client registration.
201 initial_access_token: Option<String>,
202
203 /// HTTP client
204 http_client: reqwest::Client,
205}
206
207impl DcrClient {
208 /// Create a new DCR client
209 ///
210 /// # Arguments
211 ///
212 /// * `endpoint` - Registration endpoint URL (from AS metadata `registration_endpoint`)
213 /// * `initial_access_token` - Optional token for authenticated registration
214 ///
215 /// # Example
216 ///
217 /// ```rust
218 /// use turbomcp_auth::oauth2::dcr::DcrClient;
219 ///
220 /// // Open registration (no token required)
221 /// let client = DcrClient::new(
222 /// "https://auth.example.com/register".to_string(),
223 /// None,
224 /// );
225 ///
226 /// // Authenticated registration
227 /// let auth_client = DcrClient::new(
228 /// "https://auth.example.com/register".to_string(),
229 /// Some("initial_access_token_here".to_string()),
230 /// );
231 /// ```
232 pub fn new(endpoint: String, initial_access_token: Option<String>) -> Self {
233 Self {
234 endpoint,
235 initial_access_token,
236 http_client: reqwest::Client::new(),
237 }
238 }
239
240 /// Register a new OAuth client
241 ///
242 /// # Arguments
243 ///
244 /// * `request` - Client registration metadata
245 ///
246 /// # Returns
247 ///
248 /// Registration response with client_id, client_secret, and metadata
249 ///
250 /// # Errors
251 ///
252 /// Returns error if:
253 /// - HTTP request fails
254 /// - Server rejects registration
255 /// - Response is malformed
256 ///
257 /// # Example
258 ///
259 /// ```rust,no_run
260 /// # use turbomcp_auth::oauth2::dcr::{DcrClient, DcrBuilder};
261 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
262 /// # let client = DcrClient::new("https://example.com/register".into(), None);
263 /// let request = DcrBuilder::mcp_client("My App", "http://localhost:3000/callback")
264 /// .build();
265 ///
266 /// let response = client.register(request).await?;
267 /// # Ok(())
268 /// # }
269 /// ```
270 pub async fn register(&self, request: RegistrationRequest) -> McpResult<RegistrationResponse> {
271 let mut req = self.http_client.post(&self.endpoint).json(&request);
272
273 // Add initial access token if present
274 if let Some(ref token) = self.initial_access_token {
275 req = req.bearer_auth(token);
276 }
277
278 let response = req
279 .send()
280 .await
281 .map_err(|e| McpError::internal(format!("Registration request failed: {}", e)))?;
282
283 if !response.status().is_success() {
284 let status = response.status();
285 let body = response.text().await.unwrap_or_default();
286 return Err(McpError::internal(format!(
287 "Registration failed with {}: {}",
288 status, body
289 )));
290 }
291
292 let registration_response = response.json::<RegistrationResponse>().await.map_err(|e| {
293 McpError::internal(format!("Failed to parse registration response: {}", e))
294 })?;
295
296 Ok(registration_response)
297 }
298
299 /// Update an existing client registration (RFC 7592)
300 ///
301 /// Requires the `registration_access_token` from the original registration.
302 ///
303 /// # Example
304 ///
305 /// ```rust,no_run
306 /// # use turbomcp_auth::oauth2::dcr::{DcrClient, DcrBuilder};
307 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
308 /// # let client = DcrClient::new("https://example.com/register".into(), None);
309 /// # let original_response = client.register(DcrBuilder::mcp_client("App", "http://localhost:3000/callback").build()).await?;
310 /// // Update the registration
311 /// let updated = DcrBuilder::mcp_client("Updated App Name", "http://localhost:3000/callback")
312 /// .with_scopes(vec!["mcp:tools".to_string(), "mcp:resources".to_string()])
313 /// .build();
314 ///
315 /// let response = client.update(
316 /// &original_response.registration_client_uri.unwrap(),
317 /// &original_response.registration_access_token.unwrap(),
318 /// updated,
319 /// ).await?;
320 /// # Ok(())
321 /// # }
322 /// ```
323 pub async fn update(
324 &self,
325 registration_uri: &str,
326 access_token: &str,
327 request: RegistrationRequest,
328 ) -> McpResult<RegistrationResponse> {
329 let response = self
330 .http_client
331 .put(registration_uri)
332 .bearer_auth(access_token)
333 .json(&request)
334 .send()
335 .await
336 .map_err(|e| McpError::internal(format!("Update request failed: {}", e)))?;
337
338 if !response.status().is_success() {
339 let status = response.status();
340 let body = response.text().await.unwrap_or_default();
341 return Err(McpError::internal(format!(
342 "Update failed with {}: {}",
343 status, body
344 )));
345 }
346
347 let registration_response = response
348 .json::<RegistrationResponse>()
349 .await
350 .map_err(|e| McpError::internal(format!("Failed to parse update response: {}", e)))?;
351
352 Ok(registration_response)
353 }
354
355 /// Delete a client registration (RFC 7592)
356 ///
357 /// Requires the `registration_access_token` from the original registration.
358 pub async fn delete(&self, registration_uri: &str, access_token: &str) -> McpResult<()> {
359 let response = self
360 .http_client
361 .delete(registration_uri)
362 .bearer_auth(access_token)
363 .send()
364 .await
365 .map_err(|e| McpError::internal(format!("Delete request failed: {}", e)))?;
366
367 if !response.status().is_success() {
368 let status = response.status();
369 let body = response.text().await.unwrap_or_default();
370 return Err(McpError::internal(format!(
371 "Delete failed with {}: {}",
372 status, body
373 )));
374 }
375
376 Ok(())
377 }
378}
379
380/// Builder for dynamic client registration requests
381///
382/// Provides convenient methods for constructing registration requests
383/// with sensible defaults for MCP clients.
384///
385/// # Example
386///
387/// ```rust
388/// use turbomcp_auth::oauth2::dcr::DcrBuilder;
389///
390/// let request = DcrBuilder::mcp_client("My MCP Client", "http://localhost:3000/callback")
391/// .with_scopes(vec!["mcp:tools".to_string(), "mcp:resources".to_string()])
392/// .with_client_uri("https://my-app.example.com".to_string())
393/// .with_contacts(vec!["admin@example.com".to_string()])
394/// .build();
395/// ```
396pub struct DcrBuilder {
397 request: RegistrationRequest,
398}
399
400impl DcrBuilder {
401 /// Create a new DCR builder for MCP client
402 ///
403 /// Sets sensible defaults:
404 /// - Grant types: authorization_code, refresh_token
405 /// - Response types: code
406 /// - Token endpoint auth: client_secret_basic
407 /// - Application type: web
408 /// - Software ID: turbomcp
409 ///
410 /// # Arguments
411 ///
412 /// * `client_name` - Human-readable client name
413 /// * `redirect_uri` - OAuth redirect URI
414 pub fn mcp_client(client_name: &str, redirect_uri: &str) -> Self {
415 Self {
416 request: RegistrationRequest {
417 client_name: Some(client_name.to_string()),
418 redirect_uris: Some(vec![redirect_uri.to_string()]),
419 grant_types: Some(vec![
420 "authorization_code".to_string(),
421 "refresh_token".to_string(),
422 ]),
423 response_types: Some(vec!["code".to_string()]),
424 token_endpoint_auth_method: Some("client_secret_basic".to_string()),
425 application_type: Some("web".to_string()),
426 software_id: Some("turbomcp".to_string()),
427 software_version: Some(env!("CARGO_PKG_VERSION").to_string()),
428 scope: None,
429 client_uri: None,
430 logo_uri: None,
431 contacts: None,
432 tos_uri: None,
433 policy_uri: None,
434 jwks_uri: None,
435 },
436 }
437 }
438
439 /// Create a builder for a native/mobile client
440 ///
441 /// Sets application_type to "native" and uses appropriate auth method
442 pub fn native_client(client_name: &str, redirect_uri: &str) -> Self {
443 let mut builder = Self::mcp_client(client_name, redirect_uri);
444 builder.request.application_type = Some("native".to_string());
445 builder.request.token_endpoint_auth_method = Some("none".to_string()); // Public client
446 builder
447 }
448
449 /// Set OAuth scopes
450 pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
451 self.request.scope = Some(scopes.join(" "));
452 self
453 }
454
455 /// Set client homepage URI
456 pub fn with_client_uri(mut self, uri: String) -> Self {
457 self.request.client_uri = Some(uri);
458 self
459 }
460
461 /// Set logo URI
462 pub fn with_logo_uri(mut self, uri: String) -> Self {
463 self.request.logo_uri = Some(uri);
464 self
465 }
466
467 /// Set contact emails
468 pub fn with_contacts(mut self, contacts: Vec<String>) -> Self {
469 self.request.contacts = Some(contacts);
470 self
471 }
472
473 /// Set terms of service URI
474 pub fn with_tos_uri(mut self, uri: String) -> Self {
475 self.request.tos_uri = Some(uri);
476 self
477 }
478
479 /// Set privacy policy URI
480 pub fn with_policy_uri(mut self, uri: String) -> Self {
481 self.request.policy_uri = Some(uri);
482 self
483 }
484
485 /// Set JWKS URI for public keys
486 pub fn with_jwks_uri(mut self, uri: String) -> Self {
487 self.request.jwks_uri = Some(uri);
488 self
489 }
490
491 /// Set additional redirect URIs
492 pub fn with_redirect_uris(mut self, uris: Vec<String>) -> Self {
493 self.request.redirect_uris = Some(uris);
494 self
495 }
496
497 /// Build the registration request
498 pub fn build(self) -> RegistrationRequest {
499 self.request
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_dcr_builder_mcp_client() {
509 let request = DcrBuilder::mcp_client("My MCP Client", "http://localhost:3000/callback")
510 .with_scopes(vec!["mcp:tools".to_string()])
511 .build();
512
513 assert_eq!(request.client_name, Some("My MCP Client".to_string()));
514 assert_eq!(
515 request.redirect_uris,
516 Some(vec!["http://localhost:3000/callback".to_string()])
517 );
518 assert_eq!(request.scope, Some("mcp:tools".to_string()));
519 assert!(request.software_id.is_some());
520 assert_eq!(request.application_type, Some("web".to_string()));
521 }
522
523 #[test]
524 fn test_dcr_builder_native_client() {
525 let request = DcrBuilder::native_client("My App", "myapp://callback").build();
526
527 assert_eq!(request.application_type, Some("native".to_string()));
528 assert_eq!(request.token_endpoint_auth_method, Some("none".to_string()));
529 }
530
531 #[test]
532 fn test_registration_response_deserialization() {
533 let json = r#"{
534 "client_id": "s6BhdRkqt3",
535 "client_secret": "cf136dc3c1fc93f31185e5885805d",
536 "client_secret_expires_at": 1577858400,
537 "registration_access_token": "this.is.an.access.token.value.ffx83",
538 "registration_client_uri": "https://server.example.com/register/s6BhdRkqt3",
539 "client_id_issued_at": 1571158400
540 }"#;
541
542 let response: RegistrationResponse = serde_json::from_str(json).unwrap();
543
544 assert_eq!(response.client_id, "s6BhdRkqt3");
545 assert_eq!(
546 response.client_secret,
547 Some("cf136dc3c1fc93f31185e5885805d".to_string())
548 );
549 assert_eq!(response.client_secret_expires_at, Some(1577858400));
550 assert!(response.registration_access_token.is_some());
551 assert!(response.registration_client_uri.is_some());
552 }
553
554 #[test]
555 fn test_dcr_client_creation() {
556 let client = DcrClient::new(
557 "https://auth.example.com/register".to_string(),
558 Some("initial_token".to_string()),
559 );
560
561 assert_eq!(client.endpoint, "https://auth.example.com/register");
562 assert!(client.initial_access_token.is_some());
563 }
564}