Skip to content

fix: restore OpenSAML class loading and make SAML parsing engine-portable - #19

Open
dougcain wants to merge 2 commits into
coldbox-modules:developmentfrom
dougcain:fix/boxlang-jar-loading-and-saml-parsing
Open

fix: restore OpenSAML class loading and make SAML parsing engine-portable#19
dougcain wants to merge 2 commits into
coldbox-modules:developmentfrom
dougcain:fix/boxlang-jar-loading-and-saml-parsing

Conversation

@dougcain

@dougcain dougcain commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #18. Tested against BoxLang 1.14.0+55 / ColdBox 7, MicrosoftSAMLProvider against Microsoft Entra.

On 3.0.0-snapshot as published, the consuming application could not boot at all. With these five changes a full SSO round trip completes — sign-in button → Entra → ACS → authenticated session.

Fixes #16 and #17 are confirmed working, and init()-seeding every property is a better fix than the per-getter defaults we had been carrying locally. Thank you for both.


1. The bundled jar is on no classpath — blocker

initializeOpenSAMLLib() resolves with createObject( "java", "cbsso.opensaml.AuthNRequestGenerator" ), which searches the server classpath. But cbjavaloader was dropped from this.dependencies and onLoad() no longer appends this module's /lib, so nothing ever puts the jar anywhere reachable. box.json still installs cbjavaloader, which is what makes this easy to miss.

ClassNotFoundBoxLangException: The requested class [cbsso.opensaml.AuthNRequestGenerator]
has not been located in the [java] resolver

  Application.cfc:152 (onApplicationStart)
  → ModuleService.activateAllModules:581
  → cbsso/ModuleConfig.cfc:67        onLoad()
  → models/ProviderService.cfc:14    registerProviders()
  → models/ProviderService.cfc:25    invoke( provider, "setFederationMetadataURL" )
  → MicrosoftSAMLProvider.cfc:30     setFederationMetadataURL()
  → MicrosoftSAMLProvider.cfc:119    createObject( "java", ... )

Restored: cbjavaloader in this.dependencies, appendPaths( modulePath & "/lib" ) in onLoad(), and resolution via the javaloader: DSL so the lookup uses the loader those paths were actually added to.

Why not this.javaSettings instead? It cannot work from a module. It is an Application.cfc setting read before any module registers, and ColdBox does not merge a module's copy — there is no reference to javaSettings anywhere in coldbox/system, and no installed module declares it. So "native" would mean every consuming application adding cbsso's own lib path to its Application.cfc, with a path that varies by install location. cbmarkdown solves the same problem the same way this PR does. There is also a real argument for keeping a 17MB OpenSAML/Santuario/Xerces/Guava bundle off the host application's classpath, where it can collide with the host's own versions.

2. Boot did the work at all — and one bad provider took the app with it

Every provider setter runs inside registerProviders(), inside onLoad(). So setFederationMetadataURL() calling initializeOpenSAMLLib() + cacheCerts() meant application boot loaded a 17MB jar and made an outbound HTTPS call to the IdP, per configured provider — with no try/catch anywhere in the chain. That is what escalated (1) from "SSO is broken" into "no request is served for any tenant".

Two changes, independent of each other and of (1):

  • setFederationMetadataURL() stores the URL and nothing else; initialisation happens on first use, as it did in 1.0.7.
  • registerProviders() isolates each definition, logs a failure, and leaves that provider unregistered — where missing() already reports it and Auth redirects to errorRedirect.

Worth calling out as a design question rather than a fix: this trades fail-fast for availability. In a multi-tenant app one tenant's unreachable IdP should not deny service to the others. Happy to drop this half if you would rather keep fail-fast.

3. Prefixed XPath does not resolve on BoxLang

SAMLParsingService.extractUserInfo() strips only the default namespace declaration, so xmlns:samlp survives on the document — which is exactly why the unprefixed //Attribute[…] queries work. But BoxLang's xmlSearch will not resolve //samlp:StatusCode against a prefix declared in the document.

So detectSuccess() returned false for a valid, signed, successful Entra assertion, and every login failed closed with no usable error. Both prefixed queries now match on local-name(), which behaves identically on every engine.

4. The invalid-response path never ran — engine-independent

processAuthorizationEvent()'s inner catch called extractErrorMessage( xmlData ), but xmlData ceased to exist when parsing moved to SAMLParsingService (it is samlData now). So the only path handling a failed signature or issuer check threw on an undefined variable, was swallowed by the outer catch, and reported that second failure instead of the validation failure. The statement was also missing its ;.

It now prefers the IdP's own status message where there is one and falls back to the validation error. The private extractErrorMessage() is deleted: unreachable, declared boolean while returning a string, and a duplicate of SAMLParsingService.extractErrorMessage().

This is the path that matters for a malformed or hostile response, so it is worth a test.

