Skip to content
This repository was archived by the owner on Oct 22, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ In core the SAML code is mostly related to:
- **SP Endpoint Hostname/Port**: `localhost:8443`
- **Validation Type**: Select `Only Assertion` if Azure is configured to sign assertions only (default).
If your Entra application is configured to sign the entire SAML **Response** (not just the assertion), select `Both Response and Assertion` or `Only Response` accordingly.
A response is rejected unless the element(s) selected here are signed by the IdP; any other signature present is verified too. A missing or unrecognized value is treated as `Both Response and Assertion`.
- **Public Certificate / Private Key**: Generate a public/private key pair for dotCMS SAML. This key pair is used by the SP (dotCMS) to sign authentication requests and decrypt encrypted assertions. You can generate one using:
```bash
openssl req -x509 -newkey rsa:2048 -keyout saml-key.pem -out saml-cert.pem -days 365 -nodes
Expand Down Expand Up @@ -378,8 +379,11 @@ Once your changes are tested and working, create the release version:
- authn.protocol.binding: The binding for the auth request XML, such as Http-Redirect, Http-POST, Http-POST-Raw. Based on this value will use Redirect or Post to do the authentication request from dotCMS to the IDP
- logout.service.endpoint.url: this is a callback to be called by the IDP when the logout happens on the IDP and needs to get back to dotCMS, it is usually set to "/dotAdmin/show-logout"
- logout.okta.url: okta needs a special url instead of the IDP metadata XML to get logout, this is the url used in that particular case.
- clock.skew: this is the clock skew in seconds, it is used to allow some time difference between the IDP and dotCMS servers, so if the IDP sends a SAML Assertion with a timestamp that is 5 seconds in the past, it will still be valid.
- message.life.time: this is the time in seconds that the SAML message will be valid, after this time it will be considered expired.
- clock.skew: this is the clock skew in milliseconds (default 10000), it is used to allow some time difference between the IDP and dotCMS servers when checking the Response IssueInstant and the assertion Conditions, SubjectConfirmationData and AuthnStatement validity times.
- message.life.time: this is the time in milliseconds (default 20000) that the SAML Response is valid after its IssueInstant, after this time it will be considered expired.
- allow.unsolicited.responses: boolean, false by default. By default dotCMS only accepts a SAML Response that answers an authentication request started from the same browser (InResponseTo). Set it to true to also accept IdP-initiated login (for example an app tile on the IdP portal); such responses still have to pass all the assertion checks.
- authn.request.max.age: seconds (default 900, between 60 and 3600) an authentication request started from dotCMS stays valid while the user signs in at the IdP.
- allow.sha1.signatures: boolean, false by default. IdP signatures must use RSA or ECDSA with SHA-256, SHA-384 or SHA-512, and SHA-256 or stronger digests. Set it to true only for an IdP that still signs with RSA-SHA1 or SHA-1 digests; each such response is accepted with a warning in the log.
- auth.sign.request: this is a boolean that indicates if the SAML request should be signed or not, it is usually set to false.
- auth.signature.reference.digestmethod.algorithm: this is the algorithm used to sign the SAML request, it is usually set to "http://www.w3.org/2001/04/xmlenc#sha256".
- auth.sign.params: this is a boolean that indicates if the SAML request parameters should be signed or not, it is usually set to true.
Expand All @@ -390,8 +394,8 @@ Once your changes are tested and working, create the release version:
- logout.signature.algorithm: this is the algorithm used to sign the SAML logout request, it is usually set to "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256".
- location.cleanqueryparams: this is a boolean that indicates if the query parameters should be cleaned from the location URL, it is usually set to true.
- logout.protocol.binding: this is the binding for the logout request XML, such as Http-Redirect, Http-POST, Http-Okta. Based on this value will use Redirect or Post to do the logout request from dotCMS to the IDP
- verify.signature.credentials: this is a boolean that indicates if the SAML response signature should be verified or not, it is usually set to true.
- verify.signature.profile: this is the profile used to verify the SAML response signature, it is usually set to "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256".
- verify.signature.credentials: no longer configurable. Signatures are always verified against the IdP credentials; a value of false is ignored and logged as a warning.
- verify.signature.profile: no longer configurable. The SAML signature profile check is always applied; a value of false is ignored and logged as a warning.
- idp.metadata.protocol: this is the protocol used to retrieve the IDP metadata, it is usually set to "urn:oasis:names:tc:SAML:2.0:protocol".
- use.encrypted.descriptor: When setting the Key Descriptor in the IDP metadata, this property indicates if the Key Descriptor should be encrypted or not, it is usually set to false.
- access.filter.values: Is a comma separeted list with exceptional cases to avoid SAML evaluation
Expand All @@ -415,6 +419,41 @@ Once your changes are tested and working, create the release version:
- authn.comparisontype: This is the comparison type of the AuthnContext element in the SAML request, it is usually set to "minimum". This value can be changed to "exact" or "maximum" or "better" depending on the IDP configuration.
- isassertion.encrypted: This is a boolean that indicates if the SAML assertion should be encrypted or not, it is usually set to false. If set to true, the SAML assertion will be encrypted using the public key of the SP, which can be useful for some IDPs that require it.

### How a SAML Response is validated

On the assertion consumer endpoint (`/dotsaml/login/{idpConfigId}`), after decoding, a response is accepted only when all of the following hold (SAML 2.0 Web Browser SSO profile):

- It contains exactly one assertion, encrypted or not as configured.
- The signatures required by the Validation Type are present and valid, and any other signature present is valid. The signature profile check binds each signature to the element it covers.
- Every signature uses an allowed algorithm: RSA or ECDSA with SHA-256/384/512 and SHA-256/384/512 digests (RSA-SHA1 and SHA-1 digests only with `allow.sha1.signatures`).
- The status is Success and the Response IssueInstant is within `message.life.time`.
- The Response Destination, when present (always, for a signed Response), is this site's assertion consumer URL.
- The Response Issuer (when present) and the Assertion Issuer are the IdP entityID from the IdP metadata.
- The assertion has Conditions whose NotBefore/NotOnOrAfter include the current time, and every AudienceRestriction includes the Service Provider Issuer ID.
- It has a bearer SubjectConfirmation whose SubjectConfirmationData has a NotOnOrAfter in the future, a Recipient equal to the assertion consumer URL, and an InResponseTo equal to the Response's.
- The Response InResponseTo matches an authentication request started from the same browser, or it is absent and `allow.unsolicited.responses` is true. A login started on a host other than the Service Provider Endpoint Hostname (a site alias, or a site using the System Host configuration) is first redirected to `https://<endpoint host>/dotsaml/login/<site id>` and starts from there, so the browser sends the request cookie back with the response.
- It has an AuthnStatement whose SessionNotOnOrAfter, if present, is in the future.
- The assertion ID has not been used before on any node. Used IDs are recorded in the `dotsaml_assertion_replay` table, which the bundle creates in the dotCMS database on first use, until the confirmation expires.

