Skip to content

feat: distributed cache support (SecurityCache SPI + adapters) - #2005

Open
NiklasHerrmann21 wants to merge 26 commits into
mainfrom
feature/distributed-cache-support
Open

feat: distributed cache support (SecurityCache SPI + adapters)#2005
NiklasHerrmann21 wants to merge 26 commits into
mainfrom
feature/distributed-cache-support

Conversation

@NiklasHerrmann21

Copy link
Copy Markdown
Contributor

Summary

Introduces a pluggable SecurityCache SPI so customers can share the library's caches (JWKS, OIDC discovery, outbound tokens, plus two new opt-in caches) across pods via Redis / Hazelcast / any JCache or Spring Cache backend. Eliminates cold-start latency and thundering-herd pressure on XSUAA / IAS during rolling deploys of multi-tenant services.

  • SPI SecurityCache<K,V> in java-api with get/set/delete/clear (Node.js xssec-consistent). Contract: methods must never throw — cache failures fall through to source of truth.
  • Refactored outbound token cache (AbstractOAuth2TokenService), JWKS cache, OIDC discovery cache — all now go through the SPI. Values are cached as raw JSON so they cross process boundaries cleanly.
  • New opt-in caches: token-decode cache and signature-validation cache. Signature cache values are HMAC-SHA256 signed with a per-app key derived via HKDF-SHA256 (RFC 5869) — a tampered cache entry is detected and treated as a miss.
  • Adapters: CaffeineSecurityCache (default, in-memory), new token-client-jcache module (JCacheSecurityCache), SpringCacheSecurityCache.
  • Spring auto-configuration: @Bean-only setup. Property-gated (sap.security.cache.distributed.enabled), fail-fast on misconfiguration. Discovery order: user SecurityCache bean → Spring CacheManager → JCache CacheManager (reflective). Both Boot-2 and Boot-3 Spring modules covered.
  • Thundering-herd guard: single-flight per cache key in AbstractOAuth2TokenService — only the fetcher issues the HTTP call and writes to the cache; concurrent waiters get the same result.

Scope

Cache Status
Outbound Token (AbstractOAuth2TokenService) Refactored to SPI, JSON serialization, single-flight
JWKS (OAuth2TokenKeyServiceWithCache) Refactored to SPI, raw JSON cached, PublicKey reparsed per hit
OIDC Discovery (OidcConfigurationServiceWithCache) Refactored to SPI, raw JSON cached
Token Decode (new) Opt-in, TTL bound to exp claim
Signature Validation (new) Opt-in, HMAC-SHA256 integrity, fail-safe on tamper

Backwards compatibility

None broken. All existing constructors kept; Caffeine remains the default when no SecurityCache bean is provided.

Documentation

  • Main README.md: new "Distributed Caching" section (3 usage examples, warnings, table of caches).
  • token-client/README.md, java-security/README.md, spring-security-3/README.md extended.
  • Full Javadoc on every new public API with usage snippets.

Test plan

  • Unit tests: 62 java-api, 204 token-client, 8 token-client-jcache, 388 java-security, 143 spring-security-3, 143 spring-security — all green
  • Coverage of new cache classes at ~100% line coverage; remaining gaps are unreachable JCE catch blocks (documented in source)
  • mvn clean install -DskipITs -Dgpg.skip=true — BUILD SUCCESS
  • Fail-fast test: enabling distributed cache without a backing store throws with clear remediation message
  • Single-flight test: N concurrent misses → 1 HTTP call, 1 cache write
  • HMAC tamper test: bit-flip on cached signature verdict → cache miss + WARN
  • Manual verification with a real Redis backend (out of scope for this PR)
  • Load test comparing cold-start latency with/without distributed cache (out of scope for this PR)

