diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index c99361d..d41077c 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -20,7 +20,12 @@ component { this.entryPoint = "/cbsso"; // Dependencies - this.dependencies = [ "hyper", "jwtcfml" ]; + // cbjavaloader is load-order critical, not merely installed: onLoad() hands it this module's /lib so the + // bundled OpenSAML jar becomes resolvable. A module cannot place that jar on the classpath itself - + // this.javaSettings is an Application.cfc setting, read before any module registers, and ColdBox does + // not merge a module's copy of it - so without cbjavaloader every consuming app would have to add + // cbsso's own lib path to its Application.cfc. + this.dependencies = [ "hyper", "jwtcfml", "cbjavaloader" ]; routes = [ { @@ -68,7 +73,15 @@ component { function onLoad(){ ensureSAMLRequestCache(); - // Register all app disks + // Must precede registerProviders(): a SAML provider resolves cbsso.opensaml.* out of this path. + // Guarded because /lib is a build artifact of the Gradle project in /java and is absent from a + // source checkout, where appendPaths would throw "Invalid library path". Skipping it there leaves a + // SAML provider to fail on first use with its own error rather than taking the application down. + var openSAMLLibPath = modulePath & "/lib"; + if ( directoryExists( openSAMLLibPath ) ) { + wirebox.getInstance( "loader@cbjavaloader" ).appendPaths( openSAMLLibPath ); + } + wirebox.getInstance( "ProviderService@cbsso" ).registerProviders(); if ( settings.enableCBAuthIntegration ) { diff --git a/changelog.md b/changelog.md index 282d78f..c04542e 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Resolve the bundled OpenSAML classes through cbjavaloader again, and append this module's `/lib` to it + in `onLoad()`, so the jar is reachable on an engine that does not carry it on the server classpath. +- Set the thread context classloader around OpenSAML initialisation and assertion verification, which + `ServiceLoader` reads to discover providers and crypto implementations on BoxLang. +- Track certificate readiness separately from library initialisation, so a transient metadata failure is + retried rather than leaving the validator permanently holding no certificates. +- Publish the generator and validator only once `initOpenSAML()` has returned, so a failed initialisation + cannot leave a provider unable to initialise for the life of the application. +- Throw `MicrosoftSAMLProvider.MissingConfiguration` when `federationMetadataURL` is unset, rather than + failing against an empty string on the user's first sign-in. + +### Changed + +- Initialise OpenSAML and fetch federation metadata on first use rather than during module registration, + so application boot no longer loads a 17MB jar and calls out to every configured IdP before serving a + request. +- Isolate each definition in `ProviderService.registerProviders()`, so one unbuildable provider no longer + costs the others, `onLoad()`, or the application boot. + ## [3.0.0] - 2026-08-25 ### Added diff --git a/models/ProviderService.cfc b/models/ProviderService.cfc index 8d8251e..024876d 100644 --- a/models/ProviderService.cfc +++ b/models/ProviderService.cfc @@ -10,23 +10,37 @@ component accessors="true" singleton threadsafe { variables.providers = {}; + /** + * One provider failing to build no longer aborts the rest, or the module's onLoad(), or the application + * boot that onLoad() runs inside. A definition can fail for reasons wholly outside this module - + * an unreachable IdP, a missing jar, a provider type that is not installed - and losing every other + * provider plus the whole application to it is out of all proportion. The failure is logged and that + * provider is left unregistered, so missing() reports it and Auth redirects to errorRedirect. + */ ProviderService function registerProviders(){ variables.moduleSettings.providers.each( function( providerDefinition ){ - var provider = wirebox.getInstance( providerDefinition.type ); - - for ( var setting in providerDefinition ) { - if ( !structKeyExists( provider, "set#setting#" ) ) { - continue; + try { + var provider = wirebox.getInstance( providerDefinition.type ); + + for ( var setting in providerDefinition ) { + if ( !structKeyExists( provider, "set#setting#" ) ) { + continue; + } + + invoke( + provider, + "set#setting#", + [ providerDefinition[ setting ] ] + ); } - invoke( - provider, - "set#setting#", - [ providerDefinition[ setting ] ] + providers[ provider.getName() ] = provider; + } catch ( any e ) { + log.error( + "Could not register SSO provider [#providerDefinition.name ?: providerDefinition.type ?: "unnamed"#] - it will be unavailable until the next successful registration", + { "error" : e.message, "detail" : e.detail ?: "" } ); } - - providers[ provider.getName() ] = provider; } ); return this; } diff --git a/models/providers/MicrosoftSAMLProvider.cfc b/models/providers/MicrosoftSAMLProvider.cfc index 0e426c8..7cd9de5 100644 --- a/models/providers/MicrosoftSAMLProvider.cfc +++ b/models/providers/MicrosoftSAMLProvider.cfc @@ -12,7 +12,8 @@ component property name="federationMetadataURL"; property name="expectedIssuer"; - property name="wirebox" inject="wirebox"; + property name="wirebox" inject="wirebox"; + property name="javaLoader" inject="loader@cbjavaloader"; property name="AuthNRequestGenerator"; property name="responseValidator"; property name="SAMLParsingService" inject="SAMLParsingService@cbsso"; @@ -21,17 +22,22 @@ component variables.name = "Entra"; variables.federationMetadataURL = ""; variables.maxDecodedResponseChars = 1048576; + variables.certificatesCached = false; public string function getName(){ return variables.name; } + /** + * Stores the URL and nothing more. It used to initialise OpenSAML and fetch the IdP's metadata here, + * but every setter on a provider runs inside ProviderService.registerProviders(), i.e. inside the + * module's onLoad() - so that turned application boot into "load a 17MB jar and make an outbound HTTPS + * call per configured provider", and any failure in either took the whole application down before it + * could serve a request. Both now happen on first use, via initializeOpenSAMLLib(). + */ public any function setFederationMetadataURL( required string federationMetadataURL ){ - variables.federationMetadataURL = federationMetadataURL; - - initializeOpenSAMLLib(); - - responseValidator.cacheCerts( variables.federationMetadataURL ); + variables.federationMetadataURL = arguments.federationMetadataURL; + variables.certificatesCached = false; return this; } @@ -89,13 +95,21 @@ component // IdP must have named; getRedirectUri() is the ACS URL, so it is the expected Recipient. // The final argument binds both the Response and the accepted bearer confirmation to this // outstanding AuthnRequest. - assertionXML = variables.responseValidator.parseAndValidateAssertion( - javacast( "string", data ), - javacast( "string", variables.expectedIssuer ), - javacast( "string", variables.clientId ), - javacast( "string", getRedirectUri( event ) ), - javacast( "string", responseInResponseTo ) - ); + // + // Needs the classloader context for verification specifically: SignatureValidator.validate + // resolves crypto providers through the thread context classloader, which on BoxLang is not + // the one the OpenSAML classes came from. Marshalling alone does not need it - + // getRawSAMLRequest() and getResponseInResponseTo() go through the same OpenSAML registry + // unwrapped and work - so do not widen this to "any OpenSAML call". + assertionXML = runWithClassLoader( function(){ + return variables.responseValidator.parseAndValidateAssertion( + javacast( "string", data ), + javacast( "string", variables.expectedIssuer ), + javacast( "string", variables.clientId ), + javacast( "string", getRedirectUri( event ) ), + javacast( "string", responseInResponseTo ) + ); + } ); // Do not consume a pending request until every validation step succeeds. The atomic consume // prevents two concurrent deliveries of the same valid response from both succeeding. @@ -156,16 +170,74 @@ component return binaryEncode( output, "base64" ); } + /** + * Resolved through cbjavaloader rather than createObject( "java", ... ): ModuleConfig hands the bundled + * jar to cbjavaloader's URLClassLoader, which createObject does not consult - it searches the server + * classpath and reports "has not been located in the [java] resolver". + * + * Two readiness conditions, guarded separately. The library is initialised once for the life of the + * application, but the certificates come from an outbound fetch that can fail on its own, so guarding + * both on the generator meant one transient metadata failure - which happens after the generator is + * published - left this provider short-circuiting on every later call with a validator holding no + * certificates, and no way back short of an application restart. + */ private void function initializeOpenSAMLLib(){ - if ( !isNull( variables.AuthNRequestGenerator ) ) { + if ( isNull( variables.AuthNRequestGenerator ) ) { + runWithClassLoader( function(){ + var generator = wirebox.getInstance( "javaloader:cbsso.opensaml.AuthNRequestGenerator" ); + var validator = wirebox.getInstance( "javaloader:cbsso.opensaml.AuthResponseValidator" ); + + generator.initOpenSAML(); + + // Published only once initOpenSAML() has returned. Assigning beforehand would let a failed + // initialisation leave a provider whose guard above short-circuits, so it could never + // initialise again for the life of the application. + variables.AuthNRequestGenerator = generator; + variables.responseValidator = validator; + } ); + } + + if ( variables.certificatesCached ) { return; } - variables.AuthNRequestGenerator = createObject( "java", "cbsso.opensaml.AuthNRequestGenerator" ); - variables.responseValidator = createObject( "java", "cbsso.opensaml.AuthResponseValidator" ); + // Reached lazily now rather than from setFederationMetadataURL(), so an unset URL arrives here + // instead of never getting this far. Named rather than left to fail inside cacheCerts, where an + // empty URL surfaces as an opaque fetch error on the user's first sign-in. + if ( !len( trim( variables.federationMetadataURL ) ) ) { + throw( + type = "MicrosoftSAMLProvider.MissingConfiguration", + message = "federationMetadataURL is required but not set", + detail = "Set it on the provider definition; it is the source of the signing certificates." + ); + } - variables.AuthNRequestGenerator.initOpenSAML(); - responseValidator.cacheCerts( variables.federationMetadataURL ); + variables.responseValidator.cacheCerts( variables.federationMetadataURL ); + + variables.certificatesCached = true; + } + + /** + * OpenSAML's InitializationService discovers its providers through ServiceLoader, which reads the + * *thread context* classloader. On BoxLang that is not cbjavaloader's URLClassLoader, so discovery finds + * nothing and initialisation fails; Adobe ColdFusion resolves it without help. Swapped only for the + * duration of the call, and restored in a finally so a failure cannot leak the wrong loader into the + * request thread. + */ + private any function runWithClassLoader( required function callback ){ + if ( !structKeyExists( server, "BoxLang" ) ) { + return callback(); + } + + var currentThread = createObject( "java", "java.lang.Thread" ).currentThread(); + var originalClassLoader = currentThread.getContextClassLoader(); + + try { + currentThread.setContextClassLoader( variables.javaLoader.getURLClassLoader() ); + return callback(); + } finally { + currentThread.setContextClassLoader( originalClassLoader ); + } } }