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}