Follow-ups (separate PRs)

  • End-to-end sample with Testcontainers Redis
  • token-client-jcache in the project BOM
  • Auto-inject SecurityCache bean into AbstractTokenAuthenticator via ObjectProvider (currently propagated through HybridIdentityServicesAutoConfiguration — decode/sig caches remain caller-instantiated by design)

Refs: outbound token / JWKS distributed cache alignment ticket.

@NiklasHerrmann21

Copy link
Copy Markdown
Contributor Author

/review

Base automatically changed from 4.1.0 to main August 5, 2026 06:25
Introduces a small pluggable cache SPI plus supporting utilities that the
Cloud Security libraries will use for all internal caches (token, JWKS,
OIDC, decode, signature). Downstream adapters can bind Redis, Hazelcast,
JCache or Spring Cache without pulling those dependencies into the core.

- SecurityCache<K,V>: get / set / delete / clear; contract: never throws
- NoOpSecurityCache: default and opt-out
- CacheKeys: stable, opaque, prefixed SHA-256 keys per namespace
Wraps a Caffeine cache behind the SecurityCache SPI. This is the default
in-memory implementation used by AbstractOAuth2TokenService when no
distributed cache is supplied.
Replaces the hand-rolled Caffeine cache in AbstractOAuth2TokenService with
the SecurityCache SPI, adds a single-flight collapsing map to prevent
concurrent misses from all fanning out to XSUAA, and lets callers wire in
their own cache implementation (Redis via JCache/Spring Cache adapters).

- New DefaultOAuth2TokenService(client, cfg, SecurityCache) constructor.
- Cached values are JSON round-trippable so they can travel across
  processes; the exp is stored as absolute epoch-millis to survive
  deserialization.
- The now-outdated getIfPresent-twice pattern in getOrRequestAccessToken
  is replaced by a single lookup + reuse.
- All cache calls are wrapped defensively: a broken cache never breaks
  a token fetch.
Both caches now go through the SecurityCache SPI so that consumers can
plug in a shared distributed cache — this eliminates the cold-start
JWKS refetch and the OIDC-discovery herd after a rolling deploy.

- JsonWebKeySet is rebuilt from cached JWKS JSON on every hit.
- OidcConfigurationService's endpoints are serialized into a small JSON
  envelope covering only the URIs the runtime consumes; the round-trip
  is defensive against providers that expose only a subset of endpoints.
- JwtValidatorBuilder gains withSecurityCache(SecurityCache) that wires
  the same instance into both caches (namespaces keep entries apart).
- OidcConfigurationServiceWithCache implements Cacheable so it stays
  consistent with the other cached services.
- Also lands a small SapIdType constants class referenced by SapIdToken
  Javadoc and by SapIdTokenTest but never checked in earlier.
TokenDecodeCache memoizes the 'raw JWT string -> decoded Token' mapping
behind the SecurityCache SPI so that chatty services do not re-decode
the same access token on every hop. Off by default; enable via
TokenDecodeCacheConfiguration.enabled(Duration).

- Key: sha256 of the token, namespaced under 'decode'; the raw token is
  never used as the key.
- TTL: min(exp - now, configured cap); expired tokens are not stored.
- Cache faults never propagate; a broken cache degrades to always-decode.

Not automatically wired into AbstractTokenAuthenticator; callers who
want to opt in can wrap Token.create with getOrDecode.
…integrity

Caches the boolean 'signature has been verified' verdict for a token +
kid tuple, so a chatty service does not re-run the full RSA verification
on every hop. Off by default; enable via
SignatureValidationCacheConfiguration.enabled(Duration, ikm).

Because caching a validation verdict shifts trust from the crypto verifier
onto the cache, each entry carries an HMAC-SHA256 tag whose key is derived
from application-specific IKM via HKDF-SHA256 (RFC 5869). A tampered entry
or an entry written by another application fails the MAC comparison and
is treated as a cache miss with a WARN log.

Only successful validations are cached; failures always fall through to
the source of truth.

The HmacUtil sits alongside — small, focused API for HKDF-SHA256,
HMAC-SHA256 and constant-time verify.
Introduces the token-client-jcache module holding a JSR-107 (JCache)
adapter for the SecurityCache SPI. Consumers can now plug any JCache
provider (Caffeine JCache, Ehcache, Hazelcast, Redisson, ...) behind
the library's caches without pulling those dependencies into the core.

- javax.cache:cache-api:1.1.1 keeps the compile-time surface minimal.
- Per-entry TTL is documented as ignored in favor of the caller-configured
  ExpiryPolicy on the CacheManager — most JCache providers do not honor
  per-put TTL uniformly, so this keeps behavior predictable.
- Tests run against caffeine-jcache as reference implementation.
… and Spring adapter

Wires a shared SecurityCache into the SAP Cloud Security caches when
distributed caching is enabled via sap.security.cache.distributed.enabled.
The auto-config finds, in order:

1. A user-supplied SecurityCache<String,String> bean.
2. A Spring CacheManager with a cache named 'sap-security' (name
   overridable via sap.security.cache.distributed.cache-name).
3. A javax.cache.CacheManager bean holding the same-named cache
   (reflective; only wired when javax.cache-api is on the classpath).
4. A NoOpSecurityCache fallback with an INFO-level log.

- New SpringCacheSecurityCache adapter over org.springframework.cache.Cache.
- XsuaaTokenFlowAutoConfiguration takes an ObjectProvider<SecurityCache>
  and hands it to DefaultOAuth2TokenService's new SPI-aware constructor.
- Same wiring in both spring-security (Spring Boot 4) and
  spring-security-3 (Spring Boot 3 legacy) modules.
- Registered in both starter jars via AutoConfiguration.imports.
Adds a Caching section to the main README covering the five internal
caches, why distributed caching matters (cold-start / thundering herd),
three worked examples (Spring Boot, plain Java + JCache, custom adapter)
and the security caveats around the opt-in signature cache.

Per-module extensions:
- token-client/README: distributed outbound token cache + single-flight
- java-security/README: JWKS / OIDC / decode / signature caches
- spring-security-3/README: auto-configuration wiring + discovery order
…ng store is configured

Previously the SecurityCacheAutoConfiguration silently returned a NoOpSecurityCache
when sap.security.cache.distributed.enabled=true was set but neither a Spring
CacheManager with a matching cache name nor a JCache CacheManager was available.
Because AbstractOAuth2TokenService's cache selection treats a supplied cache
(even a no-op) as authoritative, this defeated the library's Caffeine default
and caused the app to run without any cache at all.

Now the bean factory throws an IllegalStateException with a clear remediation
message so the misconfiguration surfaces at startup.
…-flight collapse

Before this change every waiter that returned from AbstractOAuth2TokenService#singleFlight
also called safeSet with the same value. For N concurrent misses on the same cache key
that produced N cache writes; with a distributed store (Redis) that is N-1 useless
network round-trips per collapse.

Move the write into an onFreshFetch callback that only the fetcher thread invokes.
Add a coverage test with 3 concurrent misses that asserts exactly one HTTP call and
exactly one cache write.
…ticator into JwtValidatorBuilder

Add a withSecurityCache setter (and matching getter) on AbstractTokenAuthenticator
so callers who use the servlet-side authenticator directly can share the same
distributed cache across JWKS + OIDC lookups. Previously the setter existed only
on JwtValidatorBuilder, which meant Spring-side wiring or manual code had to reach
around AbstractTokenAuthenticator to reuse it.

The propagation is unconditional inside getOrCreateTokenValidator so
Optional.ofNullable keeps the behavior for callers that never set it.
…nfiguration

Adds withSecurityCache to JwtDecoderBuilder (both Boot 2 and Boot 3 flavors) and
consumes the shared SecurityCache bean produced by SecurityCacheAutoConfiguration
from the HybridIdentityServicesAutoConfiguration bean methods. This closes the
last gap in the distributed cache story: JWKS and OIDC lookups now share the same
backing store as outbound token flows without any manual wiring by the user.

