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;
pub const DEFAULT_INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct IntrospectionConfig {
pub url: String,
pub client_id: String,
pub client_secret: String,
pub timeout: Option<Duration>,
}
impl IntrospectionConfig {
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
}
#[derive(Debug, Clone)]
pub struct IntrospectionSessionClient {
client: reqwest::Client,
config: IntrospectionConfig,
}
impl IntrospectionSessionClient {
pub fn new(config: IntrospectionConfig) -> Self {
let timeout = match config.timeout {
Some(timeout) => timeout,
None => DEFAULT_INTROSPECTION_TIMEOUT,
};
let client = match reqwest::Client::builder().timeout(timeout).build() {
Ok(client) => client,
Err(e) => {
log::warn!("failed to build introspection HTTP client; using defaults: {e}");
reqwest::Client::new()
}
};
Self { client, 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(),
timeout: None,
})
}
}
#[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| {
if e.is_timeout() {
AuthError::SessionTimeout(format!("introspection request timed out: {e}"))
} else {
AuthError::SessionTransport(format!("introspection request failed: {e}"))
}
})?;
if !response.status().is_success() {
return Err(AuthError::SessionGateway(format!(
"introspection returned {}",
response.status()
)));
}
let body: Value = response.json().await.map_err(|e| {
AuthError::SessionGateway(format!("invalid introspection response: {e}"))
})?;
let active = body
.get("active")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !active {
return Err(AuthError::InactiveSession);
}
let tenant_id = body
.get("tenant_id")
.and_then(|v| v.as_str())
.map(str::to_string);
let subject = body
.get("sub")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if subject.is_empty() {
return Err(AuthError::SessionGateway(
"introspection response missing sub".to_string(),
));
}
Ok(json!({
"tenant_id": tenant_id,
"sub": subject,
"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(),
timeout: None,
},
Duration::from_secs(60),
);
client.clear_cache();
assert!(client.cache.is_empty());
}
#[test]
fn introspection_constructors_apply_timeout() {
let config = IntrospectionConfig {
url: "http://localhost/introspect".into(),
client_id: "id".into(),
client_secret: "secret".into(),
timeout: None,
}
.with_timeout(Duration::from_millis(250));
assert_eq!(config.timeout, Some(Duration::from_millis(250)));
let _client = IntrospectionSessionClient::new(config.clone());
let _client =
IntrospectionSessionClient::new_with("http://localhost/introspect", "id", "secret");
let inner = IntrospectionSessionClient::new(config);
let cached = CachedIntrospectionSessionClient::new(inner, Duration::from_secs(1));
assert_eq!(cached.ttl, Duration::from_secs(1));
}
#[tokio::test]
async fn introspection_clients_require_token() {
let client = IntrospectionSessionClient::new_with("http://localhost", "id", "secret");
let error = client.to_session(None, None).await.unwrap_err();
assert!(matches!(error, AuthError::InvalidSession(_)));
let cached = CachedIntrospectionSessionClient::new(client, Duration::from_secs(1));
let error = cached.to_session(None, None).await.unwrap_err();
assert!(matches!(error, AuthError::InvalidSession(_)));
}
}