sunbeam-g2v 0.5.1

Sunbeam Service Framework - A ConnectRPC-based framework for building microservices
//! OAuth 2.0 token introspection-backed session client with optional caching.
//!
//! Validates bearer tokens by calling an OAuth 2.0 token introspection endpoint.
//! The normalized response is shaped like a session so the rest of the auth
//! middleware can stay agnostic.
//!
//! A [`CachedIntrospectionSessionClient`] wrapper adds an in-memory TTL cache
//! that skips caching inactive tokens, limiting the revocation window.

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;

/// Configuration for an introspection endpoint.
#[derive(Debug, Clone)]
pub struct IntrospectionConfig {
    /// Full URL of the introspection endpoint.
    pub url: String,
    /// OAuth2 client id used to authenticate the introspection request.
    pub client_id: String,
    /// OAuth2 client secret used to authenticate the introspection request.
    pub client_secret: String,
}

/// Session client that validates tokens via OAuth 2.0 introspection.
#[derive(Debug, Clone)]
pub struct IntrospectionSessionClient {
    client: reqwest::Client,
    config: IntrospectionConfig,
}

impl IntrospectionSessionClient {
    /// Create a client from configuration.
    pub fn new(config: IntrospectionConfig) -> Self {
        Self {
            client: reqwest::Client::new(),
            config,
        }
    }

    /// Convenience constructor from individual fields.
    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(),
            ));
        }

        // Normalize to the same shape the introspection session client produces so the
        // middleware can extract `tenant_id` and `sub` unchanged.
        Ok(json!({
            "identity": { "id": subject },
            "scope": body.get("scope").cloned().unwrap_or_else(|| json!("")),
        }))
    }
}

/// Cached entry for an introspection result.
#[derive(Clone, Debug)]
struct CachedIntrospection {
    result: Value,
    inserted_at: Instant,
}

/// Session client that wraps an [`IntrospectionSessionClient`] with an
/// in-memory TTL cache.
///
/// Inactive tokens are never cached, limiting the revocation window.
#[derive(Clone, Debug)]
pub struct CachedIntrospectionSessionClient {
    inner: IntrospectionSessionClient,
    cache: Arc<DashMap<String, CachedIntrospection>>,
    ttl: Duration,
}

impl CachedIntrospectionSessionClient {
    /// Create a cached wrapper around an existing introspection client.
    pub fn new(inner: IntrospectionSessionClient, ttl: Duration) -> Self {
        Self {
            inner,
            cache: Arc::new(DashMap::new()),
            ttl,
        }
    }

    /// Create a cached client from configuration.
    pub fn from_config(config: IntrospectionConfig, ttl: Duration) -> Self {
        Self::new(IntrospectionSessionClient::new(config), ttl)
    }

    /// Purge all cached entries.
    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);

        // Check cache.
        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?;

        // Do not cache inactive tokens; this limits the window after revocation.
        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());
    }
}