The wiring uses ObjectProvider so it stays optional; behavior when no bean is
supplied is unchanged.
…ation caches

Adds TokenValidationCachesAutoConfiguration (Boot 2 and Boot 3) that exposes
opt-in TokenDecodeCache and SignatureValidationCache beans.

Properties:
- sap.security.cache.token-decode.enabled + .duration
- sap.security.cache.signature.enabled + .duration + .ikm-env-var

The signature cache IKM must be supplied via an environment variable whose name
is configured through .ikm-env-var, keeping raw bytes out of application.yml.
An EnvVarReader bean is exposed so tests (and forward-thinking callers who use
a secret manager) can substitute their own resolver.

The signature cache bean is exposed but not wired into JwtSignatureValidator
automatically — callers must opt-in explicitly.
…CacheSecurityCache, HmacUtil

- Cover all constructors, factory methods, and the exception-swallowing try/catch
  paths of the three SecurityCache adapters via Mockito-mocked underlying caches.
- Round-trip additional edge cases of HmacUtil (utf8 null, salt-less HKDF stability).
- Add configuration-level tests for TokenDecodeCacheConfiguration and
  SignatureValidationCacheConfiguration (getters, defensive IKM copy, argument
  validation).
- Add unformatted / invalid-base64 / null-kid coverage on SignatureValidationCache
  and TTL floor + missing-exp + decoder-throws paths on TokenDecodeCache.
The JCache adapter was a thin wrapper around a Cache<String,String>. Anyone
who wants a JCache-backed SecurityCache can copy the same ~30 lines from the
README snippet — there is no library value in owning it, and shipping it
positions us as support-fähig for arbitrary JCache providers.

Kept:
- SecurityCache SPI in java-api — the actual value of the feature.
- CaffeineSecurityCache in token-client — in-memory default.
- SpringCacheSecurityCache in spring-security / spring-security-3.
- Auto-configuration now falls through to fail-fast if neither a user
  SecurityCache bean nor a Spring CacheManager cache is provided
  (JCache reflection fallback removed).

Docs:
- README §2.5 rewritten with three copy-pasteable snippets: Redis via
  Jedis (plain Java), any JCache provider (plain Java), any Spring-managed
  backend (Spring Boot).
- token-client, spring-security, spring-security-3 READMEs updated to
  point at the top-level snippets rather than at the deleted module.
- README.md: 2.5 Distributed Caching + sub-anchors (Spring Boot / plain Java / bring your own cache).
- spring-security/README.md: [Optional] Distributed Cache under Optional Usage.
- token-client/README.md: Distributed Cache under Cache configuration.
We removed the JCache adapter module but still positioned JCache prominently
as an example backend (Snippet 2, javadoc examples, README text). That
implicitly suggested JCache is still a supported path and would have driven
the same support questions the module removal was meant to prevent.

- README: drop 'Snippet 2 — Any JCache provider' entirely, renumber the
  Spring-managed backend snippet to 2, strip 'JCache' from the intro
  sentence and the TTL-behavior warning.
- spring-security / spring-security-3 READMEs: drop JCache from the
  'other backend' fallback sentence.
- token-client README: drop JCache from the snippet index sentence.
- Auto-config javadoc: drop 'JCache providers' from the backend list.

Remaining supported backends in the docs: Caffeine (default),
Spring CacheManager (Redis / Hazelcast / ...), Redis via Jedis
(plain-Java snippet).
…aches

The distributed cache feature request (mail + ticket) explicitly scoped
JWKS cache and outbound token cache — Manuel's mail asks about JWKS cache
and Token Fetch Cache, the ticket says "Scope: JWKS cache, outbound token
cache, or both?". The token-decode cache and the signature-validation
cache were added on the side but never requested.