5. BoxLang needs the thread context classloader for SPI discovery

OpenSAML's InitializationService discovers providers through ServiceLoader, which reads the thread context classloader rather than the one the classes were loaded from. On BoxLang that is not cbjavaloader's URLClassLoader, so discovery finds nothing. Set around initialisation and validation, restored in a finally so a failure cannot leak the wrong loader into the request thread. Adobe ColdFusion resolves it unaided and skips the swap entirely.

This is a consequence of the classloader isolation in (1), and confirmed still necessary against 3.0.0's rebuilt jar.


Verification

  • Full SSO round trip against Entra on BoxLang: boot clean, sign-in redirect carries a valid signed SAMLRequest, ACS authenticates, session established.
  • Boot no longer performs Java class loading or IdP network I/O.
  • A bare GET of the ACS URL returns a redirect rather than a 500.
  • box cfformat check clean on all changed files.

Item 4 is correct by inspection but not exercised at runtime — a successful login never enters that catch, and I did not force a signature failure. Flagging that rather than implying otherwise.

One note for the release, not fixed here

The getRawResponseData() return-type change and the move of the provider guard into a preHandler both alter contracts that consumer specs pin — invoking an action directly no longer runs the guard, and the actions have no guard of their own. Both are reasonable changes; they just warrant a line in the release notes so consumers know to update.

…able

Tested against BoxLang 1.14.0+55 / ColdBox 7 with MicrosoftSAMLProvider and Microsoft Entra.
On 3.0.0-snapshot the reports application could not boot at all; with these changes a full
SSO round trip completes.

The jar was on no classpath. initializeOpenSAMLLib() used
createObject( "java", "cbsso.opensaml.AuthNRequestGenerator" ), which searches the server
classpath, while cbjavaloader had been dropped from this.dependencies and onLoad() no longer
appended this module's /lib. Boot died with

  ClassNotFoundBoxLangException: The requested class [cbsso.opensaml.AuthNRequestGenerator]
  has not been located in the [java] resolver

from ModuleConfig.onLoad() -> registerProviders() -> setFederationMetadataURL() ->
initializeOpenSAMLLib(). Restored: cbjavaloader as a module dependency, appendPaths in
onLoad(), and resolution through the javaloader: DSL so the lookup uses the loader those paths
were added to. this.javaSettings cannot replace it — it is an Application.cfc setting read
before any module registers and ColdBox does not merge a module's copy, so the alternative is
every consuming app adding cbsso's lib path to its own Application.cfc. cbmarkdown solves the
same problem the same way.

Boot no longer does the work at all. setFederationMetadataURL() ran inside registerProviders(),
inside onLoad(), so booting the application loaded a 17MB jar and made an outbound HTTPS call to
the IdP per configured provider — and any failure in either took the whole application down
before it served a request. Initialisation is lazy again, and registerProviders() isolates each
definition so one unbuildable provider no longer costs the others, onLoad(), or the boot.

BoxLang needs the thread context classloader. OpenSAML's InitializationService discovers
providers via ServiceLoader, which reads the thread context classloader rather than the one the
classes came from, so discovery found nothing. Set around initialisation and validation, restored
in a finally. Adobe ColdFusion resolves it unaided and skips the swap.

Prefixed XPath does not resolve on BoxLang. extractUserInfo() strips only the default namespace
declaration, so xmlns:samlp survives — which is why the unprefixed //Attribute[...] queries work
— but BoxLang's xmlSearch will not resolve //samlp:StatusCode against a prefix declared in the
document. detectSuccess() therefore returned false for a valid, signed, successful assertion and
every login failed closed. Both prefixed queries now match on local-name().

The invalid-response path never ran. processAuthorizationEvent()'s catch called
extractErrorMessage( xmlData ), but xmlData ceased to exist when parsing moved to
SAMLParsingService, so the one path handling a failed signature or issuer check threw on an
undefined variable and reported that instead. It now prefers the IdP's own status message and
falls back to the validation error. The private extractErrorMessage() is deleted — unreachable,
declared boolean while returning a string, and a duplicate of the service's method.

Not covered here: the getRawResponseData() and preHandler changes in 3.0.0 alter contracts that
existing consumer specs pin, which is worth a note in the release.

