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:
+2
-2
@@ -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" \
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user