From a143c5fa1899dfe5bdc2f61a67d36ff2abfa09b4 Mon Sep 17 00:00:00 2001 From: Akash Kumar <116457960+akashchamp@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:07:48 +0530 Subject: [PATCH] Fix NPE in SAML 2.0 configuration when OpenSAML is on the module path Version.getVersion() relies on Package#getImplementationVersion(), which is null when OpenSAML is loaded as a named module from the Java module path (instead of the classpath). Since USE_OPENSAML_5 was computed as Version.getVersion().startsWith("5") in a static field initializer, this threw a NullPointerException at class-init time, permanently marking the class unusable for the lifetime of the JVM (ExceptionInInitializerError -> NoClassDefFoundError on every subsequent reference). This same pattern was duplicated identically in five places: Saml2LoginConfigurer, Saml2LogoutConfigurer, Saml2MetadataConfigurer, Saml2LoginBeanDefinitionParserUtils, and Saml2LogoutBeanDefinitionParserUtils. All five are fixed the same way: fall back to the class's module descriptor version when the package implementation version is unavailable, and default to assuming OpenSAML 5 (the only version this codebase supports) when neither can be determined, instead of crashing. Closes gh-19628 Signed-off-by: Akash Kumar <116457960+akashchamp@users.noreply.github.com> --- .../saml2/Saml2LoginConfigurer.java | 22 ++++++++++++++++++- .../saml2/Saml2LogoutConfigurer.java | 17 +++++++++++++- .../saml2/Saml2MetadataConfigurer.java | 17 +++++++++++++- .../Saml2LoginBeanDefinitionParserUtils.java | 17 +++++++++++++- .../Saml2LogoutBeanDefinitionParserUtils.java | 17 +++++++++++++- .../saml2/Saml2LoginConfigurerTests.java | 14 ++++++++++++ .../saml2/Saml2LogoutConfigurerTests.java | 14 ++++++++++++ .../saml2/Saml2MetadataConfigurerTests.java | 15 +++++++++++++ .../Saml2LoginBeanDefinitionParserTests.java | 15 +++++++++++++ .../Saml2LogoutBeanDefinitionParserTests.java | 15 +++++++++++++ 10 files changed, 158 insertions(+), 5 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java index 5f9f3b62464..32387674d25 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -117,7 +117,7 @@ public final class Saml2LoginConfigurer> extends AbstractAuthenticationFilterConfigurer, Saml2WebSsoAuthenticationFilter> { - private static final boolean USE_OPENSAML_5 = Version.getVersion().startsWith("5"); + private static final boolean USE_OPENSAML_5 = useOpenSaml5(Version.class); private String loginPage; @@ -521,6 +521,26 @@ private void setSharedObject(B http, Class clazz, C object) { } } + /** + * Determine whether OpenSAML 5 is on the classpath (or module path). OpenSAML's + * {@link Version#getVersion()} relies on {@link Package#getImplementationVersion()}, + * which is {@code null} when OpenSAML is loaded as a named module from the Java + * module path. In that case, fall back to the module's descriptor version. When + * neither is available, assume OpenSAML 5 since it is the only version supported. + * @param versionClass a class from the {@code org.opensaml.core} package, used to + * look up the package/module version + * @return {@code true} if OpenSAML 5 is in use (or its version could not be + * determined), {@code false} otherwise + */ + static boolean useOpenSaml5(Class versionClass) { + String version = versionClass.getPackage().getImplementationVersion(); + if (version == null) { + Module module = versionClass.getModule(); + version = module.isNamed() ? module.getDescriptor().rawVersion().orElse(null) : null; + } + return version == null || version.startsWith("5"); + } + static class PathQueryRequestMatcher implements RequestMatcher { private final RequestMatcher matcher; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java index 0bc69130603..6bfcc14af79 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java @@ -111,7 +111,7 @@ public final class Saml2LogoutConfigurer> extends AbstractHttpConfigurer, H> { - private static final boolean USE_OPENSAML_5 = Version.getVersion().startsWith("5"); + private static final boolean USE_OPENSAML_5 = useOpenSaml5(Version.class); private ApplicationContext context; @@ -307,6 +307,21 @@ private C getBeanOrNull(Class clazz) { return this.context.getBeanProvider(clazz).getIfAvailable(); } + /** + * {@link Version#getVersion()} relies on {@link Package#getImplementationVersion()}, + * which is {@code null} when OpenSAML is loaded as a named module from the Java + * module path. In that case, fall back to the module's descriptor version. When + * neither is available, assume OpenSAML 5 since it is the only version supported. + */ + static boolean useOpenSaml5(Class versionClass) { + String version = versionClass.getPackage().getImplementationVersion(); + if (version == null) { + Module module = versionClass.getModule(); + version = module.isNamed() ? module.getDescriptor().rawVersion().orElse(null) : null; + } + return version == null || version.startsWith("5"); + } + /** * A configurer for SAML 2.0 LogoutRequest components */ diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java index 00da0e71677..5dc8110ef15 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurer.java @@ -76,7 +76,7 @@ public class Saml2MetadataConfigurer> extends AbstractHttpConfigurer, H> { - private static final boolean USE_OPENSAML_5 = Version.getVersion().startsWith("5"); + private static final boolean USE_OPENSAML_5 = useOpenSaml5(Version.class); private final ApplicationContext context; @@ -176,4 +176,19 @@ private C getBeanOrNull(Class clazz) { return this.context.getBeanProvider(clazz).getIfAvailable(); } + /** + * {@link Version#getVersion()} relies on {@link Package#getImplementationVersion()}, + * which is {@code null} when OpenSAML is loaded as a named module from the Java + * module path. In that case, fall back to the module's descriptor version. When + * neither is available, assume OpenSAML 5 since it is the only version supported. + */ + static boolean useOpenSaml5(Class versionClass) { + String version = versionClass.getPackage().getImplementationVersion(); + if (version == null) { + Module module = versionClass.getModule(); + version = module.isNamed() ? module.getDescriptor().rawVersion().orElse(null) : null; + } + return version == null || version.startsWith("5"); + } + } diff --git a/config/src/main/java/org/springframework/security/config/http/Saml2LoginBeanDefinitionParserUtils.java b/config/src/main/java/org/springframework/security/config/http/Saml2LoginBeanDefinitionParserUtils.java index 28fd962824d..f5dd47a46c1 100644 --- a/config/src/main/java/org/springframework/security/config/http/Saml2LoginBeanDefinitionParserUtils.java +++ b/config/src/main/java/org/springframework/security/config/http/Saml2LoginBeanDefinitionParserUtils.java @@ -40,7 +40,7 @@ */ final class Saml2LoginBeanDefinitionParserUtils { - private static final boolean USE_OPENSAML_5 = Version.getVersion().startsWith("5"); + private static final boolean USE_OPENSAML_5 = useOpenSaml5(Version.class); private static final String ATT_RELYING_PARTY_REGISTRATION_REPOSITORY_REF = "relying-party-registration-repository-ref"; @@ -120,4 +120,19 @@ static BeanDefinition createDefaultAuthenticationConverter(BeanMetadataElement r .getBeanDefinition(); } + /** + * {@link Version#getVersion()} relies on {@link Package#getImplementationVersion()}, + * which is {@code null} when OpenSAML is loaded as a named module from the Java + * module path. In that case, fall back to the module's descriptor version. When + * neither is available, assume OpenSAML 5 since it is the only version supported. + */ + static boolean useOpenSaml5(Class versionClass) { + String version = versionClass.getPackage().getImplementationVersion(); + if (version == null) { + Module module = versionClass.getModule(); + version = module.isNamed() ? module.getDescriptor().rawVersion().orElse(null) : null; + } + return version == null || version.startsWith("5"); + } + } diff --git a/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserUtils.java b/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserUtils.java index d07e2c5d834..a8b3f2f8c11 100644 --- a/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserUtils.java +++ b/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserUtils.java @@ -38,7 +38,7 @@ */ final class Saml2LogoutBeanDefinitionParserUtils { - private static final boolean USE_OPENSAML_5 = Version.getVersion().startsWith("5"); + private static final boolean USE_OPENSAML_5 = useOpenSaml5(Version.class); private static final String ATT_RELYING_PARTY_REGISTRATION_REPOSITORY_REF = "relying-party-registration-repository-ref"; @@ -124,4 +124,19 @@ static BeanMetadataElement getLogoutRequestResolver(Element element, BeanMetadat "Spring Security does not support OpenSAML " + Version.getVersion() + ". Please use OpenSAML 5"); } + /** + * {@link Version#getVersion()} relies on {@link Package#getImplementationVersion()}, + * which is {@code null} when OpenSAML is loaded as a named module from the Java + * module path. In that case, fall back to the module's descriptor version. When + * neither is available, assume OpenSAML 5 since it is the only version supported. + */ + static boolean useOpenSaml5(Class versionClass) { + String version = versionClass.getPackage().getImplementationVersion(); + if (version == null) { + Module module = versionClass.getModule(); + version = module.isNamed() ? module.getDescriptor().rawVersion().orElse(null) : null; + } + return version == null || version.startsWith("5"); + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java index 36be1a2ea57..69112bce8be 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.opensaml.core.Version; import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; import org.opensaml.core.xml.io.Marshaller; import org.opensaml.saml.saml2.core.Assertion; @@ -431,6 +432,19 @@ public void saml2LoginWhenCustomAuthenticationProviderThenUses() throws Exceptio verify(provider).authenticate(any()); } + // gh-19628 + @Test + public void useOpenSaml5WhenImplementationVersionUnavailableThenDoesNotThrow() { + // simulates OpenSAML being loaded from the Java module path, where + // Package#getImplementationVersion() returns null + assertThat(Saml2LoginConfigurer.useOpenSaml5(getClass())).isTrue(); + } + + @Test + public void useOpenSaml5WhenOpenSamlVersionClassThenMatchesRuntimeVersion() { + assertThat(Saml2LoginConfigurer.useOpenSaml5(Version.class)).isEqualTo(Version.getVersion().startsWith("5")); + } + private void performSaml2Login(String expected) throws IOException, ServletException { // setup authentication parameters this.request.setRequestURI("/login/saml2/sso/registration-id"); diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java index 11450950a14..f5038750525 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurerTests.java @@ -30,6 +30,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.opensaml.core.Version; import org.opensaml.saml.saml2.core.LogoutRequest; import org.opensaml.xmlsec.signature.support.SignatureConstants; @@ -557,6 +558,19 @@ public void saml2LogoutWhenLogoutFilterPostProcessedThenUses() { } + // gh-19628 + @Test + public void useOpenSaml5WhenImplementationVersionUnavailableThenDoesNotThrow() { + // simulates OpenSAML being loaded from the Java module path, where + // Package#getImplementationVersion() returns null + assertThat(Saml2LogoutConfigurer.useOpenSaml5(getClass())).isTrue(); + } + + @Test + public void useOpenSaml5WhenOpenSamlVersionClassThenMatchesRuntimeVersion() { + assertThat(Saml2LogoutConfigurer.useOpenSaml5(Version.class)).isEqualTo(Version.getVersion().startsWith("5")); + } + private T getBean(Class clazz) { return this.spring.getContext().getBean(clazz); } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java index ea59a1b9ffb..afebb1a6326 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2MetadataConfigurerTests.java @@ -20,6 +20,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.opensaml.core.Version; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; @@ -42,6 +43,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.test.web.servlet.MockMvc; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -134,6 +136,19 @@ void saml2MetadataWhenBuilderBeanWithBasePathThenMetadataUrlIgnoresBasePath() th .andExpect(content().string(containsString("md:EntityDescriptor"))); } + // gh-19628 + @Test + void useOpenSaml5WhenImplementationVersionUnavailableThenDoesNotThrow() { + // simulates OpenSAML being loaded from the Java module path, where + // Package#getImplementationVersion() returns null + assertThat(Saml2MetadataConfigurer.useOpenSaml5(getClass())).isTrue(); + } + + @Test + void useOpenSaml5WhenOpenSamlVersionClassThenMatchesRuntimeVersion() { + assertThat(Saml2MetadataConfigurer.useOpenSaml5(Version.class)).isEqualTo(Version.getVersion().startsWith("5")); + } + @EnableWebSecurity @Configuration @Import(RelyingPartyRegistrationConfig.class) diff --git a/config/src/test/java/org/springframework/security/config/http/Saml2LoginBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/Saml2LoginBeanDefinitionParserTests.java index cf0dc447d0e..22e11b110a5 100644 --- a/config/src/test/java/org/springframework/security/config/http/Saml2LoginBeanDefinitionParserTests.java +++ b/config/src/test/java/org/springframework/security/config/http/Saml2LoginBeanDefinitionParserTests.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.opensaml.core.Version; import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; import org.opensaml.core.xml.io.Marshaller; import org.opensaml.saml.saml2.core.Assertion; @@ -346,6 +347,20 @@ public void authenticateWhenCustomLoginProcessingUrlAndCustomAuthenticationConve verify(this.authenticationConverter).convert(any(HttpServletRequest.class)); } + // gh-19628 + @Test + public void useOpenSaml5WhenImplementationVersionUnavailableThenDoesNotThrow() { + // simulates OpenSAML being loaded from the Java module path, where + // Package#getImplementationVersion() returns null + assertThat(Saml2LoginBeanDefinitionParserUtils.useOpenSaml5(getClass())).isTrue(); + } + + @Test + public void useOpenSaml5WhenOpenSamlVersionClassThenMatchesRuntimeVersion() { + assertThat(Saml2LoginBeanDefinitionParserUtils.useOpenSaml5(Version.class)) + .isEqualTo(Version.getVersion().startsWith("5")); + } + private RelyingPartyRegistration relyingPartyRegistrationWithVerifyingCredential() { given(this.repository.findByRegistrationId(anyString())).willReturn(registration); return registration; diff --git a/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java b/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java index 7e1d649888e..936e064f576 100644 --- a/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java +++ b/config/src/test/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParserTests.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.opensaml.core.Version; import org.opensaml.saml.saml2.core.LogoutRequest; import org.opensaml.xmlsec.signature.support.SignatureConstants; @@ -399,6 +400,20 @@ public void saml2LogoutWhenCustomLogoutRequestRepositoryThenUses() throws Except verify(getBean(Saml2LogoutRequestRepository.class)).saveLogoutRequest(eq(logoutRequest), any(), any()); } + // gh-19628 + @Test + public void useOpenSaml5WhenImplementationVersionUnavailableThenDoesNotThrow() { + // simulates OpenSAML being loaded from the Java module path, where + // Package#getImplementationVersion() returns null + assertThat(Saml2LogoutBeanDefinitionParserUtils.useOpenSaml5(getClass())).isTrue(); + } + + @Test + public void useOpenSaml5WhenOpenSamlVersionClassThenMatchesRuntimeVersion() { + assertThat(Saml2LogoutBeanDefinitionParserUtils.useOpenSaml5(Version.class)) + .isEqualTo(Version.getVersion().startsWith("5")); + } + private T getBean(Class clazz) { return this.spring.getContext().getBean(clazz); }