From 661e8d4cff8ab6546fd57d37858b647ce420831e Mon Sep 17 00:00:00 2001 From: Andrey Litvitski Date: Sat, 2 May 2026 17:38:59 +0300 Subject: [PATCH] Add OneTimeUse support for SAML assertions This change will allow us to add `` elements out of the box. To do this, we can pass the cache through `OpenSaml5AuthenticationProvider.AssertionValidator.Builder#replayCache` and add the condition itself directly when building the builder. I also created a package-private `SpringCacheReplayCache` that implements `ReplayCache` and is needed to work with the constructor of `OneTimeUseConditionValidator`. Closes: gh-19130 Signed-off-by: Andrey Litvitski --- .../servlet/saml2/login/authentication.adoc | 26 ++++++++++ .../OpenSaml5AuthenticationProvider.java | 29 ++++++++++- .../SpringCacheReplayCache.java | 49 +++++++++++++++++++ .../OpenSaml5AuthenticationProviderTests.java | 19 +++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/SpringCacheReplayCache.java diff --git a/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc b/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc index c9df44e9c6c..67cf8b3198f 100644 --- a/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc +++ b/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc @@ -483,6 +483,32 @@ provider.setAssertionValidator(assertionValidator) ---- ====== +Also, you can use `AssertionValidator.Builder#replayCache` to have Spring Security configure `OneTimeUseConditionValidator` for you: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); +AssertionValidator assertionValidator = AssertionValidator.builder() + .replayCache(cache).build(); +provider.setAssertionValidator(assertionValidator); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +val provider = OpenSaml5AuthenticationProvider() +val assertionValidator = AssertionValidator.builder() + .replayCache(cache).build() +provider.setAssertionValidator(assertionValidator) +---- + +====== + [[servlet-saml2login-opensamlauthenticationprovider-decryption]] == Customizing Decryption diff --git a/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java b/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java index 2fee31f67ef..9591a4ab81e 100644 --- a/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java @@ -42,6 +42,7 @@ import org.opensaml.saml.saml2.assertion.impl.AudienceRestrictionConditionValidator; import org.opensaml.saml.saml2.assertion.impl.BearerSubjectConfirmationValidator; import org.opensaml.saml.saml2.assertion.impl.DelegationRestrictionConditionValidator; +import org.opensaml.saml.saml2.assertion.impl.OneTimeUseConditionValidator; import org.opensaml.saml.saml2.assertion.impl.ProxyRestrictionConditionValidator; import org.opensaml.saml.saml2.core.Assertion; import org.opensaml.saml.saml2.core.Condition; @@ -57,6 +58,7 @@ import org.opensaml.xmlsec.signature.support.SignaturePrevalidator; import org.opensaml.xmlsec.signature.support.SignatureTrustEngine; +import org.springframework.cache.Cache; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationProvider; @@ -109,6 +111,7 @@ * asserting party, IDP, verification certificates. * * @author Josh Cummings + * @author Andrey Litvitski * @since 5.5 * @see SAML 2 @@ -737,10 +740,11 @@ public static final class Builder { private final Map validationParameters = new HashMap<>(); + @Nullable private Cache replayCache; + private Builder() { this.conditions.add(new AudienceRestrictionConditionValidator()); this.conditions.add(new DelegationRestrictionConditionValidator()); - this.conditions.add(new ValidConditionValidator(OneTimeUse.DEFAULT_ELEMENT_NAME)); this.conditions.add(new ProxyRestrictionConditionValidator()); this.subjects.add(new BearerSubjectConfirmationValidator()); this.validationParameters.put(SAML2AssertionValidationParameters.CLOCK_SKEW, Duration.ofMinutes(5)); @@ -819,11 +823,34 @@ public Builder subjectValidators(Consumer> su return this; } + /** + * Use this {@link Cache} to validate {@code } conditions in + * SAML assertions. When set, assertions with a {@code } + * condition will be rejected if the assertion ID has already been seen and + * has not yet expired. + *

+ * If not set, {@code } conditions are skipped. + * @param cache the {@link Cache} to use for replay detection + * @return the {@link Builder} for further configuration + */ + public Builder replayCache(Cache cache) { + Assert.notNull(cache, "cache cannot be null"); + this.replayCache = cache; + return this; + } + /** * Build the {@link AssertionValidator} * @return the {@link AssertionValidator} */ public AssertionValidator build() { + if (this.replayCache != null) { + this.conditions + .add(new OneTimeUseConditionValidator(new SpringCacheReplayCache(this.replayCache), null)); + } + else { + this.conditions.add(new ValidConditionValidator(OneTimeUse.DEFAULT_ELEMENT_NAME)); + } AssertionValidator validator = new AssertionValidator(new ValidSignatureAssertionValidator( this.conditions, this.subjects, List.of(), null, null, null)); validator.setValidationContextParameters((params) -> params.putAll(this.validationParameters)); diff --git a/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/SpringCacheReplayCache.java b/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/SpringCacheReplayCache.java new file mode 100644 index 00000000000..991d4a5cad2 --- /dev/null +++ b/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/SpringCacheReplayCache.java @@ -0,0 +1,49 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.saml2.provider.service.authentication; + +import java.time.Instant; + +import org.opensaml.storage.ReplayCache; + +import org.springframework.cache.Cache; + +/** + * For internal use only. + */ +final class SpringCacheReplayCache implements ReplayCache { + + private final Cache cache; + + SpringCacheReplayCache(Cache cache) { + this.cache = cache; + } + + @Override + public boolean check(String context, String key, Instant expires) { + Cache.ValueWrapper existing = this.cache.get(context); + if (existing != null) { + Instant storedExpiry = (Instant) existing.get(); + if (storedExpiry != null && Instant.now().isBefore(storedExpiry)) { + return false; + } + } + this.cache.put(context, expires); + return true; + } + +} diff --git a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java index 01eeafbe168..335ab33b117 100644 --- a/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java +++ b/saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java @@ -70,6 +70,8 @@ import org.opensaml.xmlsec.signature.support.SignatureConstants; import tools.jackson.databind.json.JsonMapper; +import org.springframework.cache.Cache; +import org.springframework.cache.concurrent.ConcurrentMapCache; import org.springframework.core.convert.converter.Converter; import org.springframework.security.authentication.SecurityAssertions; import org.springframework.security.core.Authentication; @@ -105,6 +107,7 @@ * * @author Filip Hanik * @author Josh Cummings + * @author Andrey Litvitski */ public class OpenSaml5AuthenticationProviderTests { @@ -225,6 +228,22 @@ public void authenticateWhenUsernameMissingThenThrowAuthenticationException() { .satisfies(errorOf(Saml2ErrorCodes.SUBJECT_NOT_FOUND)); } + @Test + public void authenticateWhenOneTimeUseAssertionReusedThenThrowAuthenticationException() { + Response response = response(); + Assertion assertion = assertion(); + OneTimeUse oneTimeUse = build(OneTimeUse.DEFAULT_ELEMENT_NAME); + assertion.getConditions().getConditions().add(oneTimeUse); + response.getAssertions().add(signed(assertion)); + Cache cache = new ConcurrentMapCache("saml2"); + OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider(); + provider.setAssertionValidator(AssertionValidator.builder().replayCache(cache).build()::validate); + Saml2AuthenticationToken token = token(response, verifying(registration())); + provider.authenticate(token); + assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token)) + .satisfies(errorOf(Saml2ErrorCodes.INVALID_ASSERTION)); + } + @Test public void authenticateWhenAssertionContainsValidationAddressThenItSucceeds() { Response response = response();