Removed for scope reasons + risk:
- SignatureValidationCache + Config + HmacUtil (HKDF-SHA256 / HMAC-SHA256):
  caches a boolean "signature verified" verdict. Even with the HMAC guard
  against a compromised backing store, an application misconfigured to run
  without the HMAC key would create an auth-bypass vector. Not a risk we
  want to take on quietly.
- TokenDecodeCache + Config: JWT decode is a few milliseconds of Base64
  + JSON parse. No concrete perf case exists in the request; belongs in
  a later PR with real numbers.
- TokenValidationCachesAutoConfiguration + its imports in the two starter
  META-INF files (spring-security + spring-security-3): existed only to
  wire the two removed caches.
- All associated tests and README sections.

What stays and matches the requested scope:
- SecurityCache SPI in java-api.
- CaffeineSecurityCache default in token-client.
- SpringCacheSecurityCache + SecurityCacheAutoConfiguration.
- JWKS/OIDC caches (java-security) refactored to the SPI.
- Outbound token cache (token-client) refactored to the SPI.
- Propagation from AbstractTokenAuthenticator into JwtValidatorBuilder.
DefaultIdTokenExtension and DefaultXsuaaTokenExtension delegate all token
exchanges (retrieveAccessTokenViaJwtBearerTokenGrant) to an
OAuth2TokenService that they receive by constructor. Before this change,
the Hybrid auto-configs and HybridTokenAuthenticator built the extensions'
token services with the 2-arg DefaultOAuth2TokenService(httpClient)
constructor, which uses a private Caffeine cache and cannot participate
in a shared SecurityCache.

Effect: even when the JWKS + outbound token caches were wired to Redis
via SecurityCacheAutoConfiguration, every ID-to-XSUAA exchange still ran
against an isolated per-instance cache. After a rolling deploy each pod
re-issued exchange tokens against XSUAA independently — the thundering
herd the distributed cache was meant to prevent.

Fix: thread the SecurityCache (ObjectProvider in Spring, getSecurityCache()
in AbstractTokenAuthenticator) into DefaultOAuth2TokenService via the
3-arg constructor (httpClient, TokenCacheConfiguration, securityCache).

Also: fixed a pre-existing bug in the spring-security-3 hybrid autoconfig
where the XSUAA token extension was constructed with identityConfig
instead of xsuaaConfig (HybridIdentityServicesAutoConfiguration line 92
and HybridIdentityServicesProofTokenAutoConfiguration line 90). Only
spring-security-3 had this typo; spring-security was correct. XSUAA
token exchanges via the spring-security-3 hybrid autoconfig would have
used the wrong OAuth2ServiceConfiguration before this fix.

Also: HybridIdentityServicesProofTokenAutoConfiguration in both
spring-security and spring-security-3 was not wiring the SecurityCache
into the JwtDecoderBuilder at all — it received no ObjectProvider. Added
the parameter and the withSecurityCache(...) call to match the non-proof
sibling.

Also: spring-security-3 auto-configs now use SecurityHttpClientProvider
(consistent with spring-security and XsuaaTokenFlowAutoConfiguration)
instead of the older HttpClientFactory, since the 3-arg constructor
requires SecurityHttpClient rather than CloseableHttpClient.
…iteria

Aligns the documentation with the original feature request from Manuel:
recommendation should change from "use the default cache" to "small
services → Caffeine default is fine, larger deployments → distributed
cache for resilience and performance". The previous docs described the
mechanism but never made the recommendation explicit, leaving readers to
guess whether their service needed a distributed cache.

Main README (§2.5):
- Recast the "Why distributed?" paragraph into an explicit "Recommendation:
  when do I need a distributed cache?" section with concrete criteria for
  both the Caffeine-default and the distributed-cache case.
- Added an anchor for the new subsection in the top-level TOC.
- Extended the cache table row for the outbound token cache to mention
  JWT-bearer token-exchange explicitly, and added a note below the table
  that DefaultIdTokenExtension / DefaultXsuaaTokenExtension share the same
  namespace ('tokens') — there is no separate exchange cache.

