use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use serde_json::{Value, json};
use super::error::AuthError;
use super::session::SessionClient;
#[derive(Debug, Clone)]
pub struct IntrospectionConfig {
pub url: String,
pub client_id: String,
pub client_secret: String,
}
#[derive(Debug, Clone)]
pub struct IntrospectionSessionClient {
client: reqwest::Client,
config: IntrospectionConfig,
}
impl IntrospectionSessionClient {
pub fn new(config: IntrospectionConfig) -> Self {
Self {
client: reqwest::Client::new(),
config,
}
}
pub fn new_with(
url: impl Into<String>,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
Self::new(IntrospectionConfig {
url: url.into(),
client_id: client_id.into(),
client_secret: client_secret.into(),
})
}
}
#[async_trait::async_trait]
impl SessionClient for IntrospectionSessionClient {
async fn to_session(
&self,
_cookie: Option<&str>,
token: Option<&str>,
) -> Result<Value, AuthError> {
let token = token.ok_or_else(|| AuthError::InvalidSession("missing token".to_string()))?;
let response = self
.client
.post(&self.config.url)
.basic_auth(&self.config.client_id, Some(&self.config.client_secret))
.form(&[("token", token)])
.send()
.await
.map_err(|e| AuthError::InvalidSession(format!("introspection request failed: {e}")))?;
if !response.status().is_success() {
return Err(AuthError::InvalidSession(format!(
"introspection returned {}",
response.status()
)));
}
let body: Value = response.json().await.map_err(|e| {
AuthError::InvalidSession(format!("invalid introspection response: {e}"))
})?;
let active = body
.get("active")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !active {
return Err(AuthError::InvalidSession("token inactive".to_string()));
}
let subject = body
.get("sub")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if subject.is_empty() {
return Err(AuthError::InvalidSession(
"introspection response missing sub".to_string(),
));
}
Ok(json!({
"identity": { "id": subject },
"scope": body.get("scope").cloned().unwrap_or_else(|| json!("")),
}))
}
}
#[derive(Clone, Debug)]
struct CachedIntrospection {
result: Value,
inserted_at: Instant,
}
#[derive(Clone, Debug)]
pub struct CachedIntrospectionSessionClient {
inner: IntrospectionSessionClient,
cache: Arc<DashMap<String, CachedIntrospection>>,
ttl: Duration,
}
impl CachedIntrospectionSessionClient {
pub fn new(inner: IntrospectionSessionClient, ttl: Duration) -> Self {
Self {
inner,
cache: Arc::new(DashMap::new()),
ttl,
}
}
pub fn from_config(config: IntrospectionConfig, ttl: Duration) -> Self {
Self::new(IntrospectionSessionClient::new(config), ttl)
}
pub fn clear_cache(&self) {
self.cache.clear();
}
}
#[async_trait::async_trait]
impl SessionClient for CachedIntrospectionSessionClient {
async fn to_session(
&self,
_cookie: Option<&str>,
token: Option<&str>,
) -> Result<Value, AuthError> {
let token = token.ok_or_else(|| AuthError::InvalidSession("missing token".to_string()))?;
let token_hash = super::hash_token(token);
if let Some(entry) = self.cache.get(&token_hash)
&& entry.inserted_at.elapsed() < self.ttl
{
tracing::debug!("token introspection cache hit");
return Ok(entry.result.clone());
}
tracing::debug!("token introspection cache miss");
let result = self.inner.to_session(None, Some(token)).await?;
let active = result
.get("identity")
.and_then(|i| i.get("id"))
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false);
if active {
self.cache.insert(
token_hash,
CachedIntrospection {
result: result.clone(),
inserted_at: Instant::now(),
},
);
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cached_client_clears_cache() {
let client = CachedIntrospectionSessionClient::from_config(
IntrospectionConfig {
url: "http://localhost".into(),
client_id: "id".into(),
client_secret: "secret".into(),
},
Duration::from_secs(60),
);
client.clear_cache();
assert!(client.cache.is_empty());
}
}