lenso-module-auth 0.2.3

First-party auth anchor module for the Lenso backend framework.
Documentation
use crate::config::{AuthRuntimeConfig, SESSION_CACHE_MAX_TTL, SessionCacheMode};
use crate::resolver::{CachedSession, SessionCache};
use chrono::{DateTime, Utc};
use platform_core::{AppContext, AppError, AppResult, ErrorCode};
use redis::AsyncCommands;
use std::sync::Arc;
use std::time::Duration;

const DEFAULT_KEY_PREFIX: &str = "auth:sessions:";

pub fn session_cache_from_context(ctx: &AppContext) -> Option<Arc<dyn SessionCache>> {
    (AuthRuntimeConfig::from_context(ctx).session_cache == SessionCacheMode::Redis)
        .then(|| ctx.redis.clone())
        .flatten()
        .map(|redis| Arc::new(RedisSessionCache::new(redis, SESSION_CACHE_MAX_TTL)) as Arc<_>)
}

#[derive(Debug, Clone)]
pub struct RedisSessionCache {
    connection: redis::aio::ConnectionManager,
    key_prefix: String,
    max_ttl: Duration,
}

impl RedisSessionCache {
    #[must_use]
    pub fn new(connection: redis::aio::ConnectionManager, max_ttl: Duration) -> Self {
        Self {
            connection,
            key_prefix: DEFAULT_KEY_PREFIX.to_owned(),
            max_ttl,
        }
    }

    pub async fn connect(url: &str, max_ttl: Duration) -> AppResult<Self> {
        let client = redis::Client::open(url).map_err(map_redis_error)?;
        let connection = redis::aio::ConnectionManager::new(client)
            .await
            .map_err(map_redis_error)?;
        Ok(Self::new(connection, max_ttl))
    }

    #[must_use]
    pub fn with_key_prefix(mut self, key_prefix: impl Into<String>) -> Self {
        self.key_prefix = key_prefix.into();
        self
    }

    fn key(&self, token_hash: &str) -> String {
        format!("{}{}", self.key_prefix, token_hash)
    }
}

#[async_trait::async_trait]
impl SessionCache for RedisSessionCache {
    async fn get(&self, token_hash: &str) -> AppResult<Option<CachedSession>> {
        let mut connection = self.connection.clone();
        let value: Option<String> = connection
            .get(self.key(token_hash))
            .await
            .map_err(map_redis_error)?;
        value
            .map(|raw| serde_json::from_str(&raw))
            .transpose()
            .map_err(|source| {
                AppError::new(ErrorCode::Internal, "Invalid cached auth session")
                    .with_source(source)
            })
    }

    async fn put(&self, token_hash: &str, session: CachedSession) -> AppResult<()> {
        let Some(ttl_seconds) = ttl_seconds(session.expires_at, self.max_ttl, Utc::now()) else {
            return Ok(());
        };
        let value = serde_json::to_string(&session).map_err(|source| {
            AppError::new(ErrorCode::Internal, "Failed to encode cached auth session")
                .with_source(source)
        })?;
        let mut connection = self.connection.clone();
        let _: () = connection
            .set_ex(self.key(token_hash), value, ttl_seconds)
            .await
            .map_err(map_redis_error)?;
        Ok(())
    }

    async fn delete(&self, token_hash: &str) -> AppResult<()> {
        let mut connection = self.connection.clone();
        let _: usize = connection
            .del(self.key(token_hash))
            .await
            .map_err(map_redis_error)?;
        Ok(())
    }
}

fn ttl_seconds(expires_at: DateTime<Utc>, max_ttl: Duration, now: DateTime<Utc>) -> Option<u64> {
    let session_ttl = (expires_at - now).to_std().ok()?;
    let ttl = session_ttl.min(max_ttl).as_secs();
    (ttl > 0).then_some(ttl)
}

fn map_redis_error(source: redis::RedisError) -> AppError {
    AppError::new(ErrorCode::ExternalDependency, "Redis operation failed")
        .with_source(source)
        .retryable()
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration as ChronoDuration;
    use platform_core::config::{
        AppConfig, AuthConfig, DatabaseConfig, HttpConfig, ModuleSourcesConfig, RedisConfig,
        ServiceConfig, TelemetryConfig,
    };
    use platform_core::{AppContext, LoggingEventPublisher};
    use std::collections::BTreeMap;
    use std::sync::Arc;

    #[test]
    fn ttl_uses_lower_of_session_and_cache_limits() {
        let now = Utc::now();
        assert_eq!(
            ttl_seconds(
                now + ChronoDuration::seconds(120),
                Duration::from_secs(300),
                now
            ),
            Some(120)
        );
        assert_eq!(
            ttl_seconds(
                now + ChronoDuration::seconds(120),
                Duration::from_secs(30),
                now
            ),
            Some(30)
        );
        assert_eq!(
            ttl_seconds(
                now - ChronoDuration::seconds(1),
                Duration::from_secs(30),
                now
            ),
            None
        );
    }

    #[tokio::test]
    async fn context_factory_returns_no_cache_for_database_mode() {
        let ctx = AppContext::new(
            AppConfig {
                auth: AuthConfig::default(),
                database: DatabaseConfig {
                    max_connections: 1,
                    url: "postgres://localhost/lenso_test".to_owned(),
                },
                http: HttpConfig::default(),
                module_sources: ModuleSourcesConfig::default(),
                modules: BTreeMap::new(),
                redis: RedisConfig::default(),
                service: ServiceConfig {
                    environment: "local".to_owned(),
                    name: "auth-cache-test".to_owned(),
                },
                telemetry: TelemetryConfig::default(),
            },
            platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
                .expect("lazy database pool"),
            Arc::new(LoggingEventPublisher),
        );

        assert!(session_cache_from_context(&ctx).is_none());
    }
}