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());
}
}