Cache Arc<TokenClaims> in JWT validation; bump Docker base images

JWT validation cache now stores Arc<TokenClaims> and validate_token
returns Arc<TokenClaims>. On a cache hit — the 99% path for every
authenticated request — the moka lookup was deep-cloning the whole
claims struct (5 Strings: sub, jti, username, email, role) on every
call. It is now a refcount bump. Read-only callers (admin middleware)
go through Deref and allocate nothing; the auth middleware clones only
the three fields it moves into CurrentUser (was 5 clones, now 3), and
the admin paths clone only role (was 5, now 1). A new test asserts the
hit path returns a pointer-equal Arc.

TokenServicePort::validate_token is the single trait method touched;
its only implementor is JwtTokenService and the only production callers
are the auth and admin middleware (the WOPI handler uses a separate
WopiTokenService).

Dockerfile: rust:1.94.1-alpine3.23 -> rust:1.96-alpine3.24 and
alpine:3.23.3 -> alpine:3.24.0 for the runtime stage.

https://claude.ai/code/session_0193Hff42gaA962wThxMGSd1
This commit is contained in:
Claude
2026-06-11 10:56:33 +00:00
parent 54c494419c
commit 23de7e503b
5 changed files with 55 additions and 19 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# ─── Stage 1: Shared build base (avoids duplicate apk install) ──────────────── # ─── 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 # 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 # 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. # 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 RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
# ─── Stage 4: Minimal runtime image ────────────────────────────────────────── # ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
FROM alpine:3.23.3 FROM alpine:3.24.0
# OCI image metadata # OCI image metadata
LABEL org.opencontainers.image.title="OxiCloud" \ LABEL org.opencontainers.image.title="OxiCloud" \
+9 -2
View File
@@ -3,6 +3,7 @@ use crate::domain::entities::app_password::AppPassword;
use crate::domain::entities::device_code::DeviceCode; use crate::domain::entities::device_code::DeviceCode;
use crate::domain::entities::session::Session; use crate::domain::entities::session::Session;
use crate::domain::entities::user::User; use crate::domain::entities::user::User;
use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
// ============================================================================ // ============================================================================
@@ -51,8 +52,14 @@ pub trait TokenServicePort: Send + Sync + 'static {
/// Generate an access token for a user /// Generate an access token for a user
fn generate_access_token(&self, user: &User) -> Result<String, DomainError>; fn generate_access_token(&self, user: &User) -> Result<String, DomainError>;
/// Validate a token and extract its claims /// Validate a token and extract its claims.
fn validate_token(&self, token: &str) -> Result<TokenClaims, DomainError>; ///
/// Returns `Arc<TokenClaims>` 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<Arc<TokenClaims>, DomainError>;
/// Generate a refresh token /// Generate a refresh token
fn generate_refresh_token(&self) -> String; fn generate_refresh_token(&self) -> String;
+36 -7
View File
@@ -13,6 +13,7 @@ use chrono::Utc;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use moka::sync::Cache; use moka::sync::Cache;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration; use std::time::Duration;
use uuid::Uuid; use uuid::Uuid;
@@ -69,8 +70,9 @@ impl From<JwtClaims> for TokenClaims {
/// ///
/// The cache uses the **BLAKE3** hash of the raw token string as key (32-byte, /// 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 /// ~0.1 µs to compute — 20× cheaper than HMAC verification) and stores the
/// validated `TokenClaims`. On a cache hit the HMAC step is completely /// validated claims behind an `Arc`. On a cache hit the HMAC step is
/// skipped. /// completely skipped and the lookup returns a refcount bump rather than a
/// deep clone of the (multi-`String`) `TokenClaims`.
/// ///
/// **Security properties**: /// **Security properties**:
/// - TTL of 30 s bounds the window in which a revoked token remains valid. /// - 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, access_token_expiry: i64,
/// Expiration time for refresh tokens in seconds /// Expiration time for refresh tokens in seconds
refresh_token_expiry: i64, refresh_token_expiry: i64,
/// Validation result cache: blake3(token) → TokenClaims /// Validation result cache: blake3(token) → Arc<TokenClaims>.
validation_cache: Cache<[u8; 32], TokenClaims>, /// `Arc` so a cache hit is a refcount bump, not a multi-`String` clone.
validation_cache: Cache<[u8; 32], Arc<TokenClaims>>,
/// Cache hit counter (for observability / metrics) /// Cache hit counter (for observability / metrics)
cache_hits: AtomicU64, cache_hits: AtomicU64,
/// Cache miss counter /// Cache miss counter
@@ -194,7 +197,7 @@ impl TokenServicePort for JwtTokenService {
}) })
} }
fn validate_token(&self, token: &str) -> Result<TokenClaims, DomainError> { fn validate_token(&self, token: &str) -> Result<Arc<TokenClaims>, DomainError> {
// ── 1. Fast-path: check the validation cache ───────────── // ── 1. Fast-path: check the validation cache ─────────────
let key = Self::token_hash(token); 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 ──────────── // ── 3. Store in cache for subsequent requests ────────────
// Only cache tokens that won't expire within the cache TTL window, // Only cache tokens that won't expire within the cache TTL window,
// avoiding stale positives right at the boundary. // avoiding stale positives right at the boundary.
let remaining_secs = claims.exp - Utc::now().timestamp(); let remaining_secs = claims.exp - Utc::now().timestamp();
if remaining_secs > VALIDATION_CACHE_TTL_SECS as i64 { 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) Ok(claims)
@@ -348,6 +352,31 @@ mod tests {
assert_eq!(misses, 1, "Expected 1 cache miss"); 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] #[test]
fn test_invalid_token_not_cached() { fn test_invalid_token_not_cached() {
let service = JwtTokenService::new("secret".to_string(), 3600, 86400); let service = JwtTokenService::new("secret".to_string(), 3600, 86400);
+2 -2
View File
@@ -54,7 +54,7 @@ pub async fn require_admin(
Ok(( Ok((
Uuid::parse_str(&claims.sub) Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?, .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(( Ok((
Uuid::parse_str(&claims.sub) Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?, .map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role, claims.role.clone(),
)) ))
} }
+6 -6
View File
@@ -183,9 +183,9 @@ pub async fn auth_middleware(
})?; })?;
let current_user = Arc::new(CurrentUser { let current_user = Arc::new(CurrentUser {
id: user_id, id: user_id,
username: claims.username, username: claims.username.clone(),
email: claims.email, email: claims.email.clone(),
role: claims.role, role: claims.role.clone(),
}); });
request.extensions_mut().insert(current_user); request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string()); 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 { let current_user = Arc::new(CurrentUser {
id: user_id, id: user_id,
username: claims.username, username: claims.username.clone(),
email: claims.email, email: claims.email.clone(),
role: claims.role, role: claims.role.clone(),
}); });
request.extensions_mut().insert(current_user); request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated); request.extensions_mut().insert(CookieAuthenticated);