lenso-module-auth 0.1.5

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

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

#[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;

    #[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
        );
    }
}