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}