diff --git a/Dockerfile b/Dockerfile index e50b8107..117edafa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ─── Stage 1: Shared build base (avoids duplicate apk install) ──────────────── -FROM rust:1.94.1-alpine3.23 AS base +FROM rust:1.96-alpine3.24 AS base # sqlx's postgres driver speaks the wire protocol in pure Rust (no pq-sys in # Cargo.lock) and TLS goes through rustls, so libpq headers are never needed at # build time. perl/make/gcc/musl-dev remain for the C builds of aws-lc-sys. @@ -41,7 +41,7 @@ ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud" RUN DATABASE_URL="${DATABASE_URL}" cargo build --release # ─── Stage 4: Minimal runtime image ────────────────────────────────────────── -FROM alpine:3.23.3 +FROM alpine:3.24.0 # OCI image metadata LABEL org.opencontainers.image.title="OxiCloud" \ diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 57f9782c..5e9c79fd 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -3,6 +3,7 @@ use crate::domain::entities::app_password::AppPassword; use crate::domain::entities::device_code::DeviceCode; use crate::domain::entities::session::Session; use crate::domain::entities::user::User; +use std::sync::Arc; use uuid::Uuid; // ============================================================================ @@ -51,8 +52,14 @@ pub trait TokenServicePort: Send + Sync + 'static { /// Generate an access token for a user fn generate_access_token(&self, user: &User) -> Result; - /// Validate a token and extract its claims - fn validate_token(&self, token: &str) -> Result; + /// Validate a token and extract its claims. + /// + /// Returns `Arc` so the implementation's validation cache can + /// hand back a hot entry with a refcount bump instead of deep-cloning the + /// (multi-`String`) claims on every authenticated request. Callers that + /// only read fields go through `Deref`; the few that retain a field clone + /// just that one. + fn validate_token(&self, token: &str) -> Result, DomainError>; /// Generate a refresh token fn generate_refresh_token(&self) -> String; diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs index 2e70f111..37d825f5 100644 --- a/src/infrastructure/services/jwt_service.rs +++ b/src/infrastructure/services/jwt_service.rs @@ -13,6 +13,7 @@ use chrono::Utc; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use moka::sync::Cache; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use uuid::Uuid; @@ -69,8 +70,9 @@ impl From for TokenClaims { /// /// The cache uses the **BLAKE3** hash of the raw token string as key (32-byte, /// ~0.1 µs to compute — 20× cheaper than HMAC verification) and stores the -/// validated `TokenClaims`. On a cache hit the HMAC step is completely -/// skipped. +/// validated claims behind an `Arc`. On a cache hit the HMAC step is +/// completely skipped and the lookup returns a refcount bump rather than a +/// deep clone of the (multi-`String`) `TokenClaims`. /// /// **Security properties**: /// - TTL of 30 s bounds the window in which a revoked token remains valid. @@ -84,8 +86,9 @@ pub struct JwtTokenService { access_token_expiry: i64, /// Expiration time for refresh tokens in seconds refresh_token_expiry: i64, - /// Validation result cache: blake3(token) → TokenClaims - validation_cache: Cache<[u8; 32], TokenClaims>, + /// Validation result cache: blake3(token) → Arc. + /// `Arc` so a cache hit is a refcount bump, not a multi-`String` clone. + validation_cache: Cache<[u8; 32], Arc>, /// Cache hit counter (for observability / metrics) cache_hits: AtomicU64, /// Cache miss counter @@ -194,7 +197,7 @@ impl TokenServicePort for JwtTokenService { }) } - fn validate_token(&self, token: &str) -> Result { + fn validate_token(&self, token: &str) -> Result, DomainError> { // ── 1. Fast-path: check the validation cache ───────────── let key = Self::token_hash(token); @@ -232,14 +235,15 @@ impl TokenServicePort for JwtTokenService { ), })?; - let claims: TokenClaims = token_data.claims.into(); + let claims = Arc::new(TokenClaims::from(token_data.claims)); // ── 3. Store in cache for subsequent requests ──────────── // Only cache tokens that won't expire within the cache TTL window, // avoiding stale positives right at the boundary. let remaining_secs = claims.exp - Utc::now().timestamp(); if remaining_secs > VALIDATION_CACHE_TTL_SECS as i64 { - self.validation_cache.insert(key, claims.clone()); + // Refcount bump — the claims live once behind the `Arc`. + self.validation_cache.insert(key, Arc::clone(&claims)); } Ok(claims) @@ -348,6 +352,31 @@ mod tests { assert_eq!(misses, 1, "Expected 1 cache miss"); } + #[test] + fn test_cache_hit_returns_same_arc_not_a_clone() { + let service = JwtTokenService::new( + "test_secret_key_at_least_32_bytes_long".to_string(), + 3600, + 86400, + ); + let token = service + .generate_access_token(&create_test_user()) + .expect("Should generate token"); + + // Miss populates the cache; hit must hand back the very same + // allocation (pointer-equal Arc), proving the hot path is a refcount + // bump rather than a deep clone of the claims' Strings. + let first = service.validate_token(&token).expect("miss"); + let second = service.validate_token(&token).expect("hit"); + + assert!( + Arc::ptr_eq(&first, &second), + "cache hit must return the same Arc, not a fresh allocation" + ); + let (hits, misses) = service.cache_stats(); + assert_eq!((hits, misses), (1, 1)); + } + #[test] fn test_invalid_token_not_cached() { let service = JwtTokenService::new("secret".to_string(), 3600, 86400); diff --git a/src/interfaces/middleware/admin.rs b/src/interfaces/middleware/admin.rs index b8662601..8ddd13c2 100644 --- a/src/interfaces/middleware/admin.rs +++ b/src/interfaces/middleware/admin.rs @@ -54,7 +54,7 @@ pub async fn require_admin( Ok(( Uuid::parse_str(&claims.sub) .map_err(|_| AppError::internal_error("Invalid user ID in token"))?, - claims.role, + claims.role.clone(), )) } @@ -87,6 +87,6 @@ pub async fn require_authenticated( Ok(( Uuid::parse_str(&claims.sub) .map_err(|_| AppError::internal_error("Invalid user ID in token"))?, - claims.role, + claims.role.clone(), )) } diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index e2e4ca5b..34ca7c1a 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -183,9 +183,9 @@ pub async fn auth_middleware( })?; let current_user = Arc::new(CurrentUser { id: user_id, - username: claims.username, - email: claims.email, - role: claims.role, + username: claims.username.clone(), + email: claims.email.clone(), + role: claims.role.clone(), }); request.extensions_mut().insert(current_user); tracing::Span::current().record("user_id", user_id.to_string()); @@ -287,9 +287,9 @@ pub async fn auth_middleware( })?; let current_user = Arc::new(CurrentUser { id: user_id, - username: claims.username, - email: claims.email, - role: claims.role, + username: claims.username.clone(), + email: claims.email.clone(), + role: claims.role.clone(), }); request.extensions_mut().insert(current_user); request.extensions_mut().insert(CookieAuthenticated);