diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index 93ae497..b9f9e17 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -30,7 +30,7 @@ impl AuthConfig { let ttl_seconds = std::env::var("JWT_TTL_SECONDS") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(86400u64); + .unwrap_or(900u64); Ok(Self { secret, ttl_seconds, diff --git a/crates/application/src/auth/login.rs b/crates/application/src/auth/login.rs index 42cf960..bdd6d15 100644 --- a/crates/application/src/auth/login.rs +++ b/crates/application/src/auth/login.rs @@ -1,12 +1,13 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use uuid::Uuid; -use domain::{errors::DomainError, value_objects::Email}; +use domain::{errors::DomainError, models::RefreshSession, value_objects::Email}; use crate::{auth::queries::LoginQuery, context::AppContext}; pub struct LoginResult { pub token: String, + pub refresh_token: String, pub user_id: Uuid, pub email: String, pub expires_at: DateTime, @@ -33,8 +34,20 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result Result<(), DomainError> { + ctx.repos.refresh_session.revoke(refresh_token).await +} diff --git a/crates/application/src/auth/mod.rs b/crates/application/src/auth/mod.rs index cf0fc74..7b4409f 100644 --- a/crates/application/src/auth/mod.rs +++ b/crates/application/src/auth/mod.rs @@ -1,5 +1,7 @@ pub mod commands; pub mod login; +pub mod logout; pub mod queries; +pub mod refresh; pub mod register; pub mod register_and_login; diff --git a/crates/application/src/auth/refresh.rs b/crates/application/src/auth/refresh.rs new file mode 100644 index 0000000..a2c0761 --- /dev/null +++ b/crates/application/src/auth/refresh.rs @@ -0,0 +1,59 @@ +use chrono::{Duration, Utc}; +use uuid::Uuid; + +use domain::{errors::DomainError, models::RefreshSession}; + +use crate::context::AppContext; + +pub struct RefreshResult { + pub token: String, + pub refresh_token: String, + pub expires_at: chrono::DateTime, +} + +pub async fn execute( + ctx: &AppContext, + old_refresh_token: &str, +) -> Result { + let session = ctx + .repos + .refresh_session + .get_by_token(old_refresh_token) + .await? + .ok_or_else(|| DomainError::Unauthorized("Invalid refresh token".into()))?; + + if session.expires_at < Utc::now() { + ctx.repos + .refresh_session + .revoke(old_refresh_token) + .await?; + return Err(DomainError::Unauthorized("Refresh token expired".into())); + } + + // Revoke old token (rotation) + ctx.repos + .refresh_session + .revoke(old_refresh_token) + .await?; + + // Generate new access token + let generated = ctx.services.auth.generate_token(&session.user_id).await?; + + // Create new refresh session + let new_refresh_token = Uuid::new_v4().to_string(); + let refresh_expires = Utc::now() + Duration::seconds(ctx.config.refresh_ttl_seconds as i64); + let new_session = RefreshSession { + id: Uuid::new_v4(), + user_id: session.user_id, + token: new_refresh_token.clone(), + expires_at: refresh_expires, + created_at: Utc::now(), + }; + ctx.repos.refresh_session.create(&new_session).await?; + + Ok(RefreshResult { + token: generated.token, + refresh_token: new_refresh_token, + expires_at: generated.expires_at, + }) +} diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs index 29d3c9d..37d3813 100644 --- a/crates/application/src/config.rs +++ b/crates/application/src/config.rs @@ -3,6 +3,7 @@ pub struct AppConfig { pub allow_registration: bool, pub base_url: String, pub rate_limit: u64, + pub refresh_ttl_seconds: u64, pub wrapup: WrapUpConfig, } @@ -24,10 +25,15 @@ impl AppConfig { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(60); + let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(2_592_000u64); Self { allow_registration, base_url, rate_limit, + refresh_ttl_seconds, wrapup: WrapUpConfig::from_env(), } } diff --git a/crates/application/src/jobs.rs b/crates/application/src/jobs.rs index 78c06a3..fe0041f 100644 --- a/crates/application/src/jobs.rs +++ b/crates/application/src/jobs.rs @@ -162,6 +162,31 @@ impl PeriodicJob for WrapUpAutoGenerateJob { } } +pub struct RefreshSessionCleanupJob { + ctx: AppContext, +} + +impl RefreshSessionCleanupJob { + pub fn new(ctx: AppContext) -> Self { + Self { ctx } + } +} + +#[async_trait] +impl PeriodicJob for RefreshSessionCleanupJob { + fn interval(&self) -> Duration { + Duration::from_secs(86400) + } + + async fn run(&self) -> Result<(), DomainError> { + let n = self.ctx.repos.refresh_session.delete_expired().await?; + if n > 0 { + tracing::info!("refresh session cleanup: removed {n} expired sessions"); + } + Ok(()) + } +} + pub struct WrapUpCleanupJob { ctx: AppContext, } diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 5fc69cb..25cdcbc 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -19,10 +19,10 @@ use domain::{ FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort, FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository, InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo, - InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository, - InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository, - NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand, - PanicRefreshSessionRepository, + InMemoryRefreshSessionRepository, InMemoryReviewRepository, InMemoryUserRepository, + InMemoryUserSettingsRepository, InMemoryWatchEventRepository, InMemoryWatchlistRepository, + InMemoryWebhookTokenRepository, NoopEventPublisher, NoopObjectStorage, + PanicDiaryExporter, PanicPersonCommand, }, }; @@ -119,11 +119,12 @@ impl TestContextBuilder { user_settings_repo: InMemoryUserSettingsRepository::new(), review_logger: Arc::new(NoopReviewLogger), social_query: Arc::new(NoopSocialQueryPort), - refresh_session_repo: Arc::new(PanicRefreshSessionRepository), + refresh_session_repo: InMemoryRefreshSessionRepository::new(), config: AppConfig { allow_registration: true, base_url: "http://localhost:3000".into(), rate_limit: 20, + refresh_ttl_seconds: 2_592_000, wrapup: crate::config::WrapUpConfig { font_path: None, logo_path: None, diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index e8dd463..22a8b79 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -6,19 +6,22 @@ use uuid::Uuid; use chrono::NaiveDateTime; +use chrono::Utc; + use crate::{ errors::DomainError, events::DomainEvent, models::{ Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary, - ProfileField, Review, User, UserSettings, UserSummary, WatchEvent, WatchEventStatus, - WatchlistEntry, WatchlistWithMovie, WebhookToken, + ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, WatchEvent, + WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken, collections::{PageParams, Paginated}, }, ports::{ GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, - MovieRepository, ReviewRepository, UserProfileFieldsRepository, UserRepository, - UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository, + MovieRepository, RefreshSessionRepository, ReviewRepository, UserProfileFieldsRepository, + UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository, + WebhookTokenRepository, }, value_objects::{ Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, @@ -777,3 +780,56 @@ impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo { Ok(()) } } + +// ── InMemoryRefreshSessionRepository ──────────────────────────────────────── + +pub struct InMemoryRefreshSessionRepository { + pub store: Mutex>, +} + +impl InMemoryRefreshSessionRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(Vec::new()), + }) + } +} + +#[async_trait] +impl RefreshSessionRepository for InMemoryRefreshSessionRepository { + async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { + self.store.lock().unwrap().push(session.clone()); + Ok(()) + } + + async fn get_by_token(&self, token: &str) -> Result, DomainError> { + Ok(self + .store + .lock() + .unwrap() + .iter() + .find(|s| s.token == token) + .cloned()) + } + + async fn revoke(&self, token: &str) -> Result<(), DomainError> { + self.store.lock().unwrap().retain(|s| s.token != token); + Ok(()) + } + + async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .retain(|s| s.user_id != *user_id); + Ok(()) + } + + async fn delete_expired(&self) -> Result { + let mut store = self.store.lock().unwrap(); + let before = store.len(); + let now = Utc::now(); + store.retain(|s| s.expires_at >= now); + Ok((before - store.len()) as u64) + } +} diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 5977b4c..c016a66 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -812,6 +812,7 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20, + refresh_ttl_seconds: 2_592_000, wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 3681965..6947bdc 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -476,6 +476,7 @@ async fn test_app() -> Router { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20, + refresh_ttl_seconds: 2_592_000, wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 6de5b47..b3b0aad 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -178,6 +178,7 @@ async fn main() -> anyhow::Result<()> { Arc::new(application::jobs::WatchEventCleanupJob::new(ctx.clone())), Arc::new(application::jobs::WrapUpAutoGenerateJob::new(ctx.clone())), Arc::new(application::jobs::WrapUpCleanupJob::new(ctx.clone())), + Arc::new(application::jobs::RefreshSessionCleanupJob::new(ctx.clone())), ]; if let Some(job) = enrichment_job { periodic_jobs.push(job);