The appendPaths call is guarded on directoryExists. /lib is a build artifact of the Gradle
project in /java and is absent from a source checkout, where cbjavaloader throws "Invalid
library path" - which broke the test harness on every engine. Skipping it there leaves a SAML
provider to fail on first use with its own error instead of taking the application down.
@dougcain
dougcain force-pushed the fix/boxlang-jar-loading-and-saml-parsing branch from 6b06a20 to c338553 Compare August 4, 2026 21:52
);
// Signature verification pulls in OpenSAML's crypto providers, discovered through the same
// ServiceLoader mechanism as initialisation, so it needs the same classloader context.
runWithClassLoader( function(){

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dougcain why does this code get called a second time after initializeOpenSAMLLib(); is already used earlier in the function? Why not just call initializeOpenSAMLLib(); again if it is necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right that it's redundant, and it's removed in c976525.

For the record on where it came from: it predates both #16 and #17. It arrived with 7cf0359 ("Verify SAML responses using OpenSAML") and was moved around by c8a56b0 / 86f2021; #18 never touched this file. My PR only wrapped what was already there, which is why it looked deliberate.

It really is a no-op, from the jar rather than from reading the CFML:

public static synchronized void initOpenSAML()
  0: getstatic  Field initialized:Z
  3: ifeq       7
  6: return

Static, synchronized, guarded by a static flag. Worth noting your suggested alternative wouldn't have done anything either — initializeOpenSAMLLib() early-returns on !isNull( variables.AuthNRequestGenerator ), and even past that guard initOpenSAML() short-circuits.

One thing the redundant call was quietly doing, though. The old ordering assigned variables.AuthNRequestGenerator before calling initOpenSAML(). So if initialisation threw, the guard at the top of initializeOpenSAMLLib() would short-circuit from then on and the provider could never initialise again for the life of the application — the second call was providing the retry. So rather than just deleting it, initialisation now publishes the objects only once initOpenSAML() has returned. Same effect, without depending on a duplicate call to recover.

The runWithClassLoader around parseAndValidate stays, but my justification for it was wrong and I've corrected the comment. I'd written that it was needed because parseAndValidate reads the provider registry through XMLObjectProviderRegistrySupport — but getRawSAMLRequest() goes through that same registry unwrapped and works fine, which disproves it. The real reason is narrower: verifySignatureSignatureValidator.validate resolves crypto providers through the thread context classloader, which on BoxLang isn't the one the OpenSAML classes were loaded from. The comment now says that, and warns against both wrong conclusions (wrap everything / remove it).

Two other things came out of chasing this, both in the same commit:

  • cacheCerts() is now reachable with an unset federationMetadataURL, since initialisation is lazy and no longer runs only from the setter. It throws a named MicrosoftSAMLProvider.MissingConfiguration instead of failing against an empty string on a user's first sign-in.
  • Changelog updated for that.

Re-verified end to end on BoxLang 1.14.0+55 / ColdBox 7 against Entra after a cold boot: registration does no jar loading or IdP fetch, sign-in redirects with a valid signed SAMLRequest, ACS authenticates. Worth flagging that CI can't cover any of this — the repo has no lib/, so the SAML provider path never executes there.

… error

Addresses review feedback on the duplicate initOpenSAML() call, plus three things found
while establishing whether it was safe to remove.

initOpenSAML() is static, synchronized and guarded by a static `initialized` flag, so the
call inside processAuthorizationEvent was a no-op once initialisation had happened. Removed.

Removing it plainly would have lost something accidental, though: the old ordering assigned
variables.AuthNRequestGenerator before calling initOpenSAML(), so an initialisation failure
left a provider whose guard short-circuits, unable to initialise for the life of the
application — the redundant second call was silently providing the retry. Initialisation now
publishes the objects only after initOpenSAML() returns, which removes the need for a retry
rather than depending on one.

That same guard also stood in front of the certificate fetch, which fails independently and
for different reasons. cacheCerts() runs after the generator is published, so one transient
metadata failure — or an IdP unreachable at the moment of the first sign-in — satisfied the
guard on every later call, leaving a validator holding no certificates and every subsequent
login failing on a signature it had nothing to check against, recoverable only by restarting
the application. Library initialisation and certificate readiness are therefore tracked
separately: the library initialises once, the certificates are re-fetched on each call until a
fetch succeeds. setFederationMetadataURL() clears the flag, so re-registering a provider
against a different IdP refetches rather than validating against the previous IdP's
certificates.

The comment on the remaining runWithClassLoader cited the OpenSAML provider registry as its
reason. getRawSAMLRequest() reaches that same registry unwrapped and works, so the stated
reason was wrong and invited either widening the wrap to every OpenSAML call or removing it
as unnecessary. It now names what actually needs the context: crypto provider resolution in
SignatureValidator.validate.

cacheCerts() is reachable with an unset federationMetadataURL now that initialisation is
lazy — it previously ran only from the setter, which by definition had a value. Throws a
named MissingConfiguration error instead of failing against an empty string on the user's
first sign-in.

Verified on BoxLang 1.14.0+55 / ColdBox 7 against Microsoft Entra: full round trip after a
cold boot.
@dougcain
dougcain force-pushed the fix/boxlang-jar-loading-and-saml-parsing branch from 7447aae to 1b8c940 Compare August 5, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auth handler's missing-provider guard is unreachable: ProviderService.get() throws before isNull() can fire

2 participants