### Troubleshooting rejected SAML logins

Each rejection is logged with the IdP name and the reason. The usual causes:

| Log message contains | Cause | Fix |
|---|---|---|
| `The SAML Response ... must be signed` / `The SAML Assertion ... must be signed` | The IdP doesn't sign what Validation Type requires. | Turn signing on at the IdP, or select the Validation Type that matches exactly what it signs. |
| `Signature cannot be validated` | The signature doesn't match the IdP certificate in the metadata. | Re-import the IdP metadata (the IdP may have rotated its certificate). |
| `signature algorithm ... is not allowed` / `digest algorithm ... is not allowed` | The IdP signs with SHA-1. | Switch the IdP to SHA-256. As a stopgap, set `allow.sha1.signatures=true`. |
| `Unsolicited SAML Responses (IdP-initiated login) are not accepted` | The login was started from the IdP (for example an app tile). | Start it from dotCMS, or set `allow.unsolicited.responses=true`. |
| `does not match an authentication request started from this browser` | The login took longer than `authn.request.max.age`, the browser didn't send the request cookie (it needs HTTPS), or the response was posted again. | Start the login again from dotCMS. |
| `Destination ... is not this service provider's assertion consumer URL` / `Recipient ...` | The IdP posts to a different URL. | Set the IdP's ACS URL to `https://<Service Provider Endpoint Hostname>/dotsaml/login/<site id>`, or correct the Endpoint Hostname. |
| `AudienceRestriction does not include this service provider` | The IdP's audience (SP entity ID) differs from the Service Provider Issuer ID. | Make the two match. |
| `Issuer ... is not the configured IdP` | The metadata belongs to another IdP or tenant. | Re-import the right IdP metadata. |
| `expired at` / `not valid before` | Clock difference between the IdP and dotCMS. | Check NTP on both sides, or raise `clock.skew`. |
| `has already been used` | The same response was posted twice (back button, refresh). | Start the login again. |
| `Could not check whether the SAML Assertion ... has already been used` | The replay table couldn't be read or written. | Check database connectivity and that the dotCMS user can create `dotsaml_assertion_replay`. |
| `must contain exactly one ... assertion` | Several assertions, or encryption that doesn't match `isassertion.encrypted`. | Configure the IdP to send one assertion, and set `isassertion.encrypted` to match. |

