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