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
| Component | Purpose |
|---|---|
SamlAssertionValidator | Validates a SAML 2.0 <samlp:Response> document and returns a ClaimsPrincipal marked with issuerType=SAML. |
SamlAuthFilter | AuthFilter that decodes the inbound SAMLResponse form / query parameter and delegates validation. |
SamlBinding | Enum of supported HTTP bindings (POST, REDIRECT). Artifact + SOAP deferred. |
SamlMetadataResolvers | Convenience 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:
SamlBinding | Wire shape | Decode steps |
|---|---|---|
POST (default) | SAMLResponse form parameter, base64-encoded XML. | base64-decode → UTF-8 string. |
REDIRECT | SAMLResponse 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:
| Default | Setting | Rationale |
|---|---|---|
| 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 assertion | yes | Unsigned assertions are rejected. |
SAMLSignatureProfileValidator | yes | Runs before crypto verification to catch wrapping / structural attacks. |
| Audience restriction | required | <AudienceRestriction> must list the configured spEntityId. |
| Clock skew | 60s default, 300s max | Mirrors the JWT validator's defaults; the builder refuses larger values. |
| Encrypted assertions | opt-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
- AuthN Filter Framework — the SPI both this module's filter and the bundled bearer/api-key filters implement.
- AuthN Guards — the op-level guard family that composes with this framework (
ClaimsPrincipal,TokenValidator, etc. live there). - OAuth AuthN Support — the sibling OAuth 2.0 / OIDC module.
- OASIS SAML 2.0 Core, SAML 2.0 Bindings.