use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use turbomcp_protocol::{Error as McpError, Result as McpResult};
#[derive(Clone, Serialize)]
pub struct IntrospectionRequest {
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_type_hint: Option<String>,
}
impl std::fmt::Debug for IntrospectionRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IntrospectionRequest")
.field("token", &"[REDACTED]")
.field("token_type_hint", &self.token_type_hint)
.finish()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IntrospectionResponse {
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
#[derive(Clone)]
pub struct IntrospectionClient {
endpoint: String,
client_id: String,
client_secret: Option<String>,
http_client: reqwest::Client,
}
impl std::fmt::Debug for IntrospectionClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IntrospectionClient")
.field("endpoint", &self.endpoint)
.field("client_id", &self.client_id)
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| "[REDACTED]"),
)
.field("http_client", &"<reqwest::Client>")
.finish()
}
}
impl IntrospectionClient {
pub fn new(endpoint: String, client_id: String, client_secret: Option<String>) -> Self {
Self {
endpoint,
client_id,
client_secret,
http_client: reqwest::Client::new(),
}
}
pub async fn introspect(
&self,
token: &str,
token_type_hint: Option<&str>,
) -> McpResult<IntrospectionResponse> {
let mut form_data = vec![("token", token), ("client_id", &self.client_id)];
let secret_storage;
if let Some(ref secret) = self.client_secret {
secret_storage = secret.clone();
form_data.push(("client_secret", &secret_storage));
}
let hint_storage;
if let Some(hint) = token_type_hint {
hint_storage = hint.to_string();
form_data.push(("token_type_hint", &hint_storage));
}
let response = self
.http_client
.post(&self.endpoint)
.form(&form_data)
.send()
.await
.map_err(|e| McpError::internal(format!("Introspection request failed: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(McpError::internal(format!(
"Introspection endpoint returned {}: {}",
status, body
)));
}
let introspection_response =
response
.json::<IntrospectionResponse>()
.await
.map_err(|e| {
McpError::internal(format!("Failed to parse introspection response: {}", e))
})?;
Ok(introspection_response)
}
pub async fn is_token_active(&self, token: &str) -> McpResult<bool> {
let response = self.introspect(token, Some("access_token")).await?;
Ok(response.active)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_introspection_client_creation() {
let client = IntrospectionClient::new(
"https://auth.example.com/introspect".to_string(),
"client_id".to_string(),
Some("secret".to_string()),
);
assert_eq!(client.endpoint, "https://auth.example.com/introspect");
assert_eq!(client.client_id, "client_id");
assert!(client.client_secret.is_some());
}
#[test]
fn test_introspection_response_active() {
let json = r#"{"active": true, "client_id": "test_client", "scope": "read write"}"#;
let response: IntrospectionResponse = serde_json::from_str(json).unwrap();
assert!(response.active);
assert_eq!(response.client_id, Some("test_client".to_string()));
assert_eq!(response.scope, Some("read write".to_string()));
}
#[test]
fn test_introspection_response_inactive() {
let json = r#"{"active": false}"#;
let response: IntrospectionResponse = serde_json::from_str(json).unwrap();
assert!(!response.active);
}
#[test]
fn test_introspection_response_full() {
let json = r#"{
"active": true,
"scope": "read write",
"client_id": "l238j323ds-23ij4",
"username": "jdoe",
"token_type": "Bearer",
"exp": 1419356238,
"iat": 1419350238,
"nbf": 1419350238,
"sub": "Z5O3upPC88QrAjx00dis",
"aud": "https://protected.example.net/resource",
"iss": "https://server.example.com/",
"jti": "JlbmMiOiJBMTI4Q0JDLUhTMjU2In"
}"#;
let response: IntrospectionResponse = serde_json::from_str(json).unwrap();
assert!(response.active);
assert_eq!(response.username, Some("jdoe".to_string()));
assert_eq!(response.token_type, Some("Bearer".to_string()));
assert_eq!(response.exp, Some(1419356238));
assert_eq!(response.sub, Some("Z5O3upPC88QrAjx00dis".to_string()));
}
}