Skip to content

Fix NPE in SAML 2.0 configuration when OpenSAML is on the module path - #19784

Open
akashchamp wants to merge 1 commit into
spring-projects:mainfrom
akashchamp:gh-19628-opensaml-module-path
Open

akashchamp wants to merge 1 commit into
spring-projects:mainfrom
akashchamp:gh-19628-opensaml-module-path

Conversation

@akashchamp

Copy link
Copy Markdown

Fixes gh-19628

Problem

Saml2LoginConfigurer (and four sibling SAML 2.0 configuration classes) compute a static field via:

private static final boolean USE_OPENSAML_5 = Version.getVersion().startsWith("5");

org.opensaml.core.Version#getVersion() reads Package#getImplementationVersion(), which is null when OpenSAML is loaded as a named module from the Java module path rather than the classpath (confirmed below). Calling .startsWith("5") on that null result throws a NullPointerException during the class's static initializer, which permanently marks the class unusable for the lifetime of the JVM (ExceptionInInitializerError the first time, NoClassDefFoundError on every later reference) — so any application that loads OpenSAML from the module path cannot use SAML 2.0 login at all.

The exact same pattern (and therefore the exact same bug) is duplicated in five places:

  • Saml2LoginConfigurer
  • Saml2LogoutConfigurer
  • Saml2MetadataConfigurer
  • Saml2LoginBeanDefinitionParserUtils
  • Saml2LogoutBeanDefinitionParserUtils

Fix

Each of the five duplicates now computes the flag with a small null-safe helper, following the fallback the reporter suggested: if the package implementation version is unavailable, fall back to the owning module's descriptor version (available for a named/automatic module); if neither is available, default to assuming OpenSAML 5, since it's the only version this codebase currently supports, rather than crashing.

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");
}

The method takes the lookup class as a parameter (instead of hardcoding Version.class inside it) purely so it's directly unit-testable without needing to construct a real Java module layer in the test.

How this was verified

Reproduced the exact crash with the actual opensaml-core-api-5.2.3.jar from this project's own dependency graph, loading org.opensaml.core.Version first from the classpath, then as a module-path automatic module:

=== classpath mode ===
package implementation version = 5.2.3
NEW: no crash, useOpenSaml5=true

=== module-path mode ===
package implementation version = null
module = module org.opensaml.core, isNamed=true
module descriptor rawVersion = 5.2.3
OLD: NullPointerException (class init would crash) -> java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because the return value of "org.opensaml.core.Version.getVersion()" is null
NEW: no crash, useOpenSaml5=true

This confirms both halves of the reporter's diagnosis: the package version really is null under the module path, and the module descriptor fallback correctly recovers 5.2.3 from the automatic module's filename-derived version, matching the pre-fix classpath behavior with no regression.

Automated tests: added a regression test per file (10 total) that exercises useOpenSaml5(...):

  • with Version.class, asserting it still matches Version.getVersion().startsWith("5") on the normal test classpath (no behavior change)
  • with the test class itself (compiled to a plain classpath directory, so its package implementation version is genuinely null and it runs in the unnamed module) to prove the null-guard path no longer throws

Ran the full existing suites for the five affected files locally (Saml2LoginConfigurerTests, Saml2LogoutConfigurerTests, Saml2MetadataConfigurerTests, Saml2LoginBeanDefinitionParserTests, Saml2LogoutBeanDefinitionParserTests) — all passing, no change to existing behavior on a normal classpath.

AI disclosure

This change was implemented with AI assistance (Claude Code).

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 spring-projectsgh-19628

Signed-off-by: Akash Kumar <116457960+akashchamp@users.noreply.github.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Sep 24, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spring security becomes unusable when OpenSAML is on the module path

2 participants