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}