turbomcp_auth/
introspection.rs

1//! OAuth 2.0 Token Introspection (RFC 7662)
2//!
3//! Provides real-time token validation via authorization server introspection endpoint.
4//! Complements JWT validation by enabling immediate revocation checking.
5//!
6//! # Why Token Introspection?
7//!
8//! JWT signatures cannot be revoked without key rotation. Introspection provides:
9//! - Real-time revocation checking
10//! - Centralized token state management
11//! - Support for opaque tokens (non-JWT)
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use turbomcp_auth::introspection::IntrospectionClient;
17//!
18//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
19//! let client = IntrospectionClient::new(
20//!     "https://auth.example.com/oauth/introspect".to_string(),
21//!     "client_id".to_string(),
22//!     Some("client_secret".to_string()),
23//! );
24//!
25//! // Check if token is active
26//! let is_active = client.is_token_active("access_token_here").await?;
27//!
28//! if is_active {
29//!     println!("Token is valid");
30//! } else {
31//!     println!("Token revoked or expired");
32//! }
33//! # Ok(())
34//! # }
35//! ```
36
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39use turbomcp_protocol::{Error as McpError, Result as McpResult};
40
41/// Token introspection request per RFC 7662 Section 2.1
42#[derive(Clone, Serialize)]
43pub struct IntrospectionRequest {
44    /// The token to introspect (REQUIRED)
45    pub token: String,
46
47    /// Hint about token type (access_token or refresh_token)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub token_type_hint: Option<String>,
50}
51
52// Manual Debug impl to prevent token exposure in logs (Sprint 3.6)
53impl std::fmt::Debug for IntrospectionRequest {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("IntrospectionRequest")
56            .field("token", &"[REDACTED]")
57            .field("token_type_hint", &self.token_type_hint)
58            .finish()
59    }
60}
61
62/// Token introspection response per RFC 7662 Section 2.2
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct IntrospectionResponse {
65    /// Whether the token is currently active (REQUIRED)
66    pub active: bool,
67
68    /// Scope(s) associated with the token
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub scope: Option<String>,
71
72    /// Client identifier
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub client_id: Option<String>,
75
76    /// Username (if applicable)
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub username: Option<String>,
79
80    /// Token type (Bearer, etc.)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub token_type: Option<String>,
83
84    /// Expiration timestamp (seconds since epoch)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub exp: Option<u64>,
87
88    /// Issued at timestamp (seconds since epoch)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub iat: Option<u64>,
91
92    /// Not before timestamp (seconds since epoch)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub nbf: Option<u64>,
95
96    /// Subject
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub sub: Option<String>,
99
100    /// Audience
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub aud: Option<serde_json::Value>,
103
104    /// Issuer
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub iss: Option<String>,
107
108    /// JWT ID
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub jti: Option<String>,
111
112    /// Additional fields
113    #[serde(flatten)]
114    pub additional: HashMap<String, serde_json::Value>,
115}
116
117/// Token introspection client
118///
119/// # Example
120///
121/// ```rust,no_run
122/// use turbomcp_auth::introspection::IntrospectionClient;
123///
124/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
125/// let client = IntrospectionClient::new(
126///     "https://auth.example.com/oauth/introspect".to_string(),
127///     "my_client_id".to_string(),
128///     Some("my_client_secret".to_string()),
129/// );
130///
131/// // Full introspection
132/// let response = client.introspect("token_here", Some("access_token")).await?;
133/// println!("Token active: {}", response.active);
134/// println!("Token scopes: {:?}", response.scope);
135///
136/// // Quick check
137/// let is_active = client.is_token_active("token_here").await?;
138/// # Ok(())
139/// # }
140/// ```
141#[derive(Clone)]
142pub struct IntrospectionClient {
143    /// Introspection endpoint URL
144    endpoint: String,
145
146    /// Client ID for authentication
147    client_id: String,
148
149    /// Client secret for authentication (if confidential client)
150    client_secret: Option<String>,
151
152    /// HTTP client
153    http_client: reqwest::Client,
154}
155
156// Manual Debug impl to prevent client_secret exposure in logs (Sprint 3.6)
157impl std::fmt::Debug for IntrospectionClient {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("IntrospectionClient")
160            .field("endpoint", &self.endpoint)
161            .field("client_id", &self.client_id)
162            .field(
163                "client_secret",
164                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
165            )
166            .field("http_client", &"<reqwest::Client>")
167            .finish()
168    }
169}
170
171impl IntrospectionClient {
172    /// Create a new introspection client
173    ///
174    /// # Arguments
175    ///
176    /// * `endpoint` - Token introspection endpoint URL
177    /// * `client_id` - Client ID for authenticating with the introspection endpoint
178    /// * `client_secret` - Client secret (None for public clients)
179    ///
180    /// # Example
181    ///
182    /// ```rust
183    /// use turbomcp_auth::introspection::IntrospectionClient;
184    ///
185    /// // Confidential client
186    /// let client = IntrospectionClient::new(
187    ///     "https://auth.example.com/introspect".to_string(),
188    ///     "client_id".to_string(),
189    ///     Some("secret".to_string()),
190    /// );
191    ///
192    /// // Public client
193    /// let public_client = IntrospectionClient::new(
194    ///     "https://auth.example.com/introspect".to_string(),
195    ///     "public_client".to_string(),
196    ///     None,
197    /// );
198    /// ```
199    pub fn new(endpoint: String, client_id: String, client_secret: Option<String>) -> Self {
200        Self {
201            endpoint,
202            client_id,
203            client_secret,
204            http_client: reqwest::Client::new(),
205        }
206    }
207
208    /// Introspect a token per RFC 7662
209    ///
210    /// # Arguments
211    ///
212    /// * `token` - The token to introspect
213    /// * `token_type_hint` - Optional hint (e.g., "access_token", "refresh_token")
214    ///
215    /// # Returns
216    ///
217    /// IntrospectionResponse indicating if token is active and its metadata
218    ///
219    /// # Errors
220    ///
221    /// Returns error if:
222    /// - HTTP request fails
223    /// - Response is malformed
224    /// - Authentication fails
225    ///
226    /// # Example
227    ///
228    /// ```rust,no_run
229    /// # use turbomcp_auth::introspection::IntrospectionClient;
230    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
231    /// # let client = IntrospectionClient::new("https://example.com".into(), "id".into(), None);
232    /// let response = client.introspect("my_token", Some("access_token")).await?;
233    ///
234    /// if response.active {
235    ///     println!("Token is valid");
236    ///     println!("Subject: {:?}", response.sub);
237    ///     println!("Scopes: {:?}", response.scope);
238    /// } else {
239    ///     println!("Token is revoked or expired");
240    /// }
241    /// # Ok(())
242    /// # }
243    /// ```
244    pub async fn introspect(
245        &self,
246        token: &str,
247        token_type_hint: Option<&str>,
248    ) -> McpResult<IntrospectionResponse> {
249        let mut form_data = vec![("token", token), ("client_id", &self.client_id)];
250
251        // Add client secret if present
252        let secret_storage;
253        if let Some(ref secret) = self.client_secret {
254            secret_storage = secret.clone();
255            form_data.push(("client_secret", &secret_storage));
256        }
257
258        // Add token type hint if present
259        let hint_storage;
260        if let Some(hint) = token_type_hint {
261            hint_storage = hint.to_string();
262            form_data.push(("token_type_hint", &hint_storage));
263        }
264
265        let response = self
266            .http_client
267            .post(&self.endpoint)
268            .form(&form_data)
269            .send()
270            .await
271            .map_err(|e| McpError::internal(format!("Introspection request failed: {}", e)))?;
272
273        if !response.status().is_success() {
274            let status = response.status();
275            let body = response.text().await.unwrap_or_default();
276            return Err(McpError::internal(format!(
277                "Introspection endpoint returned {}: {}",
278                status, body
279            )));
280        }
281
282        let introspection_response =
283            response
284                .json::<IntrospectionResponse>()
285                .await
286                .map_err(|e| {
287                    McpError::internal(format!("Failed to parse introspection response: {}", e))
288                })?;
289
290        Ok(introspection_response)
291    }
292
293    /// Check if a token is active (convenience method)
294    ///
295    /// This is a shortcut for `introspect()` that only returns the `active` field.
296    ///
297    /// # Example
298    ///
299    /// ```rust,no_run
300    /// # use turbomcp_auth::introspection::IntrospectionClient;
301    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
302    /// # let client = IntrospectionClient::new("https://example.com".into(), "id".into(), None);
303    /// if client.is_token_active("my_token").await? {
304    ///     // Token is valid, proceed
305    /// } else {
306    ///     // Token is revoked or expired, reject
307    /// }
308    /// # Ok(())
309    /// # }
310    /// ```
311    pub async fn is_token_active(&self, token: &str) -> McpResult<bool> {
312        let response = self.introspect(token, Some("access_token")).await?;
313        Ok(response.active)
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_introspection_client_creation() {
323        let client = IntrospectionClient::new(
324            "https://auth.example.com/introspect".to_string(),
325            "client_id".to_string(),
326            Some("secret".to_string()),
327        );
328
329        assert_eq!(client.endpoint, "https://auth.example.com/introspect");
330        assert_eq!(client.client_id, "client_id");
331        assert!(client.client_secret.is_some());
332    }
333
334    #[test]
335    fn test_introspection_response_active() {
336        let json = r#"{"active": true, "client_id": "test_client", "scope": "read write"}"#;
337        let response: IntrospectionResponse = serde_json::from_str(json).unwrap();
338
339        assert!(response.active);
340        assert_eq!(response.client_id, Some("test_client".to_string()));
341        assert_eq!(response.scope, Some("read write".to_string()));
342    }
343
344    #[test]
345    fn test_introspection_response_inactive() {
346        let json = r#"{"active": false}"#;
347        let response: IntrospectionResponse = serde_json::from_str(json).unwrap();
348
349        assert!(!response.active);
350    }
351
352    #[test]
353    fn test_introspection_response_full() {
354        let json = r#"{
355            "active": true,
356            "scope": "read write",
357            "client_id": "l238j323ds-23ij4",
358            "username": "jdoe",
359            "token_type": "Bearer",
360            "exp": 1419356238,
361            "iat": 1419350238,
362            "nbf": 1419350238,
363            "sub": "Z5O3upPC88QrAjx00dis",
364            "aud": "https://protected.example.net/resource",
365            "iss": "https://server.example.com/",
366            "jti": "JlbmMiOiJBMTI4Q0JDLUhTMjU2In"
367        }"#;
368
369        let response: IntrospectionResponse = serde_json::from_str(json).unwrap();
370
371        assert!(response.active);
372        assert_eq!(response.username, Some("jdoe".to_string()));
373        assert_eq!(response.token_type, Some("Bearer".to_string()));
374        assert_eq!(response.exp, Some(1419356238));
375        assert_eq!(response.sub, Some("Z5O3upPC88QrAjx00dis".to_string()));
376    }
377}