feat: add production capability foundations

This commit is contained in:
donghyeon-ka
2026-07-31 23:50:44 +09:00
parent b3add0162d
commit 567422f2e5
757 changed files with 132385 additions and 2146 deletions
+39 -25
View File
@@ -47,9 +47,24 @@ production configuration and compare the result with the committed snapshot.
### JwtToAuthenticatedPrincipalConverter
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
`ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인).
`ObjectOutputStream` 으로 round-trip 되지 않는다. Redis session mode에서도 아래 primitive snapshot
repository가 `Authentication` 객체 그래프를 저장하지 않는다.
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
### JWT / Redis session 상호배타 모드
`ca-skeleton.security.auth-mode=jwt|redis-session`은 하나만 선택한다. JWT mode는 stateless이고
CSRF/session repository를 만들지 않는다. Redis session mode는 `Secure`, `HttpOnly`, host-only
session cookie, `SameSite=Lax`, cookie/header CSRF와 `migrateSession` fixation 방어를 함께 켠다.
기본 `HttpSessionSecurityContextRepository`는 Spring Security 객체 전체를 session attribute에 넣어
outbound session codec의 primitive allowlist를 깨므로 사용하지 않는다.
`PrimitiveSessionSecurityContextRepository``AuthenticatedPrincipal`의 bounded
principal/email/roles/authorities만 versioned `byte[]` snapshot으로 저장한다. credential, bearer/JWT,
arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지 않는다. foreign principal이나
손상·초과 snapshot은 인증 없음으로 fail closed한다. 실제 security filter save/restore 테스트가 다음
요청에서 principal과 authorities가 복원되고 session에는 primitive snapshot만 남는 것을 검증한다.
### SecurityErrorClassifier
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
선언한 세분화 코드를 방출한다.
@@ -234,22 +249,20 @@ production configuration and compare the result with the committed snapshot.
## ratelimit
### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter)
- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다.
- **OCP(개방-폐쇄)**: `RateLimitInterceptor``RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일
`switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm`
enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`.
- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill)
`X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다.
- `limit` — 설정 quota
- `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor
- `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음
refill, sliding-window=가장 오래된 카운트 요청 만료 시점)
- `allowed` — quota 소진 시 false (→ 429)
- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를
가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance
전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node,
distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요.
### provider-neutral edge contract
- inbound web은 `shared-contract``EdgeRateLimitPort`만 호출한다. Redis key, Lua, local counter와
provider 설정을 알지 못한다.
- outbound provider activation SSOT는
`ca-skeleton.capabilities.rate-limit.provider=disabled|redis`이고, HTTP enforcement의 별도 축은
`app.rate-limit.enabled`다. transport가 enabled인데 exact provider가 없거나 중복이면 startup을
실패시킨다.
- fixed window, sliding counter, token bucket 선택과 policy revision은 Redis provider가 소유한다.
과거 process-local unbounded fixed-window map/factory/settings는 제거되었다. local emergency가
필요하면 bounded cardinality/TTL/in-flight와 명시적 degraded-provider 계약을 먼저 추가해야 하며,
silent primary fallback은 허용하지 않는다.
- `EdgeRateLimitTransportBridge`는 provider의 typed allow/deny/unavailable/incompatible outcome을
HTTP 2xx/429/503과 `Retry-After`로만 투영한다. timeout은 quota가 소비되지 않았다는 증거가 아니다.
### RateLimitKeyResolver
- 키 형태: service-to-service
@@ -270,11 +283,12 @@ production configuration and compare the result with the committed snapshot.
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
resolve 되기 때문(RateLimitKeyResolver 참조).
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에서도 `EdgeRateLimitTransportSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에선 `Clock#systemUTC()` 로 fallback.
### RateLimitInterceptor
- fixed-window rate limit 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true).
- provider가 선택한 rate-limit policy를 매핑된 handler 실행 전에 적용. quota 결과에는
`X-RateLimit-*` 헤더를 포함한다(generated_if_missing=true).
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
의존성 장애로 오분류하는 것을 막는다.
@@ -295,12 +309,12 @@ production configuration and compare the result with the committed snapshot.
`Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
시점에 fail-fast 거부.
### RateLimitSettings
- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다.
- `enabled``APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑.
- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스
정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값.
`algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`.
### EdgeRateLimitTransportSettings
- `app.rate-limit.*`은 HTTP enforcement, default policy ID, pseudonymization key version,
caller deadline, trusted client-IP mode만 소유한다.
- algorithm/quota/state TTL/HMAC secret는 outbound Redis capability 설정이 소유하며 web settings로
복제하지 않는다.
### SecuritySettings
- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가
+2
View File
@@ -6,6 +6,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.session:spring-session-core'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
implementation('org.openapitools:jackson-databind-nullable:0.2.6') {
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
@@ -15,4 +16,5 @@ dependencies {
// never a hand-maintained stale schema). The release-blocking drift gate is
// owned by feature-contract-verification-test-suite (planned).
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
testImplementation 'org.springframework.security:spring-security-test'
}
+2
View File
@@ -164,7 +164,9 @@ org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runti
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -4,6 +4,7 @@ import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
@@ -24,6 +25,10 @@ import org.springframework.security.oauth2.jwt.SupplierJwtDecoder;
* README for the design rationale.
*/
@Configuration
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "jwt",
matchIfMissing = true)
public class JwtDecoderConfig {
@Bean
@@ -0,0 +1,255 @@
package dev.caskeleton.adapter.inbound.web.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SecurityContextRepository;
/**
* Stores only a bounded primitive authentication snapshot in {@link HttpSession}.
*
* <p>Spring Security objects, credentials, tokens and arbitrary principal graphs never cross the
* Spring Session serialization boundary.
*/
final class PrimitiveSessionSecurityContextRepository implements SecurityContextRepository {
static final String SNAPSHOT_ATTRIBUTE = "dev.caskeleton.security.PRIMITIVE_SECURITY_CONTEXT_V1";
private static final int MAGIC = 0x43534543;
private static final int VERSION = 1;
private static final int MAXIMUM_SNAPSHOT_BYTES = 16_384;
private static final int MAXIMUM_PRINCIPAL_BYTES = 256;
private static final int MAXIMUM_EMAIL_BYTES = 320;
private static final int MAXIMUM_TOKEN_BYTES = 128;
private static final int MAXIMUM_ROLES = 64;
private static final int MAXIMUM_AUTHORITIES = 128;
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
return load(requestResponseHolder.getRequest());
}
@Override
public void saveContext(
SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
Objects.requireNonNull(request, "request");
Authentication authentication = context == null ? null : context.getAuthentication();
if (authentication == null
|| !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
HttpSession existing = request.getSession(false);
if (existing != null) {
existing.removeAttribute(SNAPSHOT_ATTRIBUTE);
}
return;
}
request.getSession(true).setAttribute(SNAPSHOT_ATTRIBUTE, encode(authentication));
}
@Override
public boolean containsContext(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return session != null && session.getAttribute(SNAPSHOT_ATTRIBUTE) instanceof byte[];
}
private static SecurityContext load(HttpServletRequest request) {
SecurityContext empty = SecurityContextHolder.createEmptyContext();
HttpSession session = request.getSession(false);
if (session == null) {
return empty;
}
Object stored = session.getAttribute(SNAPSHOT_ATTRIBUTE);
if (!(stored instanceof byte[] snapshot)) {
return empty;
}
try {
PrimitiveAuthentication decoded = decode(snapshot);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal(decoded.principalId, decoded.email, decoded.roles);
List<GrantedAuthority> authorities =
decoded.authorities.stream()
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast)
.toList();
empty.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
return empty;
} catch (IllegalArgumentException exception) {
session.removeAttribute(SNAPSHOT_ATTRIBUTE);
return empty;
}
}
private static byte[] encode(Authentication authentication) {
if (!(authentication.getPrincipal() instanceof AuthenticatedPrincipal principal)) {
throw new IllegalArgumentException(
"redis-session authentication requires an AuthenticatedPrincipal");
}
Set<String> roles = boundedTokens(principal.roles(), MAXIMUM_ROLES, "roles");
Set<String> authorities =
boundedTokens(
authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(),
MAXIMUM_AUTHORITIES,
"authorities");
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (DataOutputStream output = new DataOutputStream(bytes)) {
output.writeInt(MAGIC);
output.writeByte(VERSION);
writeText(output, principal.idpUserId(), MAXIMUM_PRINCIPAL_BYTES, "principal ID");
writeNullableText(output, principal.email(), MAXIMUM_EMAIL_BYTES, "email");
writeTokens(output, roles);
writeTokens(output, authorities);
}
byte[] snapshot = bytes.toByteArray();
if (snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
throw new IllegalArgumentException("security context snapshot exceeds the byte bound");
}
return snapshot;
} catch (IOException exception) {
throw new IllegalStateException("in-memory security context encoding failed", exception);
}
}
private static PrimitiveAuthentication decode(byte[] snapshot) {
if (snapshot.length < 1 || snapshot.length > MAXIMUM_SNAPSHOT_BYTES) {
throw invalidSnapshot();
}
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(snapshot.clone()))) {
if (input.readInt() != MAGIC || input.readUnsignedByte() != VERSION) {
throw invalidSnapshot();
}
String principalId = readText(input, MAXIMUM_PRINCIPAL_BYTES);
String email = readNullableText(input, MAXIMUM_EMAIL_BYTES);
Set<String> roles = readTokens(input, MAXIMUM_ROLES);
Set<String> authorities = readTokens(input, MAXIMUM_AUTHORITIES);
if (input.available() != 0) {
throw invalidSnapshot();
}
return new PrimitiveAuthentication(principalId, email, roles, authorities);
} catch (IOException | IllegalArgumentException exception) {
throw invalidSnapshot();
}
}
private static void writeTokens(DataOutputStream output, Set<String> values) throws IOException {
output.writeInt(values.size());
for (String value : values) {
writeText(output, value, MAXIMUM_TOKEN_BYTES, "security token");
}
}
private static Set<String> readTokens(DataInputStream input, int maximumCount)
throws IOException {
int count = input.readInt();
if (count < 0 || count > maximumCount) {
throw invalidSnapshot();
}
Set<String> values = new LinkedHashSet<>();
for (int index = 0; index < count; index++) {
if (!values.add(readText(input, MAXIMUM_TOKEN_BYTES))) {
throw invalidSnapshot();
}
}
return Set.copyOf(values);
}
private static Set<String> boundedTokens(
Collection<String> values, int maximumCount, String field) {
if (values == null || values.size() > maximumCount) {
throw new IllegalArgumentException(field + " exceed the configured count bound");
}
TreeSet<String> bounded = new TreeSet<>();
for (String value : values) {
requireBoundedText(value, MAXIMUM_TOKEN_BYTES, field);
bounded.add(value);
}
return Set.copyOf(bounded);
}
private static void writeNullableText(
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
output.writeBoolean(value != null);
if (value != null) {
writeText(output, value, maximumBytes, field);
}
}
private static String readNullableText(DataInputStream input, int maximumBytes)
throws IOException {
return input.readBoolean() ? readText(input, maximumBytes) : null;
}
private static void writeText(
DataOutputStream output, String value, int maximumBytes, String field) throws IOException {
byte[] encoded = requireBoundedText(value, maximumBytes, field);
output.writeInt(encoded.length);
output.write(encoded);
}
private static String readText(DataInputStream input, int maximumBytes) throws IOException {
int length = input.readInt();
if (length < 1 || length > maximumBytes || length > input.available()) {
throw new EOFException("invalid security context text length");
}
byte[] encoded = input.readNBytes(length);
String value = new String(encoded, StandardCharsets.UTF_8);
byte[] canonical = requireBoundedText(value, maximumBytes, "decoded value");
if (!java.util.Arrays.equals(canonical, encoded)) {
throw invalidSnapshot();
}
return value;
}
private static byte[] requireBoundedText(String value, int maximumBytes, String field) {
if (value == null || value.isBlank() || value.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(field + " must be non-blank text without controls");
}
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
if (encoded.length > maximumBytes) {
throw new IllegalArgumentException(field + " exceeds the UTF-8 byte bound");
}
return encoded;
}
private static IllegalArgumentException invalidSnapshot() {
return new IllegalArgumentException("security context snapshot is corrupt or incompatible");
}
private static final class PrimitiveAuthentication {
private final String principalId;
private final String email;
private final Set<String> roles;
private final Set<String> authorities;
private PrimitiveAuthentication(
String principalId, String email, Set<String> roles, Set<String> authorities) {
this.principalId = principalId;
this.email = email;
this.roles = roles;
this.authorities = authorities;
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;
/** Provider-neutral servlet session filter and hardened host-only cookie composition. */
@Configuration(proxyBeanMethods = false)
@EnableSpringHttpSession
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "redis-session",
matchIfMissing = false)
public class RedisSessionWebConfig {
@Bean
CookieSerializer sessionCookieSerializer(SecuritySettings settings) {
SecuritySettings.SessionCookieSettings policy = settings.session();
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName(policy.cookieName());
serializer.setUseSecureCookie(policy.secure());
serializer.setUseHttpOnlyCookie(policy.httpOnly());
serializer.setSameSite(policy.sameSite());
serializer.setCookiePath(policy.path());
serializer.setCookieMaxAge(-1);
serializer.setUseBase64Encoding(true);
// No domain or domain pattern is configured: the session cookie remains host-only.
return serializer;
}
}
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.inbound.web.auth;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -10,6 +11,8 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@@ -49,19 +52,28 @@ public class SecurityConfig {
return new EnvelopeAccessDeniedHandler(classifier, objectMapper);
}
@Bean
@ConditionalOnProperty(
name = "ca-skeleton.security.auth-mode",
havingValue = "redis-session",
matchIfMissing = false)
PrimitiveSessionSecurityContextRepository primitiveSessionSecurityContextRepository() {
return new PrimitiveSessionSecurityContextRepository();
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
AuthenticationEntryPoint authenticationEntryPoint,
AccessDeniedHandler accessDeniedHandler)
AccessDeniedHandler accessDeniedHandler,
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
sessionSecurityContextRepository)
throws Exception {
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
http.csrf(csrf -> csrf.disable())
.cors(c -> c.configurationSource(corsConfigurationSource()))
http.cors(c -> c.configurationSource(corsConfigurationSource()))
// Disable Spring Security's default Cache-Control writer; CacheControlFilter
// owns the cache header policy. See README for the design rationale.
.headers(headers -> headers.cacheControl(cache -> cache.disable()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
auth -> {
if (publicPaths.length > 0) {
@@ -75,13 +87,45 @@ public class SecurityConfig {
.exceptionHandling(
ex ->
ex.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.oauth2ResourceServer(
oauth ->
oauth
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
.accessDeniedHandler(accessDeniedHandler));
if (securitySettings.authMode() == SecuritySettings.AuthenticationMode.JWT) {
http.csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(
oauth ->
oauth
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
} else {
SecuritySettings.SessionCookieSettings sessionSettings = securitySettings.session();
CookieCsrfTokenRepository csrfRepository = new CookieCsrfTokenRepository();
csrfRepository.setCookieName(sessionSettings.csrfCookieName());
csrfRepository.setHeaderName(sessionSettings.csrfHeaderName());
csrfRepository.setCookieCustomizer(
cookie ->
cookie
.secure(true)
.httpOnly(false)
.sameSite(sessionSettings.sameSite())
.path(sessionSettings.path()));
CsrfTokenRequestAttributeHandler csrfRequestHandler = new CsrfTokenRequestAttributeHandler();
http.csrf(
csrf ->
csrf.csrfTokenRepository(csrfRepository)
.csrfTokenRequestHandler(csrfRequestHandler))
.sessionManagement(
session ->
session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(fixation -> fixation.migrateSession()))
.securityContext(
securityContext ->
securityContext
.securityContextRepository(sessionSecurityContextRepository.getObject())
.requireExplicitSave(false));
}
return http.build();
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Provider-neutral bridge from an HTTP request to {@link EdgeRateLimitPort}.
*
* <p>Raw principal, API-key identity, client IP, and route values stop at the pseudonymizer. Only
* the versioned digest and bounded enforcement metadata cross the provider boundary.
*/
public final class EdgeRateLimitTransportBridge {
private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}");
private static final Duration MAXIMUM_CALLER_DEADLINE_BUDGET = Duration.ofSeconds(30);
private final EdgeRateLimitPort port;
private final EdgeSubjectPseudonymizer pseudonymizer;
private final RateLimitKeyResolver subjectResolver;
private final Clock clock;
private final String policyId;
private final Duration callerDeadlineBudget;
private final RateLimitEvaluationIdGenerator evaluationIdGenerator;
public EdgeRateLimitTransportBridge(
EdgeRateLimitPort port,
EdgeSubjectPseudonymizer pseudonymizer,
RateLimitKeyResolver subjectResolver,
Clock clock,
String policyId,
Duration callerDeadlineBudget,
RateLimitEvaluationIdGenerator evaluationIdGenerator) {
this.port = Objects.requireNonNull(port, "port must not be null");
this.pseudonymizer = Objects.requireNonNull(pseudonymizer, "pseudonymizer must not be null");
this.subjectResolver =
Objects.requireNonNull(subjectResolver, "subjectResolver must not be null");
this.clock = Objects.requireNonNull(clock, "clock must not be null");
if (policyId == null || !POLICY_ID.matcher(policyId).matches()) {
throw new IllegalArgumentException("policyId must be a bounded policy identifier");
}
this.policyId = policyId;
this.callerDeadlineBudget = positiveBoundedBudget(callerDeadlineBudget, "callerDeadlineBudget");
this.evaluationIdGenerator =
Objects.requireNonNull(evaluationIdGenerator, "evaluationIdGenerator must not be null");
}
public RateLimitOutcome evaluate(HttpServletRequest request) {
Objects.requireNonNull(request, "request must not be null");
EdgeRateLimitSubject rawSubject = subjectResolver.resolve(request);
RateLimitSubjectDigest subjectDigest =
Objects.requireNonNull(
pseudonymizer.pseudonymize(rawSubject), "pseudonymizer must return a subject digest");
Instant callerDeadline = clock.instant().plus(callerDeadlineBudget);
String evaluationId =
Objects.requireNonNull(
evaluationIdGenerator.generate(), "evaluationIdGenerator must return an evaluation ID");
return Objects.requireNonNull(
port.evaluate(
new RateLimitRequest(policyId, subjectDigest, 1, evaluationId, callerDeadline)),
"rate-limit port must return an outcome");
}
static Duration positiveBoundedBudget(Duration value, String field) {
Objects.requireNonNull(value, field + " must not be null");
if (value.isZero()
|| value.isNegative()
|| value.compareTo(MAXIMUM_CALLER_DEADLINE_BUDGET) > 0) {
throw new IllegalArgumentException(field + " must be positive and no more than 30 seconds");
}
long milliseconds = value.toMillis();
if (!Duration.ofMillis(milliseconds).equals(value)) {
throw new IllegalArgumentException(field + " must use whole milliseconds");
}
return value;
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Duration;
import java.util.regex.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* HTTP bridge settings bound to the transport-only {@code app.rate-limit} axis.
*
* <p>The outbound provider is selected independently by {@code
* ca-skeleton.capabilities.rate-limit.provider}; enabling this bridge never selects a provider or a
* fallback.
*/
@ConfigurationProperties(prefix = "app.rate-limit")
public record EdgeRateLimitTransportSettings(
boolean enabled,
String defaultPolicyId,
Duration callerDeadlineBudget,
int hashKeyVersion,
RateLimitClientIpMode clientIpMode) {
private static final Pattern POLICY_ID = Pattern.compile("[a-z][a-z0-9-]{0,62}");
public EdgeRateLimitTransportSettings {
defaultPolicyId =
defaultPolicyId == null || defaultPolicyId.isBlank() ? "api-default" : defaultPolicyId;
callerDeadlineBudget =
callerDeadlineBudget == null ? Duration.ofSeconds(2) : callerDeadlineBudget;
hashKeyVersion = hashKeyVersion == 0 ? 1 : hashKeyVersion;
clientIpMode = clientIpMode == null ? RateLimitClientIpMode.REMOTE_ADDR_ONLY : clientIpMode;
if (!POLICY_ID.matcher(defaultPolicyId).matches()) {
throw new IllegalArgumentException("defaultPolicyId must be a bounded policy identifier");
}
EdgeRateLimitTransportBridge.positiveBoundedBudget(
callerDeadlineBudget, "callerDeadlineBudget");
if (hashKeyVersion < 1 || hashKeyVersion > 9999) {
throw new IllegalArgumentException("hashKeyVersion must be in 1..9999");
}
}
}
@@ -1,54 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Single-node, in-process fixed-window rate limiter. Each key gets a counter for the current window
* {@code floor(epochSecond / window)}; the counter resets when the window rolls. See README for the
* design rationale.
*/
public final class FixedWindowRateLimiter implements RateLimiter {
private final int limit;
private final long windowSeconds;
private final Clock clock;
private final ConcurrentMap<String, Window> windows = new ConcurrentHashMap<>();
public FixedWindowRateLimiter(int limit, Duration window, Clock clock) {
this.limit = Math.max(1, limit);
this.windowSeconds = Math.max(1L, window.toSeconds());
this.clock = clock;
}
@Override
public RateLimitDecision decide(String key) {
long nowSecond = clock.instant().getEpochSecond();
long windowId = nowSecond / windowSeconds;
Instant resetAt = Instant.ofEpochSecond((windowId + 1) * windowSeconds);
Window window =
windows.compute(
key,
(k, current) ->
(current == null || current.id != windowId) ? new Window(windowId) : current);
int count = window.count.incrementAndGet();
boolean allowed = count <= limit;
int remaining = Math.max(0, limit - count);
return new RateLimitDecision(allowed, limit, remaining, resetAt);
}
private static final class Window {
private final long id;
private final AtomicInteger count = new AtomicInteger();
private Window(long id) {
this.id = id;
}
}
}
@@ -1,11 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/**
* Selectable rate-limit algorithm, bound from {@code ca-skeleton.rate-limit.algorithm}. See README
* for the design rationale.
*/
public enum RateLimitAlgorithm {
/** Fixed-window counter — the default single-node implementation. */
FIXED_WINDOW
}
@@ -1,15 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Instant;
/**
* Outcome of a single rate-limit check, carrying the values surfaced as the {@code X-RateLimit-*}
* signaling headers. See README for the design rationale.
*
* @param allowed false when the caller has exceeded the limit this window (→ 429)
* @param limit the window quota ({@code X-RateLimit-Limit})
* @param remaining requests left in the current window, floored at 0 ({@code
* X-RateLimit-Remaining})
* @param resetAt instant the current fixed window ends ({@code X-RateLimit-Reset}, rfc3339)
*/
public record RateLimitDecision(boolean allowed, int limit, int remaining, Instant resetAt) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/** Server-owned source of per-evaluation replay identifiers. */
@FunctionalInterface
public interface RateLimitEvaluationIdGenerator {
String generate();
}
@@ -2,73 +2,111 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.response.Envelope;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.HandlerInterceptor;
import tools.jackson.databind.ObjectMapper;
/**
* Applies the rate limit before a mapped handler runs. Every response carries the {@code
* X-RateLimit-*} signaling headers; when the limit is exceeded the request is rejected with a 429
* {@code RATE_LIMIT_EXCEEDED} envelope, a {@code Retry-After} header, and the signaling headers.
* See README for the design rationale.
* Maps provider-neutral rate-limit outcomes to the stable HTTP signaling contract.
*
* <p>Disabled instances have no bridge and therefore cannot resolve a subject, pseudonymize, or
* invoke a provider.
*/
public final class RateLimitInterceptor implements HandlerInterceptor {
private static final String CLIENT_SAFE_MESSAGE =
private static final String DENIED_MESSAGE =
"Too many requests, please retry after the indicated interval";
private static final String UNAVAILABLE_MESSAGE =
"Rate-limit enforcement is temporarily unavailable";
private static final String INCOMPATIBLE_MESSAGE =
"Rate-limit enforcement is unavailable due to an incompatible provider";
private final boolean enabled;
private final RateLimiter limiter;
private final RateLimitKeyResolver keyResolver;
private final EdgeRateLimitTransportBridge bridge;
private final ObjectMapper objectMapper;
private final int retryAfterSeconds;
public RateLimitInterceptor(
boolean enabled,
RateLimiter limiter,
RateLimitKeyResolver keyResolver,
ObjectMapper objectMapper,
int retryAfterSeconds) {
this.enabled = enabled;
this.limiter = limiter;
this.keyResolver = keyResolver;
this.objectMapper = objectMapper;
this.retryAfterSeconds = retryAfterSeconds;
private RateLimitInterceptor(EdgeRateLimitTransportBridge bridge, ObjectMapper objectMapper) {
this.bridge = bridge;
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
}
public static RateLimitInterceptor enabled(
EdgeRateLimitTransportBridge bridge, ObjectMapper objectMapper) {
return new RateLimitInterceptor(
Objects.requireNonNull(bridge, "bridge must not be null"), objectMapper);
}
public static RateLimitInterceptor disabled(ObjectMapper objectMapper) {
return new RateLimitInterceptor(null, objectMapper);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (!enabled) {
if (bridge == null) {
return true;
}
RateLimitDecision decision = limiter.decide(keyResolver.resolve(request));
return switch (bridge.evaluate(request)) {
case RateLimitOutcome.Evaluated evaluated -> handleEvaluated(response, evaluated.decision());
case RateLimitOutcome.Unavailable unavailable ->
rejectUnavailable(response, unavailable.retryAfter());
case RateLimitOutcome.Indeterminate indeterminate ->
rejectUnavailable(response, indeterminate.retryAfter());
case RateLimitOutcome.Incompatible incompatible -> rejectIncompatible(response);
};
}
private boolean handleEvaluated(HttpServletResponse response, RateLimitDecision decision)
throws Exception {
applySignalingHeaders(response, decision);
if (decision.allowed()) {
return true;
}
rejectWith429(response);
response.setHeader(ApiHeaders.RETRY_AFTER, retryAfterSeconds(decision.retryAfter()));
reject(response, OperationalError.RATE_LIMIT_EXCEEDED, DENIED_MESSAGE);
return false;
}
private void applySignalingHeaders(HttpServletResponse response, RateLimitDecision decision) {
response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Integer.toString(decision.limit()));
response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Integer.toString(decision.remaining()));
private boolean rejectUnavailable(HttpServletResponse response, Duration retryAfter)
throws Exception {
response.setHeader(ApiHeaders.RETRY_AFTER, retryAfterSeconds(retryAfter));
reject(response, RateLimitTransportError.RATE_LIMIT_UNAVAILABLE, UNAVAILABLE_MESSAGE);
return false;
}
private boolean rejectIncompatible(HttpServletResponse response) throws Exception {
reject(response, RateLimitTransportError.RATE_LIMIT_INCOMPATIBLE, INCOMPATIBLE_MESSAGE);
return false;
}
private static void applySignalingHeaders(
HttpServletResponse response, RateLimitDecision decision) {
response.setHeader(ApiHeaders.X_RATELIMIT_LIMIT, Long.toString(decision.limit()));
response.setHeader(ApiHeaders.X_RATELIMIT_REMAINING, Long.toString(decision.remaining()));
response.setHeader(
ApiHeaders.X_RATELIMIT_RESET, DateTimeFormatter.ISO_INSTANT.format(decision.resetAt()));
}
private void rejectWith429(HttpServletResponse response) throws Exception {
response.setStatus(OperationalError.RATE_LIMIT_EXCEEDED.httpStatus());
response.setHeader(ApiHeaders.RETRY_AFTER, Integer.toString(retryAfterSeconds));
private void reject(HttpServletResponse response, ApiErrorCode error, String message)
throws Exception {
response.setStatus(error.httpStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Envelope<Void> body =
ErrorResponseFactory.body(OperationalError.RATE_LIMIT_EXCEEDED, CLIENT_SAFE_MESSAGE, null);
Envelope<Void> body = ErrorResponseFactory.body(error, message, null);
objectMapper.writeValue(response.getWriter(), body);
}
private static String retryAfterSeconds(Duration retryAfter) {
long milliseconds = retryAfter.toMillis();
long seconds = Math.floorDiv(milliseconds + 999, 1000);
return Long.toString(seconds);
}
}
@@ -1,21 +1,24 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerMapping;
/**
* Derives the rate-limit key from a request:
* Derives a bounded pre-pseudonymization rate-limit subject from a request:
*
* <ul>
* <li>authenticated user → {@code user:<principal>}
* <li>service-to-service (a {@code service}-role principal) → {@code apikey:<id>}
* <li>unauthenticated → {@code ip:<source-ip>:<METHOD route-template>}
* <li>authenticated user → principal + operation
* <li>service-to-service (a {@code service}-role principal) → API key + operation
* <li>unauthenticated → client IP + operation
* </ul>
*
* <p>See README for the design rationale.
* <p>The returned raw identity exists only until {@link EdgeSubjectPseudonymizer} runs. It must not
* cross the provider port boundary.
*/
public final class RateLimitKeyResolver {
@@ -27,19 +30,37 @@ public final class RateLimitKeyResolver {
this.clientIpResolver = clientIpResolver;
}
public String resolve(HttpServletRequest request) {
public EdgeRateLimitSubject resolve(HttpServletRequest request) {
String operationId = operationId(request);
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null
&& auth.isAuthenticated()
&& auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
return user.hasRole(SERVICE_ROLE) ? "apikey:" + user.idpUserId() : "user:" + user.idpUserId();
EdgeRateLimitSubject.Kind kind =
user.hasRole(SERVICE_ROLE)
? EdgeRateLimitSubject.Kind.API_KEY
: EdgeRateLimitSubject.Kind.PRINCIPAL;
return new EdgeRateLimitSubject(kind, user.idpUserId(), operationId);
}
return "ip:" + clientIpResolver.resolve(request) + ":" + routeTemplate(request);
return new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP, clientIpResolver.resolve(request), operationId);
}
private static String routeTemplate(HttpServletRequest request) {
private static String operationId(HttpServletRequest request) {
String method = normalizedMethod(request.getMethod());
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String route = pattern instanceof String s ? s : request.getRequestURI();
return request.getMethod() + " " + route;
String route =
pattern instanceof String value && !value.isBlank() ? value : "<unresolved-route>";
return method + " " + route;
}
private static String normalizedMethod(String method) {
if (method == null
|| method.isBlank()
|| method.length() > 16
|| !method.chars().allMatch(Character::isLetter)) {
return "OTHER";
}
return method.toUpperCase(Locale.ROOT);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/** HTTP-only mapping codes for provider outcomes that do not contain an allow/deny decision. */
enum RateLimitTransportError implements ApiErrorCode {
RATE_LIMIT_UNAVAILABLE(Category.TRANSIENT_DEPENDENCY, true),
RATE_LIMIT_INCOMPATIBLE(Category.INTERNAL, false);
private final Category category;
private final boolean retryable;
RateLimitTransportError(Category category, boolean retryable) {
this.category = category;
this.retryable = retryable;
}
@Override
public String code() {
return name();
}
@Override
public Category category() {
return category;
}
@Override
public int httpStatus() {
return 503;
}
@Override
public boolean retryable() {
return retryable;
}
}
@@ -1,8 +1,7 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.adapter.inbound.web.observability.RetryAfterAdvisor;
import dev.caskeleton.adapter.inbound.web.settings.RateLimitSettings;
import dev.caskeleton.shared.error.OperationalError;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import java.time.Clock;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -12,39 +11,57 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import tools.jackson.databind.ObjectMapper;
/**
* Wires the {@link RateLimitInterceptor} into the MVC interceptor chain. The {@link Clock} is taken
* from the shared application bean when present and falls back to {@link Clock#systemUTC()}. With
* no rate-limit config bound, {@code enabled} defaults to {@code false} and the interceptor is a
* pass-through. See README for the design rationale.
* Wires provider-neutral edge enforcement into MVC.
*
* <p>Transport activation, trusted client-IP selection, policy selection, and deadlines come from
* {@code app.rate-limit}. Provider activation is a separate composition-root decision; an enabled
* bridge requires exactly one semantic port and never installs a local fallback.
*/
@Configuration
@EnableConfigurationProperties(RateLimitSettings.class)
@EnableConfigurationProperties(EdgeRateLimitTransportSettings.class)
public class RateLimitWebConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimitInterceptor;
public RateLimitWebConfig(
RateLimitSettings properties, ObjectMapper objectMapper, ObjectProvider<Clock> clock) {
RateLimiter limiter =
RateLimiterFactory.create(
properties.algorithm(),
properties.limit(),
properties.window(),
clock.getIfAvailable(Clock::systemUTC));
int retryAfter =
RetryAfterAdvisor.retryAfterSeconds(OperationalError.RATE_LIMIT_EXCEEDED).orElse(1);
ClientIpResolver clientIpResolver = ClientIpResolverFactory.create(properties.clientIpMode());
this.rateLimitInterceptor =
new RateLimitInterceptor(
properties.enabled(),
limiter,
new RateLimitKeyResolver(clientIpResolver),
objectMapper,
retryAfter);
EdgeRateLimitTransportSettings transportSettings,
ObjectMapper objectMapper,
ObjectProvider<Clock> clockProvider,
ObjectProvider<EdgeRateLimitPort> portProvider,
ObjectProvider<UserPrincipalPseudonymizerPort> pseudonymizerProvider) {
if (!transportSettings.enabled()) {
this.rateLimitInterceptor = RateLimitInterceptor.disabled(objectMapper);
return;
}
EdgeRateLimitPort port = requiredUnique(portProvider, "EdgeRateLimitPort");
UserPrincipalPseudonymizerPort secretBackedPseudonymizer =
requiredUnique(pseudonymizerProvider, "UserPrincipalPseudonymizerPort");
EdgeRateLimitTransportBridge bridge =
new EdgeRateLimitTransportBridge(
port,
new VersionedEdgeSubjectPseudonymizer(
secretBackedPseudonymizer, transportSettings.hashKeyVersion()),
new RateLimitKeyResolver(
ClientIpResolverFactory.create(transportSettings.clientIpMode())),
clockProvider.getIfAvailable(Clock::systemUTC),
transportSettings.defaultPolicyId(),
transportSettings.callerDeadlineBudget(),
SecureRandomRateLimitEvaluationIdGenerator.versionOne());
this.rateLimitInterceptor = RateLimitInterceptor.enabled(bridge, objectMapper);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor);
}
private static <T> T requiredUnique(ObjectProvider<T> provider, String capability) {
T instance = provider.getIfUnique();
if (instance == null) {
throw new IllegalStateException(
capability + " must have exactly one bean when edge rate limiting is enabled");
}
return instance;
}
}
@@ -1,12 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
/**
* Rate-limit strategy. Implementations populate {@link RateLimitDecision} so the {@code
* X-RateLimit-*} header contract stays stable across a strategy swap. See README for the design
* rationale and the algorithm-neutral output contract.
*/
public interface RateLimiter {
/** Register one request for {@code key} and report whether it is within the limit. */
RateLimitDecision decide(String key);
}
@@ -1,17 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.time.Clock;
import java.time.Duration;
/** Builds the configured {@link RateLimiter} strategy. See README for the design rationale. */
public final class RateLimiterFactory {
private RateLimiterFactory() {}
public static RateLimiter create(
RateLimitAlgorithm algorithm, int limit, Duration window, Clock clock) {
return switch (algorithm) {
case FIXED_WINDOW -> new FixedWindowRateLimiter(limit, window, clock);
};
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
/**
* Cryptographically random evaluation ID generator.
*
* <p>IDs are created only by the server. HTTP headers and request bodies are never consulted.
*/
public final class SecureRandomRateLimitEvaluationIdGenerator
implements RateLimitEvaluationIdGenerator {
private static final int RANDOM_BYTES = 16;
private final SecureRandom secureRandom;
private final String prefix;
public SecureRandomRateLimitEvaluationIdGenerator(SecureRandom secureRandom, int version) {
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom must not be null");
if (version < 1 || version > 9999) {
throw new IllegalArgumentException("evaluation ID version must be in 1..9999");
}
this.prefix = "ev" + version + ":";
}
public static SecureRandomRateLimitEvaluationIdGenerator versionOne() {
return new SecureRandomRateLimitEvaluationIdGenerator(new SecureRandom(), 1);
}
@Override
public String generate() {
byte[] random = new byte[RANDOM_BYTES];
secureRandom.nextBytes(random);
return prefix + Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* Adapts the application-provided secret-backed HMAC capability to the edge subject contract.
*
* <p>The adapter length-frames each dimension before hashing and adds an explicit key-rotation
* version to the resulting digest. It does not resolve or retain the HMAC secret.
*/
final class VersionedEdgeSubjectPseudonymizer implements EdgeSubjectPseudonymizer {
private final UserPrincipalPseudonymizerPort delegate;
private final int version;
VersionedEdgeSubjectPseudonymizer(UserPrincipalPseudonymizerPort delegate, int version) {
this.delegate = Objects.requireNonNull(delegate, "delegate must not be null");
if (version < 1 || version > 9999) {
throw new IllegalArgumentException("subject digest version must be in 1..9999");
}
this.version = version;
}
@Override
public RateLimitSubjectDigest pseudonymize(EdgeRateLimitSubject subject) {
Objects.requireNonNull(subject, "subject must not be null");
String canonical =
frame(subject.kind().name())
+ "|"
+ frame(subject.canonicalIdentity())
+ "|"
+ frame(subject.operationId());
String digest = delegate.pseudonymize(canonical);
return new RateLimitSubjectDigest("v" + version + ":" + digest);
}
private static String frame(String value) {
return value.getBytes(StandardCharsets.UTF_8).length + ":" + value;
}
}
@@ -1,42 +0,0 @@
package dev.caskeleton.adapter.inbound.web.settings;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Rate-limit knobs bound from {@code ca-skeleton.rate-limit.*}. See README for the design
* rationale.
*
* @param enabled whether the rate-limit interceptor enforces limits
* @param limit max requests allowed per key within one window
* @param window the fixed time window over which {@code limit} is counted
* @param algorithm the rate-limit strategy to use
* @param clientIpMode client-IP source for unauthenticated rate-limit keys
*/
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.rate-limit")
public record RateLimitSettings(
boolean enabled,
Integer limit,
Duration window,
RateLimitAlgorithm algorithm,
RateLimitClientIpMode clientIpMode) {
public RateLimitSettings {
if (limit == null || limit < 1) {
limit = 100;
}
if (window == null || window.isZero() || window.isNegative()) {
window = Duration.ofSeconds(1);
}
if (algorithm == null) {
algorithm = RateLimitAlgorithm.FIXED_WINDOW;
}
if (clientIpMode == null) {
clientIpMode = RateLimitClientIpMode.REMOTE_ADDR_ONLY;
}
}
}
@@ -4,29 +4,107 @@ import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* OIDC resource-server config bound from {@code ca-skeleton.security.*}. See README for the design
* rationale.
* Exclusive JWT or Redis-backed browser-session security policy bound from {@code
* ca-skeleton.security.*}.
*/
@ConfigurationProperties(prefix = "ca-skeleton.security")
public record SecuritySettings(String issuerUri, String audience, List<String> publicPaths) {
public record SecuritySettings(
AuthenticationMode authMode,
String issuerUri,
String audience,
List<String> publicPaths,
SessionCookieSettings session) {
private static final Logger log = LoggerFactory.getLogger(SecuritySettings.class);
public SecuritySettings {
if (issuerUri == null || issuerUri.isBlank()) {
@ConstructorBinding
public SecuritySettings(
AuthenticationMode authMode,
String issuerUri,
String audience,
List<String> publicPaths,
SessionCookieSettings session) {
this.authMode = authMode == null ? AuthenticationMode.JWT : authMode;
if (this.authMode == AuthenticationMode.JWT && (issuerUri == null || issuerUri.isBlank())) {
throw new IllegalArgumentException(
"APP_SECURITY_JWT_ISSUER (ca-skeleton.security.issuer-uri) is required");
}
this.issuerUri = issuerUri == null ? "" : issuerUri.trim();
if (audience == null) {
log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation");
audience = "";
if (this.authMode == AuthenticationMode.JWT) {
log.warn("APP_SECURITY_JWT_AUDIENCE is missing; skipping audience validation");
}
this.audience = "";
} else {
this.audience = audience.trim();
}
if (publicPaths == null) {
publicPaths = List.of();
this.publicPaths = List.of();
} else {
publicPaths = List.copyOf(publicPaths);
this.publicPaths = List.copyOf(publicPaths);
}
this.session = session == null ? SessionCookieSettings.defaults() : session;
}
public SecuritySettings(String issuerUri, String audience, List<String> publicPaths) {
this(AuthenticationMode.JWT, issuerUri, audience, publicPaths, null);
}
public enum AuthenticationMode {
JWT,
REDIS_SESSION
}
public record SessionCookieSettings(
String cookieName,
Boolean secure,
Boolean httpOnly,
String sameSite,
String path,
String csrfCookieName,
String csrfHeaderName) {
public SessionCookieSettings(
String cookieName,
Boolean secure,
Boolean httpOnly,
String sameSite,
String path,
String csrfCookieName,
String csrfHeaderName) {
this.cookieName = safeName(cookieName, "CA_SESSION", "cookieName");
this.secure = secure == null || secure;
this.httpOnly = httpOnly == null || httpOnly;
this.sameSite = sameSite == null || sameSite.isBlank() ? "Lax" : sameSite;
if (!this.sameSite.matches("Lax|Strict|None")) {
throw new IllegalArgumentException("session sameSite must be Lax, Strict, or None");
}
this.path = path == null || path.isBlank() ? "/" : path;
if (!this.path.startsWith("/")
|| this.path.length() > 128
|| this.path.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("session cookie path must be a bounded absolute path");
}
this.csrfCookieName = safeName(csrfCookieName, "XSRF-TOKEN", "csrfCookieName");
this.csrfHeaderName = safeName(csrfHeaderName, "X-XSRF-TOKEN", "csrfHeaderName");
if (!this.secure || !this.httpOnly) {
throw new IllegalArgumentException("Redis session cookie must remain Secure and HttpOnly");
}
}
private static SessionCookieSettings defaults() {
return new SessionCookieSettings(null, null, null, null, null, null, null);
}
private static String safeName(String value, String fallback, String field) {
String resolved = value == null || value.isBlank() ? fallback : value;
if (!resolved.matches("[A-Za-z][A-Za-z0-9_-]{1,63}")) {
throw new IllegalArgumentException(field + " must be a bounded cookie/header token");
}
return resolved;
}
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpRequestResponseHolder;
class PrimitiveSessionSecurityContextRepositoryTest {
private final PrimitiveSessionSecurityContextRepository repository =
new PrimitiveSessionSecurityContextRepository();
@Test
void roundTripsOnlyABoundedPrimitiveSnapshotWithoutCredentialsOrFrameworkObjects() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"idp-user-42", "user@example.test", Set.of("operator", "auditor")),
"must-never-be-stored",
Set.of(
new SimpleGrantedAuthority("ROLE_OPERATOR"),
new SimpleGrantedAuthority("worklog:read"))));
repository.saveContext(context, request, response);
Object stored =
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE);
assertThat(stored).isInstanceOf(byte[].class);
assertThat(request.getSession(false).getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
var loaded =
repository
.loadContext(new HttpRequestResponseHolder(request, response))
.getAuthentication();
assertThat(loaded.getCredentials()).isNull();
assertThat(loaded.getPrincipal())
.isEqualTo(
new AuthenticatedPrincipal(
"idp-user-42", "user@example.test", Set.of("operator", "auditor")));
assertThat(loaded.getAuthorities())
.extracting(authority -> authority.getAuthority())
.containsExactlyInAnyOrder("ROLE_OPERATOR", "worklog:read");
}
@Test
void rejectsForeignPrincipalGraphsAndFailsClosedOnCorruptSnapshots() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
var foreign = SecurityContextHolder.createEmptyContext();
foreign.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(new Object(), "credential", Set.of()));
assertThatThrownBy(() -> repository.saveContext(foreign, request, response))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("AuthenticatedPrincipal");
request
.getSession(true)
.setAttribute(
PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE,
new byte[] {0x01, 0x02, 0x03});
assertThat(
repository
.loadContext(new HttpRequestResponseHolder(request, response))
.getAuthentication())
.isNull();
assertThat(
request
.getSession(false)
.getAttribute(PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isNull();
}
@Test
void rejectsAuthorityCountsBeyondThePublishedBound() {
MockHttpServletRequest request = new MockHttpServletRequest();
var authorities =
IntStream.range(0, 129)
.mapToObj(index -> new SimpleGrantedAuthority("authority-" + index))
.toList();
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal("idp-user-42", null, Set.of()), null, authorities));
assertThatThrownBy(
() -> repository.saveContext(context, request, new MockHttpServletResponse()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("authorities");
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.session.MapSessionRepository;
import org.springframework.session.web.http.CookieSerializer;
class RedisSessionWebConfigTest {
private final WebApplicationContextRunner runner =
new WebApplicationContextRunner()
.withUserConfiguration(PropertiesConfig.class, RedisSessionWebConfig.class);
@Test
void jwtModeCreatesNoSessionFilterOrCookieSerializer() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=jwt",
"ca-skeleton.security.issuer-uri=https://issuer.example")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(CookieSerializer.class);
assertThat(context).doesNotHaveBean("springSessionRepositoryFilter");
});
}
@Test
void redisSessionModeWritesSecureHttpOnlySameSiteHostOnlyCookie() {
runner
.withBean(
MapSessionRepository.class, () -> new MapSessionRepository(new ConcurrentHashMap<>()))
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.session.cookie-name=APP_SESSION",
"ca-skeleton.security.session.secure=true",
"ca-skeleton.security.session.http-only=true",
"ca-skeleton.security.session.same-site=Strict",
"ca-skeleton.security.session.path=/")
.run(
context -> {
assertThat(context).hasNotFailed();
CookieSerializer serializer = context.getBean(CookieSerializer.class);
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSecure(true);
MockHttpServletResponse response = new MockHttpServletResponse();
serializer.writeCookieValue(
new CookieSerializer.CookieValue(request, response, "opaque-session-id"));
assertThat(response.getHeader("Set-Cookie"))
.contains("APP_SESSION=")
.contains("Path=/")
.contains("Secure")
.contains("HttpOnly")
.contains("SameSite=Strict")
.doesNotContain("Domain=");
});
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SecuritySettings.class)
static class PropertiesConfig {}
}
@@ -0,0 +1,199 @@
package dev.caskeleton.adapter.inbound.web.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import jakarta.servlet.Filter;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.session.SessionManagementFilter;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.ObjectMapper;
class SecurityModeWebContractTest {
private final WebApplicationContextRunner runner =
new WebApplicationContextRunner()
.withUserConfiguration(PropertiesConfig.class, SecurityConfig.class)
.withBean(JwtToAuthenticatedPrincipalConverter.class)
.withBean(ObjectMapper.class, ObjectMapper::new)
.withBean(
JwtDecoder.class,
() ->
token -> {
throw new UnsupportedOperationException("decoder must remain unused");
});
@Test
void redisSessionModeEnablesCsrfAndRotatesAnAuthenticatedSessionIdentifier() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.public-paths=/probe,/csrf",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
mvc.perform(post("/probe")).andExpect(status().isForbidden());
var csrfResult = mvc.perform(get("/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
assertThat(csrfCookie).isNotNull();
mvc.perform(
post("/probe")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfCookie.getValue()))
.andExpect(status().isOk());
FilterChainProxy proxy =
context.getBean("springSecurityFilterChain", FilterChainProxy.class);
SessionManagementFilter sessionManagement =
proxy.getFilterChains().getFirst().getFilters().stream()
.filter(SessionManagementFilter.class::isInstance)
.map(SessionManagementFilter.class::cast)
.findFirst()
.orElseThrow();
Object strategy =
ReflectionTestUtils.getField(
sessionManagement, "sessionAuthenticationStrategy");
assertThat(strategy).isInstanceOf(CompositeSessionAuthenticationStrategy.class);
assertThat(
(java.util.List<?>)
ReflectionTestUtils.getField(strategy, "delegateStrategies"))
.anyMatch(SessionFixationProtectionStrategy.class::isInstance);
} catch (Exception exception) {
throw new AssertionError("session security contract failed", exception);
}
});
}
@Test
void redisSessionSecurityFilterPersistsAndRestoresOnlyThePrimitiveAuthenticationSnapshot() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.public-paths=/login-test,/csrf",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
var csrfResult = mvc.perform(get("/csrf")).andExpect(status().isOk()).andReturn();
Cookie csrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
var login =
mvc.perform(
post("/login-test")
.cookie(csrfCookie)
.header("X-XSRF-TOKEN", csrfCookie.getValue()))
.andExpect(status().isOk())
.andReturn();
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
assertThat(session).isNotNull();
assertThat(
session.getAttribute(
PrimitiveSessionSecurityContextRepository.SNAPSHOT_ATTRIBUTE))
.isInstanceOf(byte[].class);
assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
mvc.perform(get("/whoami").session(session))
.andExpect(status().isOk())
.andExpect(content().string("session-user"));
} catch (Exception exception) {
throw new AssertionError(
"primitive session security context round-trip failed", exception);
}
});
}
@Test
void jwtModeRemainsCsrfDisabledAndStateless() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=jwt",
"ca-skeleton.security.issuer-uri=https://issuer.example",
"ca-skeleton.security.public-paths=/probe",
"ca-skeleton.cors.enabled=false")
.run(
context -> {
MockMvc mvc = mvc(context.getBean("springSecurityFilterChain", Filter.class));
try {
var result = mvc.perform(post("/probe")).andExpect(status().isOk()).andReturn();
assertThat(result.getRequest().getSession(false)).isNull();
} catch (Exception exception) {
throw new AssertionError("JWT security contract failed", exception);
}
});
}
private static MockMvc mvc(Filter springSecurityFilterChain) {
return MockMvcBuilders.standaloneSetup(new ProbeController())
.addFilters(springSecurityFilterChain)
.build();
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({SecuritySettings.class, CorsSettings.class})
static class PropertiesConfig {}
@RestController
static class ProbeController {
@GetMapping("/probe")
String getProbe() {
return "ok";
}
@PostMapping("/probe")
String postProbe() {
return "ok";
}
@GetMapping("/csrf")
String csrf(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
return token.getToken();
}
@PostMapping("/login-test")
String loginForContract() {
SecurityContextHolder.getContext()
.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new AuthenticatedPrincipal(
"session-user", "session-user@example.test", java.util.Set.of("operator")),
null,
java.util.Set.of(new SimpleGrantedAuthority("ROLE_OPERATOR"))));
return "authenticated";
}
@GetMapping("/whoami")
String whoami() {
return ((AuthenticatedPrincipal)
SecurityContextHolder.getContext().getAuthentication().getPrincipal())
.idpUserId();
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.HandlerMapping;
class EdgeRateLimitTransportBridgeTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-07-29T01:00:00Z"), ZoneOffset.UTC);
private static final String DIGEST = "v7:" + "b".repeat(64);
private static final String EVALUATION_ID = "ev9:" + "C".repeat(22);
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void sendsOnlyABoundedPseudonymousSubjectAndTransportBudgetToThePort() {
Capture capture = new Capture();
RateLimitOutcome expected =
new RateLimitOutcome.Evaluated(
new RateLimitDecision(
true,
100,
99,
Duration.ZERO,
CLOCK.instant().plusSeconds(1),
"api-default",
"v3",
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
RateLimitDecision.DecisionCertainty.CERTAIN));
EdgeRateLimitTransportBridge bridge =
new EdgeRateLimitTransportBridge(
request -> {
capture.request = request;
return expected;
},
subject -> {
capture.rawSubject = subject;
return new RateLimitSubjectDigest(DIGEST);
},
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
CLOCK,
"api-default",
Duration.ofMillis(750),
() -> EVALUATION_ID);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal("raw-user-42", "raw@example.com", Set.of("user"));
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(principal, "n/a", Set.of()));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs/123");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}");
request.addHeader("Idempotency-Key", "client-controlled-value");
request.addHeader("X-Rate-Limit-Evaluation-Id", "ev1:" + "Z".repeat(22));
RateLimitOutcome actual = bridge.evaluate(request);
assertThat(actual).isSameAs(expected);
assertThat(capture.rawSubject)
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.PRINCIPAL, "raw-user-42", "GET /v1/worklogs/{id}"));
assertThat(capture.request.policyId()).isEqualTo("api-default");
assertThat(capture.request.subjectDigest()).isEqualTo(DIGEST);
assertThat(capture.request.subjectDigest())
.doesNotContain("raw-user-42")
.doesNotContain("raw@example.com");
assertThat(capture.request.cost()).isEqualTo(1);
assertThat(capture.request.evaluationId()).isEqualTo(EVALUATION_ID);
assertThat(capture.request.evaluationId())
.doesNotContain("client-controlled-value")
.doesNotContain("ZZZZ");
assertThat(capture.request.callerDeadline())
.isEqualTo(Instant.parse("2026-07-29T01:00:00.750Z"));
}
private static final class Capture {
private EdgeRateLimitSubject rawSubject;
private RateLimitRequest request;
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class EdgeRateLimitTransportSettingsTest {
@Test
void defaultsPolicyAndCallerBudgetWithoutSelectingALocalProvider() {
EdgeRateLimitTransportSettings settings =
new EdgeRateLimitTransportSettings(true, null, null, 0, null);
assertThat(settings.enabled()).isTrue();
assertThat(settings.defaultPolicyId()).isEqualTo("api-default");
assertThat(settings.callerDeadlineBudget()).isEqualTo(Duration.ofSeconds(2));
assertThat(settings.hashKeyVersion()).isEqualTo(1);
assertThat(settings.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY);
}
@Test
void rejectsUnboundedPolicyAndDeadlineValues() {
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "INVALID POLICY", Duration.ofSeconds(1), 1, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("defaultPolicyId");
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "api-default", Duration.ofSeconds(31), 1, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("callerDeadlineBudget");
assertThatThrownBy(
() ->
new EdgeRateLimitTransportSettings(
true, "api-default", Duration.ofSeconds(1), 10_000, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("hashKeyVersion");
}
}
@@ -1,84 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
class FixedWindowRateLimiterTest {
private static final Instant T0 = Instant.parse("2026-06-09T12:00:00Z");
@Test
void allowsUpToTheLimitThenRejectsWithinAWindow() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(2, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC));
assertThat(limiter.decide("k").allowed()).isTrue();
RateLimitDecision second = limiter.decide("k");
assertThat(second.allowed()).isTrue();
assertThat(second.remaining()).isZero();
RateLimitDecision third = limiter.decide("k");
assertThat(third.allowed()).isFalse();
assertThat(third.remaining()).isZero();
}
@Test
void separateKeysHaveIndependentCounters() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(1, Duration.ofSeconds(1), Clock.fixed(T0, ZoneOffset.UTC));
assertThat(limiter.decide("a").allowed()).isTrue();
assertThat(limiter.decide("b").allowed()).isTrue();
assertThat(limiter.decide("a").allowed()).isFalse();
}
@Test
void counterResetsWhenTheWindowRolls() {
MutableClock clock = new MutableClock(T0);
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1, Duration.ofSeconds(1), clock);
assertThat(limiter.decide("k").allowed()).isTrue();
assertThat(limiter.decide("k").allowed()).isFalse();
clock.advance(Duration.ofSeconds(1)); // next fixed window
assertThat(limiter.decide("k").allowed()).isTrue();
}
@Test
void resetInstantIsTheWindowEnd() {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(5, Duration.ofSeconds(60), Clock.fixed(T0, ZoneOffset.UTC));
// T0 = 12:00:00 → 60s window starting at 12:00:00 ends at 12:01:00.
assertThat(limiter.decide("k").resetAt()).isEqualTo(Instant.parse("2026-06-09T12:01:00Z"));
}
static final class MutableClock extends Clock {
private Instant instant;
MutableClock(Instant start) {
this.instant = start;
}
void advance(Duration d) {
instant = instant.plus(d);
}
@Override
public Instant instant() {
return instant;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
}
@@ -3,75 +3,176 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import dev.caskeleton.shared.ratelimit.EdgeSubjectPseudonymizer;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitSubjectDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.servlet.HandlerMapping;
import tools.jackson.databind.ObjectMapper;
class RateLimitInterceptorTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-06-09T12:00:00Z"), ZoneOffset.UTC);
private static final String SUBJECT_DIGEST = "v1:" + "a".repeat(64);
private final ObjectMapper objectMapper = new ObjectMapper();
private RateLimitInterceptor interceptor(boolean enabled, int limit) {
FixedWindowRateLimiter limiter =
new FixedWindowRateLimiter(limit, Duration.ofSeconds(1), CLOCK);
return new RateLimitInterceptor(
enabled,
limiter,
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
objectMapper,
1);
}
private MockHttpServletRequest request() {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs");
req.setRemoteAddr("203.0.113.7");
return req;
}
@Test
void allowedRequestPassesAndEmitsSignalingHeaders() throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse();
void allowedRequestPassesAndEmitsExistingSignalingHeaders() throws Exception {
RateLimitOutcome outcome =
evaluated(true, 5, 4, Duration.ZERO, Instant.parse("2026-06-09T12:00:01Z"));
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed = interceptor(true, 5).preHandle(request(), res, new Object());
boolean proceed = interceptor(outcome).preHandle(request(), response, new Object());
assertThat(proceed).isTrue();
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5");
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4");
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z");
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("5");
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_REMAINING)).isEqualTo("4");
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_RESET)).isEqualTo("2026-06-09T12:00:01Z");
assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isNull();
}
@Test
void exceedingTheLimitRejectsWith429EnvelopeRetryAfterAndRetryableTrue() throws Exception {
RateLimitInterceptor interceptor = interceptor(true, 1);
// first request consumes the only slot
interceptor.preHandle(request(), new MockHttpServletResponse(), new Object());
void deniedDecisionRejectsWith429AndUsesTheProviderRetryHint() throws Exception {
RateLimitOutcome outcome =
evaluated(false, 1, 0, Duration.ofMillis(1500), Instant.parse("2026-06-09T12:00:02Z"));
MockHttpServletResponse response = new MockHttpServletResponse();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = interceptor.preHandle(request(), res, new Object());
boolean proceed = interceptor(outcome).preHandle(request(), response, new Object());
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
assertThat(res.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1");
assertThat(res.getContentAsString())
assertThat(response.getStatus()).isEqualTo(429);
assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("2");
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isEqualTo("1");
assertThat(response.getContentAsString())
.contains("\"RATE_LIMIT_EXCEEDED\"")
.contains("\"RATE_LIMIT\"")
.contains("\"retryable\":true");
}
@Test
void disabledLimiterPassesWithoutTouchingHeaders() throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse();
void unavailableAndIndeterminateOutcomesMapTo503WithTheirOwnRetryHints() throws Exception {
MockHttpServletResponse unavailableResponse = new MockHttpServletResponse();
MockHttpServletResponse indeterminateResponse = new MockHttpServletResponse();
boolean proceed = interceptor(false, 1).preHandle(request(), res, new Object());
boolean unavailableProceed =
interceptor(
new RateLimitOutcome.Unavailable(
"api-default",
Duration.ofMillis(100),
RateLimitOutcome.UnavailableCategory.UNAVAILABLE_BEFORE_SEND))
.preHandle(request(), unavailableResponse, new Object());
boolean indeterminateProceed =
interceptor(new RateLimitOutcome.Indeterminate("api-default", Duration.ofMillis(2500)))
.preHandle(request(), indeterminateResponse, new Object());
assertThat(unavailableProceed).isFalse();
assertThat(unavailableResponse.getStatus()).isEqualTo(503);
assertThat(unavailableResponse.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("1");
assertThat(unavailableResponse.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull();
assertThat(unavailableResponse.getContentAsString())
.contains("\"RATE_LIMIT_UNAVAILABLE\"")
.contains("\"retryable\":true");
assertThat(indeterminateProceed).isFalse();
assertThat(indeterminateResponse.getStatus()).isEqualTo(503);
assertThat(indeterminateResponse.getHeader(ApiHeaders.RETRY_AFTER)).isEqualTo("3");
}
@Test
void incompatibleOutcomeMapsToNonRetryable503WithoutInventingARetryHint() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed =
interceptor(
new RateLimitOutcome.Incompatible(
"api-default", RateLimitOutcome.IncompatibleCategory.PROGRAM_INCOMPATIBLE))
.preHandle(request(), response, new Object());
assertThat(proceed).isFalse();
assertThat(response.getStatus()).isEqualTo(503);
assertThat(response.getHeader(ApiHeaders.RETRY_AFTER)).isNull();
assertThat(response.getContentAsString())
.contains("\"RATE_LIMIT_INCOMPATIBLE\"")
.contains("\"retryable\":false");
}
@Test
void disabledModeHasNoProviderPseudonymizerOrResolverSideEffects() throws Exception {
AtomicInteger calls = new AtomicInteger();
EdgeRateLimitPort port =
request -> {
calls.incrementAndGet();
throw new AssertionError("disabled interceptor must not call the provider");
};
EdgeSubjectPseudonymizer pseudonymizer =
subject -> {
calls.incrementAndGet();
throw new AssertionError("disabled interceptor must not pseudonymize");
};
EdgeRateLimitTransportBridge unusedBridge =
new EdgeRateLimitTransportBridge(
port,
pseudonymizer,
new RateLimitKeyResolver(
request -> {
calls.incrementAndGet();
return request.getRemoteAddr();
}),
CLOCK,
"api-default",
Duration.ofSeconds(1),
() -> "ev1:" + "D".repeat(22));
MockHttpServletResponse response = new MockHttpServletResponse();
boolean proceed =
RateLimitInterceptor.disabled(objectMapper).preHandle(request(), response, unusedBridge);
assertThat(proceed).isTrue();
assertThat(res.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull();
assertThat(calls).hasValue(0);
assertThat(response.getHeader(ApiHeaders.X_RATELIMIT_LIMIT)).isNull();
}
private RateLimitInterceptor interceptor(RateLimitOutcome outcome) {
EdgeRateLimitTransportBridge bridge =
new EdgeRateLimitTransportBridge(
request -> outcome,
subject -> new RateLimitSubjectDigest(SUBJECT_DIGEST),
new RateLimitKeyResolver(new RemoteAddrClientIpResolver()),
CLOCK,
"api-default",
Duration.ofSeconds(1),
() -> "ev1:" + "D".repeat(22));
return RateLimitInterceptor.enabled(bridge, objectMapper);
}
private MockHttpServletRequest request() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs");
request.setRemoteAddr("203.0.113.7");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs");
return request;
}
private static RateLimitOutcome evaluated(
boolean allowed, long limit, long remaining, Duration retryAfter, Instant resetAt) {
return new RateLimitOutcome.Evaluated(
new RateLimitDecision(
allowed,
limit,
remaining,
retryAfter,
resetAt,
"api-default",
"v1",
RateLimitDecision.DecisionSource.GLOBAL_REDIS,
RateLimitDecision.DecisionCertainty.CERTAIN));
}
}
@@ -3,6 +3,7 @@ package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -27,34 +28,42 @@ class RateLimitKeyResolverTest {
}
@Test
void unauthenticatedKeyIsIpPlusRouteTemplate() {
void unauthenticatedSubjectIsBoundedClientIpPlusRouteTemplate() {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/v1/worklogs/123");
req.setRemoteAddr("203.0.113.7");
req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs/{id}");
assertThat(resolver.resolve(req)).isEqualTo("ip:203.0.113.7:GET /v1/worklogs/{id}");
assertThat(resolver.resolve(req))
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP, "203.0.113.7", "GET /v1/worklogs/{id}"));
}
@Test
void unauthenticatedKeyFallsBackToUriWhenNoPattern() {
void unauthenticatedSubjectUsesABoundedFallbackWhenNoRouteTemplateExists() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/v1/worklogs");
req.setRemoteAddr("198.51.100.4");
assertThat(resolver.resolve(req)).isEqualTo("ip:198.51.100.4:POST /v1/worklogs");
assertThat(resolver.resolve(req).operationId()).isEqualTo("POST <unresolved-route>");
}
@Test
void authenticatedUserKeyIsKeyedByPrincipal() {
void authenticatedUserSubjectIsPrincipalPlusOperation() {
authenticateAs(new AuthenticatedPrincipal("user-42", "u@x.io", Set.of("user")));
assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs")))
.isEqualTo("user:user-42");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/worklogs");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/v1/worklogs");
assertThat(resolver.resolve(request))
.isEqualTo(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.PRINCIPAL, "user-42", "GET /v1/worklogs"));
}
@Test
void servicePrincipalKeyIsKeyedByApiKeyId() {
void servicePrincipalSubjectUsesApiKeyKind() {
authenticateAs(new AuthenticatedPrincipal("svc-7", "svc@x.io", Set.of("service")));
assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs")))
.isEqualTo("apikey:svc-7");
assertThat(resolver.resolve(new MockHttpServletRequest("GET", "/v1/worklogs")).kind())
.isEqualTo(EdgeRateLimitSubject.Kind.API_KEY);
}
@Test
@@ -63,7 +72,7 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("10.0.0.1");
req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1");
assertThat(resolver.resolve(req)).isEqualTo("ip:10.0.0.1:GET /v1/ping");
assertThat(resolver.resolve(req).canonicalIdentity()).isEqualTo("10.0.0.1");
}
@Test
@@ -74,7 +83,7 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("10.0.0.1");
req.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1");
assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:203.0.113.9:GET /v1/ping");
assertThat(forwardedResolver.resolve(req).canonicalIdentity()).isEqualTo("203.0.113.9");
}
@Test
@@ -85,6 +94,6 @@ class RateLimitKeyResolverTest {
req.setRemoteAddr("198.51.100.4");
req.addHeader("X-Forwarded-For", " ");
assertThat(forwardedResolver.resolve(req)).isEqualTo("ip:198.51.100.4:GET /v1/ping");
assertThat(forwardedResolver.resolve(req).canonicalIdentity()).isEqualTo("198.51.100.4");
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort;
import java.time.Clock;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import tools.jackson.databind.ObjectMapper;
class RateLimitWebConfigTest {
@Test
void disabledCapabilityDoesNotResolveProviderPseudonymizerOrClock() {
ObjectProvider<Clock> clockProvider = provider();
ObjectProvider<EdgeRateLimitPort> rateLimitPortProvider = provider();
ObjectProvider<UserPrincipalPseudonymizerPort> pseudonymizerProvider = provider();
new RateLimitWebConfig(
new EdgeRateLimitTransportSettings(false, null, null, 0, null),
new ObjectMapper(),
clockProvider,
rateLimitPortProvider,
pseudonymizerProvider);
verifyNoInteractions(clockProvider, rateLimitPortProvider, pseudonymizerProvider);
}
@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> provider() {
return mock(ObjectProvider.class);
}
}
@@ -1,28 +0,0 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
class RateLimiterFactoryTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-06-09T00:00:00Z"), ZoneOffset.UTC);
@Test
void fixedWindowAlgorithmBuildsAFixedWindowLimiter() {
RateLimiter limiter =
RateLimiterFactory.create(
RateLimitAlgorithm.FIXED_WINDOW, 10, Duration.ofSeconds(1), CLOCK);
assertThat(limiter).isInstanceOf(FixedWindowRateLimiter.class);
// returns the interface type so the interceptor never sees the concrete class
RateLimitDecision decision = limiter.decide("k");
assertThat(decision.allowed()).isTrue();
assertThat(decision.limit()).isEqualTo(10);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import java.security.SecureRandom;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
class SecureRandomRateLimitEvaluationIdGeneratorTest {
@Test
void generatesVersionedBoundedServerSideIdsWithCryptographicRandomness() {
RateLimitEvaluationIdGenerator generator =
new SecureRandomRateLimitEvaluationIdGenerator(new SecureRandom(), 1);
Set<String> generated = new HashSet<>();
for (int index = 0; index < 100; index++) {
generated.add(generator.generate());
}
assertThat(generated).hasSize(100).allMatch(value -> value.matches("ev1:[A-Za-z0-9_-]{22}"));
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.inbound.web.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.ratelimit.EdgeRateLimitSubject;
import org.junit.jupiter.api.Test;
class VersionedEdgeSubjectPseudonymizerTest {
@Test
void lengthFramesEveryDimensionBeforeDelegatingAndVersionsTheDigest() {
StringBuilder delegatedInput = new StringBuilder();
VersionedEdgeSubjectPseudonymizer pseudonymizer =
new VersionedEdgeSubjectPseudonymizer(
raw -> {
delegatedInput.append(raw);
return "c".repeat(64);
},
3);
assertThat(
pseudonymizer
.pseudonymize(
new EdgeRateLimitSubject(
EdgeRateLimitSubject.Kind.CLIENT_IP,
"203.0.113.7",
"GET /v1/worklogs/{id}"))
.value())
.isEqualTo("v3:" + "c".repeat(64));
assertThat(delegatedInput).hasToString("9:CLIENT_IP|11:203.0.113.7|21:GET /v1/worklogs/{id}");
}
}
@@ -1,43 +0,0 @@
package dev.caskeleton.adapter.inbound.web.settings;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitClientIpMode;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class RateLimitSettingsTest {
@Test
void bindsSuppliedValues() {
RateLimitSettings props =
new RateLimitSettings(
true,
250,
Duration.ofSeconds(5),
RateLimitAlgorithm.FIXED_WINDOW,
RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED);
assertThat(props.enabled()).isTrue();
assertThat(props.limit()).isEqualTo(250);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(5));
assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW);
assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.FORWARDED_HEADERS_TRUSTED);
}
@Test
void defaultsAbsentOrInvalidLimitWindowAndAlgorithm() {
RateLimitSettings props = new RateLimitSettings(false, null, null, null, null);
assertThat(props.limit()).isEqualTo(100);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(1));
assertThat(props.algorithm()).isEqualTo(RateLimitAlgorithm.FIXED_WINDOW);
assertThat(props.clientIpMode()).isEqualTo(RateLimitClientIpMode.REMOTE_ADDR_ONLY);
}
@Test
void rejectsNonPositiveLimitAndWindowWithSafeDefaults() {
RateLimitSettings props = new RateLimitSettings(true, 0, Duration.ZERO, null, null);
assertThat(props.limit()).isEqualTo(100);
assertThat(props.window()).isEqualTo(Duration.ofSeconds(1));
}
}
@@ -68,6 +68,27 @@ class SecuritySettingsTest {
assertThat(settings.publicPaths()).isUnmodifiable();
}
@Test
void redisSessionModeDoesNotRequireJwtAndBindsSecureHostOnlyCookiePolicy() {
runner
.withPropertyValues(
"ca-skeleton.security.auth-mode=redis-session",
"ca-skeleton.security.session.cookie-name=APP_SESSION",
"ca-skeleton.security.session.secure=true",
"ca-skeleton.security.session.http-only=true",
"ca-skeleton.security.session.same-site=Strict")
.run(
context -> {
assertThat(context).hasNotFailed();
SecuritySettings settings = context.getBean(SecuritySettings.class);
assertThat(settings.authMode())
.isEqualTo(SecuritySettings.AuthenticationMode.REDIS_SESSION);
assertThat(settings.issuerUri()).isEmpty();
assertThat(settings.session().cookieName()).isEqualTo("APP_SESSION");
assertThat(settings.session().sameSite()).isEqualTo("Strict");
});
}
@Configuration
@EnableConfigurationProperties(SecuritySettings.class)
static class EnableProperties {}