### Why SAML evolution is organic, and has been reactive

The evolution of our SAML implementation has been organic because it started from a baseline that was designed based on best practices and common SAML flows described in the literature (e.g., handling GET/POST authentication, single logout, assertion consumption endpoints, and metadata generation/parsing).
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/dotcms/saml/service/external/SamlConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ public class SamlConstants {
public static final String RESPONSE_AND_ASSERTION = "responseandassertion";
public static final String RESPONSE = "response";
public static final String ASSERTION = "assertion";

/**
* Optional IdP property. When "true", responses without InResponseTo (IdP-initiated SSO) are accepted.
* By default only responses to an authentication request started from the same browser are accepted.
*/
public static final String ALLOW_UNSOLICITED_RESPONSES = "allow.unsolicited.responses";

/**
* Optional IdP property. Seconds an outstanding authentication request stays valid while the user
* authenticates at the IdP.
*/
public static final String AUTHN_REQUEST_MAX_AGE = "authn.request.max.age";
public static final int AUTHN_REQUEST_MAX_AGE_DEFAULT_VALUE = 900;

/**
* Optional IdP property. When "true", RSA-SHA1 signatures and SHA-1 digests are accepted (with a warning)
* for IdPs that can not sign with SHA-256 or stronger yet.
*/
public static final String ALLOW_SHA1_SIGNATURES = "allow.sha1.signatures";

public static final String SAML_USER_ID = "SAMLUserId";
public static final String DEFAULT_LOGIN_PATH = "/dotAdmin";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.dotcms.saml.service.external.SamlException;
import com.dotcms.saml.service.impl.DotHTTPPOSTDeflateEncoder;
import com.dotcms.saml.service.internal.SamlCoreService;
import com.dotcms.saml.utils.AuthnRequestStateCookie;
import com.dotcms.saml.utils.SamlUtils;
import com.dotcms.saml.utils.SignatureUtils;
import com.dotmarketing.util.UtilMethods;
Expand Down Expand Up @@ -53,6 +54,8 @@ public void handle(final HttpServletRequest request, final HttpServletResponse r

final MessageContext context = new MessageContext(); // main context
final AuthnRequest authnRequest = this.samlCoreService.buildAuthnRequest(request, identityProviderConfiguration, SAMLConstants.SAML2_POST_BINDING_URI);
// lets the SAML Response be bound to this request (InResponseTo) when the IdP posts it back
AuthnRequestStateCookie.remember(request, response, identityProviderConfiguration, authnRequest.getID());

// the client can ask to sign or not the request
final boolean needSign = identityProviderConfiguration.containsOptionalProperty("auth.sign.request")?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.dotcms.saml.service.external.SamlException;
import com.dotcms.saml.service.impl.DotHTTPPOSTRawDeflateEncoder;
import com.dotcms.saml.service.internal.SamlCoreService;
import com.dotcms.saml.utils.AuthnRequestStateCookie;
import com.dotcms.saml.utils.SamlUtils;
import com.dotmarketing.util.UtilMethods;
import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
Expand Down Expand Up @@ -47,6 +48,8 @@ public void handle(final HttpServletRequest request, final HttpServletResponse r

final MessageContext context = new MessageContext(); // main context
final AuthnRequest authnRequest = this.samlCoreService.buildAuthnRequest(request, identityProviderConfiguration, SAMLConstants.SAML2_POST_BINDING_URI);
// lets the SAML Response be bound to this request (InResponseTo) when the IdP posts it back
AuthnRequestStateCookie.remember(request, response, identityProviderConfiguration, authnRequest.getID());

context.setMessage(authnRequest);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import com.dotcms.saml.MessageObserver;
import com.dotcms.saml.SamlConfigurationService;
import com.dotcms.saml.SamlName;
import com.dotcms.saml.service.impl.SamlResponseValidator;
import com.dotcms.saml.service.internal.SamlCoreService;
import com.dotcms.saml.service.external.SamlException;
import com.dotcms.saml.utils.InstanceUtil;
import com.dotcms.saml.utils.SamlUtils;
import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
import org.apache.commons.lang.StringUtils;
Expand Down Expand Up @@ -44,14 +46,25 @@ public class HttpPostAssertionResolverHandlerImpl implements AssertionResolverHa
private final MessageObserver messageObserver;
private final SamlCoreService samlCoreService;
private final SamlConfigurationService samlConfigurationService;
private final SamlResponseValidator samlResponseValidator;

public HttpPostAssertionResolverHandlerImpl(final MessageObserver messageObserver,
final SamlCoreService samlCoreService,
final SamlConfigurationService samlConfigurationService) {

this(messageObserver, samlCoreService, samlConfigurationService,
InstanceUtil.getInstance(SamlResponseValidator.class));
}

public HttpPostAssertionResolverHandlerImpl(final MessageObserver messageObserver,
final SamlCoreService samlCoreService,
final SamlConfigurationService samlConfigurationService,
final SamlResponseValidator samlResponseValidator) {

this.messageObserver = messageObserver;
this.samlCoreService = samlCoreService;
this.samlConfigurationService = samlConfigurationService;
this.samlResponseValidator = samlResponseValidator;
}

@Override
Expand Down Expand Up @@ -85,6 +98,13 @@ public Assertion resolveAssertion(final HttpServletRequest request, final HttpSe
decoder.decode();

messageContext = decoder.getMessageContext();
if (!(messageContext.getMessage() instanceof Response)) {

this.messageObserver.updateError(this.getClass().getName(), "The SAML message posted for IdP '"
+ identityProviderConfiguration.getIdpName() + "' is not a Response");
throw new SamlException("The SAML message posted to the assertion consumer URL is not a Response");
}

samlResponse = (Response) messageContext.getMessage();

this.messageObserver.updateDebug(this.getClass().getName(),"Post message context decoded:");
Expand All @@ -100,7 +120,13 @@ public Assertion resolveAssertion(final HttpServletRequest request, final HttpSe
decoder.destroy();
}

this.validateDestinationAndLifetime(messageContext, request, identityProviderConfiguration);
if (null == this.samlResponseValidator) {

// fail closed: never resolve an assertion without the profile checks
throw new SamlException("The SAML Response validator is not available");
}

this.validateMessageLifetime(messageContext, identityProviderConfiguration);

assertion = this.samlCoreService.getAssertion(samlResponse, identityProviderConfiguration);

Expand All @@ -113,26 +139,35 @@ public Assertion resolveAssertion(final HttpServletRequest request, final HttpSe

this.verifyStatus(samlResponse);

// Destination, Issuer, Audience, Conditions, SubjectConfirmation, InResponseTo and replay.
this.samlResponseValidator.validate(samlResponse, assertion, request, response, identityProviderConfiguration);

return assertion;
}

private void verifyStatus(final Response response) {

final Status status = response.getStatus();
final StatusCode statusCode = status.getStatusCode();
final String statusCodeURI = statusCode.getValue();
final StatusCode statusCode = null != status ? status.getStatusCode() : null;
final String statusCodeURI = null != statusCode ? statusCode.getValue() : null;

if (!statusCodeURI.equals(StatusCode.SUCCESS)) {
if (!StatusCode.SUCCESS.equals(statusCodeURI)) {

this.messageObserver.updateError(this.getClass().getName(),
"SAML status code was NOT successful: " + statusCode.getStatusCode().getValue());
throw new SamlException("SAML status code was NOT successful: " + statusCode.getValue());
"SAML status code was NOT successful: " + statusCodeURI);
throw new SamlException("SAML status code was NOT successful: " + statusCodeURI);
}
}

/**
* Checks the Response IssueInstant against the configured message lifetime. The Destination and the
* assertion validity window are checked by {@link SamlResponseValidator}, against the configured assertion
* consumer URL rather than the servlet request URL, which differs behind a TLS-terminating proxy and after the
* /dotsaml/login to /api/v1/dotsaml/login rewrite.
*/
@SuppressWarnings("unchecked")
private void validateDestinationAndLifetime(final MessageContext<SAMLObject> context,
final HttpServletRequest request, final IdentityProviderConfiguration identityProviderConfiguration) {
private void validateMessageLifetime(final MessageContext<SAMLObject> context,
final IdentityProviderConfiguration identityProviderConfiguration) {

// Just setting it to a value in case of exception.
long clockSkew = DOT_SAML_CLOCK_SKEW_DEFAULT_VALUE;
Expand Down Expand Up @@ -180,7 +215,6 @@ private void validateDestinationAndLifetime(final MessageContext<SAMLObject> con
lifetimeSecurityHandler.setMessageLifetime(lifeTime);
lifetimeSecurityHandler.setRequiredRule(true);

// validation of message destination.
handlers.add(lifetimeSecurityHandler);
handlerChain.setHandlers(handlers);

Expand Down
Loading