feat: distributed cache support (SecurityCache SPI + adapters) - #2005
Open
NiklasHerrmann21 wants to merge 26 commits into
Open
feat: distributed cache support (SecurityCache SPI + adapters)#2005NiklasHerrmann21 wants to merge 26 commits into
NiklasHerrmann21 wants to merge 26 commits into
Conversation
Contributor
Author
|
/review |
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
force-pushed
the
feature/distributed-cache-support
branch
from
August 14, 2026 08:29
f4ff8e1 to
dae2746
Compare
| } 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:...
…tyCacheAutoConfiguration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces a pluggable
SecurityCacheSPI 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.SecurityCache<K,V>injava-apiwithget/set/delete/clear(Node.js xssec-consistent). Contract: methods must never throw — cache failures fall through to source of truth.AbstractOAuth2TokenService), JWKS cache, OIDC discovery cache — all now go through the SPI. Values are cached as raw JSON so they cross process boundaries cleanly.CaffeineSecurityCache(default, in-memory), newtoken-client-jcachemodule (JCacheSecurityCache),SpringCacheSecurityCache.@Bean-only setup. Property-gated (sap.security.cache.distributed.enabled), fail-fast on misconfiguration. Discovery order: userSecurityCachebean → SpringCacheManager→ JCacheCacheManager(reflective). Both Boot-2 and Boot-3 Spring modules covered.AbstractOAuth2TokenService— only the fetcher issues the HTTP call and writes to the cache; concurrent waiters get the same result.Scope
AbstractOAuth2TokenService)OAuth2TokenKeyServiceWithCache)PublicKeyreparsed per hitOidcConfigurationServiceWithCache)expclaimBackwards compatibility
None broken. All existing constructors kept; Caffeine remains the default when no
SecurityCachebean is provided.Documentation
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.mdextended.Test plan
mvn clean install -DskipITs -Dgpg.skip=true— BUILD SUCCESSFollow-ups (separate PRs)
token-client-jcachein the project BOMSecurityCachebean intoAbstractTokenAuthenticatorvia ObjectProvider (currently propagated throughHybridIdentityServicesAutoConfiguration— decode/sig caches remain caller-instantiated by design)Refs: outbound token / JWKS distributed cache alignment ticket.