Module READMEs (spring-security, spring-security-3, token-client,
java-security): opened each distributed-cache section with a short
"Caffeine by default, distributed cache recommended for larger
deployments" paragraph that links back to the main README's criteria, so
readers arriving at a module page get the same guidance. The
spring-security and spring-security-3 sections also now explicitly list
JWT-bearer token-exchange under the outbound token cache wiring.

Also removed two stale references to "opt-in decode / signature caches"
in spring-security and spring-security-3 READMEs — those caches were
removed in commit 655ade1, but the module READMEs still pointed to them
via the top-level link.
- CacheKeys: split build() (plaintext, for JWKS/OIDC) and buildOpaque()
  (SHA-256, for token cache where fingerprint contains credentials);
  remove unused NAMESPACE_DECODE and NAMESPACE_SIG constants
- AbstractOAuth2TokenService: use buildOpaque() for token cache keys
- CaffeineSecurityCache: make Ticker constructor package-private (test-only)
- AbstractTokenAuthenticator: reduce getSecurityCache() to protected;
  replace getter-roundtrip tests with behaviour-based assertions
@NiklasHerrmann21
NiklasHerrmann21 force-pushed the feature/distributed-cache-support branch from f4ff8e1 to dae2746 Compare August 14, 2026 08:29
} else {
LOGGER.debug(
"Configured token service with {} using cache impl {}",
tokenCacheConfiguration,
// Caffeine uses cache-wide expiration; per-entry ttl argument is intentionally ignored.
delegate.put(key, value);
} catch (final RuntimeException e) {
LOGGER.warn("CaffeineSecurityCache.set failed for key {}: {}", key, e.getMessage());
try {
return Optional.ofNullable(delegate.getIfPresent(key));
} catch (final RuntimeException e) {
LOGGER.warn("CaffeineSecurityCache.get failed for key {}: {}", key, e.getMessage());
try {
delegate.put(key, value);
} catch (final RuntimeException e) {
LOGGER.warn("SpringCache.put failed for {}: {}", key, e.getMessage());
Object v = wrapper.get();
return v instanceof String s ? Optional.of(s) : Optional.empty();
} catch (final RuntimeException e) {
LOGGER.warn("SpringCache.get failed for {}: {}", key, e.getMessage());
try {
delegate.put(key, value);
} catch (final RuntimeException e) {
LOGGER.warn("SpringCache.put failed for {}: {}", key, e.getMessage());
if (jwks == null || jwks.getAll().isEmpty()) {
LOGGER.error(
"Retrieved no token keys from {} for the given header parameters.",
LogSanitizer.sanitize(keyParameters.keyUri));
try {
return Optional.ofNullable(delegate.getIfPresent(key));
} catch (final RuntimeException e) {
LOGGER.warn("CaffeineSecurityCache.get failed for key {}: {}", key, e.getMessage());
// Caffeine uses cache-wide expiration; per-entry ttl argument is intentionally ignored.
delegate.put(key, value);
} catch (final RuntimeException e) {
LOGGER.warn("CaffeineSecurityCache.set failed for key {}: {}", key, e.getMessage());
* @param value the value to store, never {@code null}
* @param ttl the time-to-live for this entry; {@code null} means "use adapter default"
*/
void set(@Nonnull K key, @Nonnull V value, Duration ttl);
Keys are now <namespace>:<fingerprint> without a library-level prefix.
Scoping is the responsibility of the cache adapter — Spring CacheManager
adapters use the cache name as namespace; raw store adapters (Jedis, ...)
should apply their own prefix when storing.

This eliminates the redundant double-prefix that appeared in Redis when
using RedisCacheManager with cache name "sap-security":
  before: sap-security::sap-security:jwks:...
  after:  sap-security::jwks:...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants