Skip to main content

SAML 2.0 AuthN Support (juneau-rest-server-auth-saml)

The opt-in juneau-rest-server-auth-saml module adds SAML 2.0 single-sign-on (Web Browser SSO Profile) to juneau-rest-server by wrapping OpenSAML 5.x behind the AuthFilter / TokenValidator SPIs that ship in juneau-rest-server.

OpenSAML is an Apache 2.0-licensed library maintained by Shibboleth. It is declared in provided scope on the module's POM so the dependency does not bleed into juneau-rest-server — consumers explicitly pick the OpenSAML 5.x patch they want (default pin: 5.2.2).

At a glance​

ComponentPurpose
SamlAssertionValidatorValidates a SAML 2.0 <samlp:Response> document and returns a ClaimsPrincipal marked with issuerType=SAML.
SamlAuthFilterAuthFilter that decodes the inbound SAMLResponse form / query parameter and delegates validation.
SamlBindingEnum of supported HTTP bindings (POST, REDIRECT). Artifact + SOAP deferred.
SamlMetadataResolversConvenience factories (`file(File

Drop-in usage​

@Bean
public AuthFilterChain authFilters(BeanStore bs) throws IOException {

// 1) Build the validator. Pick one of:
// - .metadataResolver(SamlMetadataResolvers.url("https://idp.example.com/metadata"))
// - .signingCredential(myStaticCredential)
SamlAssertionValidator validator = SamlAssertionValidator.create()
.metadataResolver(SamlMetadataResolvers.url("https://idp.example.com/metadata"))
.spEntityId("https://sp.example.com")
.expectedIssuer("https://idp.example.com")
.build();

// 2) Wrap it in a SamlAuthFilter and attach to the chain.
return AuthFilterChain.create(bs)
.append(SamlAuthFilter.create()
.consumerPath("/saml/acs")
.binding(SamlBinding.POST)
.validator(validator)
.build())
.build();
}

JettyServerComponent auto-mounts the AuthFilterChain bean at /* once it is present in the BeanStore — no explicit addFilter(...) call needed.

Bindings​

SamlAuthFilter accepts SAML responses in either HTTP-flavored binding from OASIS SAML 2.0:

SamlBindingWire shapeDecode steps
POST (default)SAMLResponse form parameter, base64-encoded XML.base64-decode → UTF-8 string.
REDIRECTSAMLResponse query parameter, base64-encoded DEFLATE-compressed XML.base64-decode → raw DEFLATE-inflate → UTF-8 string.

Set the binding on the filter builder:

SamlAuthFilter.create()
.binding(SamlBinding.REDIRECT)
.validator(validator)
.build();

Artifact + SOAP bindings are deferred — if your IdP only emits those, see SamlAssertionValidator.validate(String) and decode the wire-format yourself, then call the validator directly.

Security defaults​

SamlAssertionValidator ships with deliberately strict defaults aligned with current SAML 2.0 hardening guidance:

DefaultSettingRationale
Signature algorithm allowlist[rsa-sha256, ecdsa-sha256]SHA-1 is permanently rejected. The builder will refuse a algorithms(...) value containing any SHA-1 URI.
Mandatory signature on every assertionyesUnsigned assertions are rejected.
SAMLSignatureProfileValidatoryesRuns before crypto verification to catch wrapping / structural attacks.
Audience restrictionrequired<AudienceRestriction> must list the configured spEntityId.
Clock skew60s default, 300s maxMirrors the JWT validator's defaults; the builder refuses larger values.
Encrypted assertionsopt-in via decryptionCredential(...)A response carrying <EncryptedAssertion> is rejected with a decryption_required challenge when no key is configured; a wrong key yields decryption_failed.

Replay protection in clustered deployments​

SamlAssertionValidator enforces one-time use of each assertion ID via a ReplayCache. The default is InMemoryReplayCache, which is per-process only — it's backed by an in-JVM map, so two nodes behind a load balancer don't see each other's recorded assertion IDs.

In a multi-node deployment, inject a ReplayCache backed by a store shared across every node (e.g. a distributed cache or database) via SamlAssertionValidator.Builder.replayCache(...), otherwise an attacker can replay the same assertion against a different node before it's been recorded there:

SamlAssertionValidator validator = SamlAssertionValidator.create()
.metadataResolver(SamlMetadataResolvers.url("https://idp.example.com/metadata"))
.spEntityId("https://sp.example.com")
.expectedIssuer("https://idp.example.com")
.replayCache(myClusteredReplayCache) // shared store across every node
.build();

The check is always fail-closed: a cache that can't answer causes the assertion to be rejected. See Concurrent Package § Replay Detection for the ReplayCache SPI contract (including ReplayCache.FailMode), and OIDC Relying Party Login § Session stores for the same shared-store principle applied to session storage — a different SPI, but the same clustering requirement.

Marker claim, not subclass​

The returned ClaimsPrincipal is annotated with issuerType=SAML (rather than introducing a SamlClaimsPrincipal subclass) so downstream code can distinguish SAML-derived principals from JWT-derived principals without resorting to instanceof checks:

@RestGet
public String hello(@Auth Principal principal) {
var cp = (ClaimsPrincipal) principal;
if ("SAML".equals(cp.getClaim("issuerType", String.class).orElse(null))) {
// SAML path
}
return "Hello, " + principal.getName();
}

Encrypted assertions​

The module ships support for <EncryptedAssertion>:

var validator = SamlAssertionValidator.create()
.metadataResolver(SamlMetadataResolvers.url("https://idp.example.com/metadata"))
.spEntityId("https://sp.example.com")
.expectedIssuer("https://idp.example.com")
.decryptionCredential(myDecryptionCredential)
.build();

The decrypter uses OpenSAML's opensaml-xmlsec-impl chain (InlineEncryptedKeyResolver + SimpleRetrievalMethodEncryptedKeyResolver). When a response contains <EncryptedAssertion> but no decryptionCredential(...) is configured, validation fails fast with an AuthenticationException carrying the WWW-Authenticate: SAML error="decryption_required" challenge.

Maven dependency​

OpenSAML 5.x is published to the Shibboleth Maven repository rather than Maven Central. The juneau-rest-server-auth-saml POM declares that repository locally so consumers don't need to configure it in their own settings.xml. Pin the version via the <opensaml.version> POM property (default 5.2.2; do not use 5.0.0).

<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-auth-saml</artifactId>
<version>${juneau.version}</version>
</dependency>
<dependency>
<groupId>org.opensaml</groupId>
<artifactId>opensaml-saml-impl</artifactId>
<version>5.2.2</version> <!-- provided scope; consumer-supplied -->
</dependency>

mvn -pl juneau-rest/juneau-rest-server dependency:tree | grep -i opensaml returns nothing — the containment requirement is verified at build time.

Deferred​

  • Artifact + SOAP bindings. Both are post-IdP-redirect server-to-server flows; rare in modern integrations. If your IdP requires either, file an issue with a worked test fixture.
  • Single Logout (SLO). Out of scope for v1 of this module — SLO is typically handled at the IdP layer rather than per-SP.

See also​