This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch docs in repository https://gitbox.apache.org/repos/asf/juneau.git
commit a42d4de0a48d30987495edf8cdf1388b510c6483 Author: James Bognar <[email protected]> AuthorDate: Sun Aug 16 13:40:59 2026 -0400 READY-398: Document SAML ReplayCache multi-node deployment options Explain that InMemoryReplayCache is per-process and how to inject a shared ReplayCache for clustered SAML deployments. --- pages/topics/10.46.SamlAuthSupport.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pages/topics/10.46.SamlAuthSupport.md b/pages/topics/10.46.SamlAuthSupport.md index 645df9e9f0..884042ac16 100644 --- a/pages/topics/10.46.SamlAuthSupport.md +++ b/pages/topics/10.46.SamlAuthSupport.md @@ -77,6 +77,23 @@ Artifact + SOAP bindings are deferred — if your IdP only emits those, see | Clock skew | 60s default, 300s max | Mirrors the JWT validator's defaults; the builder refuses larger values. | | Encrypted assertions | opt-in via `decryptionCredential(...)` | A response carrying `<EncryptedAssertion>` is rejected with a `decryption_required` challenge when no key is configured; a wrong key yields `decryption_failed`. | +## Replay protection in clustered deployments + +`SamlAssertionValidator` enforces one-time use of each assertion ID via a `ReplayCache`. The default is `InMemoryReplayCache`, which is **per-process only** — it's backed by an in-JVM map, so two nodes behind a load balancer don't see each other's recorded assertion IDs. + +In a multi-node deployment, inject a `ReplayCache` backed by a store shared across every node (e.g. a distributed cache or database) via `SamlAssertionValidator.Builder.replayCache(...)`, otherwise an attacker can replay the same assertion against a different node before it's been recorded there: + +```java +SamlAssertionValidator validator = SamlAssertionValidator.create() + .metadataResolver(SamlMetadataResolvers.url("https://idp.example.com/metadata")) + .spEntityId("https://sp.example.com") + .expectedIssuer("https://idp.example.com") + .replayCache(myClusteredReplayCache) // shared store across every node + .build(); +``` + +The check is always fail-closed: a cache that can't answer causes the assertion to be rejected. See [Concurrent Package § Replay Detection](/docs/topics/JuneauCommonsConcurrent#replay-detection) for the `ReplayCache` SPI contract (including `ReplayCache.FailMode`), and [OIDC Relying Party Login § Session stores](/docs/topics/OidcRelyingParty#session-stores) for the same shared-store principle applied to session storage — a different SPI, but the same clustering requirement. + ## Marker claim, not subclass The returned [`ClaimsPrincipal`](/site/apidocs/org/apache/juneau/rest/server/auth/ClaimsPrincipal.html) is annotated with `issuerType=SAML` (rather than introducing a `SamlClaimsPrincipal` subclass) so downstream code can distinguish SAML-derived principals from JWT-derived principals without resorting to instanceof checks:
