All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.coveo.saml.SamlClient Maven / Gradle / Ivy

There is a newer version: 5.0.0
Show newest version
package com.coveo.saml;

import com.sun.org.apache.xerces.internal.parsers.DOMParser;

import org.apache.commons.codec.binary.Base64;
import org.joda.time.DateTime;
import org.opensaml.core.config.InitializationService;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
import org.opensaml.core.xml.io.Marshaller;
import org.opensaml.core.xml.io.MarshallingException;
import org.opensaml.core.xml.io.UnmarshallingException;
import org.opensaml.saml.common.SAMLVersion;
import org.opensaml.saml.metadata.resolver.impl.DOMMetadataResolver;
import org.opensaml.saml.saml2.core.Assertion;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.opensaml.saml.saml2.core.Conditions;
import org.opensaml.saml.saml2.core.Issuer;
import org.opensaml.saml.saml2.core.NameIDPolicy;
import org.opensaml.saml.saml2.core.Response;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml.saml2.metadata.IDPSSODescriptor;
import org.opensaml.saml.saml2.metadata.KeyDescriptor;
import org.opensaml.saml.saml2.metadata.SingleSignOnService;
import org.opensaml.security.credential.Credential;
import org.opensaml.security.credential.UsageType;
import org.opensaml.security.x509.BasicX509Credential;
import org.opensaml.xmlsec.keyinfo.KeyInfoSupport;
import org.opensaml.xmlsec.signature.Signature;
import org.opensaml.xmlsec.signature.X509Data;
import org.opensaml.xmlsec.signature.support.SignatureException;
import org.opensaml.xmlsec.signature.support.SignatureValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Element;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.bind.ValidationException;
import javax.xml.namespace.QName;

import net.shibboleth.utilities.java.support.component.ComponentInitializationException;

public class SamlClient {
  private static final Logger logger = LoggerFactory.getLogger(SamlClient.class);
  private static boolean initializedOpenSaml = false;

  public enum SamlIdpBinding {
    POST,
    Redirect;
  }

  private String relyingPartyIdentifier;
  private String assertionConsumerServiceUrl;
  private String identityProviderUrl;
  private String responseIssuer;
  private List credentials;
  private DateTime now; // used for testing only
  private long notBeforeSkew = 0L;
  private SamlIdpBinding samlBinding;

  /**
   * Returns the url where SAML requests should be posted.
   *
   * @return the url where SAML requests should be posted.
   */
  public String getIdentityProviderUrl() {
    return identityProviderUrl;
  }

  /**
   * Sets the date that will be considered as now. This is only useful for testing.
   *
   * @param now the date to use for now.
   */
  public void setDateTimeNow(DateTime now) {
    this.now = now;
  }

  /**
   * Sets by how much the current time can be before the assertion's notBefore.
   *
   * Used to mitigate clock differences between the identity provider and relying party.
   *
   * @param notBeforeSkew non-negative amount of skew (in milliseconds) to allow between the
   *                      current time and the assertion's notBefore date. Default: 0
   */
  public void setNotBeforeSkew(long notBeforeSkew) {
    if (notBeforeSkew < 0) {
      throw new IllegalArgumentException("Skew must be non-negative");
    }
    this.notBeforeSkew = notBeforeSkew;
  }

  /**
   * Constructs an SAML client using explicit parameters.
   *
   * @param relyingPartyIdentifier      the identifier of the relying party.
   * @param assertionConsumerServiceUrl the url where the identity provider will post back the
   *                                    SAML response.
   * @param identityProviderUrl         the url where the SAML request will be submitted.
   * @param responseIssuer              the expected issuer ID for SAML responses.
   * @param certificates                the list of base-64 encoded certificates to use to validate
   *                                    responses.
   * @param samlBinding                 what type of SAML binding should the client use.
   * @throws SamlException thrown if any error occur while loading the provider information.
   */
  public SamlClient(
      String relyingPartyIdentifier,
      String assertionConsumerServiceUrl,
      String identityProviderUrl,
      String responseIssuer,
      List certificates,
      SamlIdpBinding samlBinding)
      throws SamlException {

    ensureOpenSamlIsInitialized();

    if (relyingPartyIdentifier == null) {
      throw new IllegalArgumentException("relyingPartyIdentifier");
    }
    if (identityProviderUrl == null) {
      throw new IllegalArgumentException("identityProviderUrl");
    }
    if (responseIssuer == null) {
      throw new IllegalArgumentException("responseIssuer");
    }
    if (certificates == null || certificates.isEmpty()) {
      throw new IllegalArgumentException("certificates");
    }

    this.relyingPartyIdentifier = relyingPartyIdentifier;
    this.assertionConsumerServiceUrl = assertionConsumerServiceUrl;
    this.identityProviderUrl = identityProviderUrl;
    this.responseIssuer = responseIssuer;
    credentials = certificates.stream().map(SamlClient::getCredential).collect(Collectors.toList());
    this.samlBinding = samlBinding;
  }

  /**
   * Constructs an SAML client using explicit parameters.
   *
   * @param relyingPartyIdentifier      the identifier of the relying party.
   * @param assertionConsumerServiceUrl the url where the identity provider will post back the
   *                                    SAML response.
   * @param identityProviderUrl         the url where the SAML request will be submitted.
   * @param responseIssuer              the expected issuer ID for SAML responses.
   * @param certificates                the list of base-64 encoded certificates to use to validate
   *                                    responses.
   * @throws SamlException thrown if any error occur while loading the provider information.
   */
  public SamlClient(
      String relyingPartyIdentifier,
      String assertionConsumerServiceUrl,
      String identityProviderUrl,
      String responseIssuer,
      List certificates)
      throws SamlException {

    this(
        relyingPartyIdentifier,
        assertionConsumerServiceUrl,
        identityProviderUrl,
        responseIssuer,
        certificates,
        SamlIdpBinding.POST);
  }

  /**
   * Constructs an SAML client using explicit parameters.
   *
   * @param relyingPartyIdentifier      the identifier of the relying party.
   * @param assertionConsumerServiceUrl the url where the identity provider will post back the
   *                                    SAML response.
   * @param identityProviderUrl         the url where the SAML request will be submitted.
   * @param responseIssuer              the expected issuer ID for SAML responses.
   * @param certificate                 the base-64 encoded certificate to use to validate
   *                                    responses.
   * @throws SamlException thrown if any error occur while loading the provider information.
   */
  public SamlClient(
      String relyingPartyIdentifier,
      String assertionConsumerServiceUrl,
      String identityProviderUrl,
      String responseIssuer,
      X509Certificate certificate)
      throws SamlException {

    this(
        relyingPartyIdentifier,
        assertionConsumerServiceUrl,
        identityProviderUrl,
        responseIssuer,
        Collections.singletonList(certificate),
        SamlIdpBinding.POST);
  }

  /**
   * Builds an encoded SAML request.
   *
   * @return The base-64 encoded SAML request.
   * @throws SamlException thrown if an unexpected error occurs.
   */
  public String getSamlRequest() throws SamlException {
    AuthnRequest request = (AuthnRequest) buildSamlObject(AuthnRequest.DEFAULT_ELEMENT_NAME);
    request.setID("z" + UUID.randomUUID().toString()); // ADFS needs IDs to start with a letter

    request.setVersion(SAMLVersion.VERSION_20);
    request.setIssueInstant(DateTime.now());
    request.setProtocolBinding(
        "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-" + this.samlBinding.toString());
    request.setAssertionConsumerServiceURL(assertionConsumerServiceUrl);

    Issuer issuer = (Issuer) buildSamlObject(Issuer.DEFAULT_ELEMENT_NAME);
    issuer.setValue(relyingPartyIdentifier);
    request.setIssuer(issuer);

    NameIDPolicy nameIDPolicy = (NameIDPolicy) buildSamlObject(NameIDPolicy.DEFAULT_ELEMENT_NAME);
    nameIDPolicy.setFormat("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
    request.setNameIDPolicy(nameIDPolicy);

    StringWriter stringWriter = new StringWriter();
    try {
      Marshaller marshaller =
          XMLObjectProviderRegistrySupport.getMarshallerFactory().getMarshaller(request);
      Element dom = marshaller.marshall(request);
      XMLHelper.writeNode(dom, stringWriter);
    } catch (MarshallingException ex) {
      throw new SamlException("Error while marshalling SAML request to XML", ex);
    }

    logger.trace("Issuing SAML request: " + stringWriter.toString());

    return Base64.encodeBase64String(stringWriter.toString().getBytes(StandardCharsets.UTF_8));
  }

  /**
   * Decodes and validates an SAML response returned by an identity provider.
   *
   * @param encodedResponse the encoded response returned by the identity provider.
   * @return An {@link SamlResponse} object containing information decoded from the SAML response.
   * @throws SamlException if the signature is invalid, or if any other error occurs.
   */
  public SamlResponse decodeAndValidateSamlResponse(String encodedResponse) throws SamlException {
    String decodedResponse;
    decodedResponse = new String(Base64.decodeBase64(encodedResponse), StandardCharsets.UTF_8);

    logger.trace("Validating SAML response: " + decodedResponse);

    Response response;
    try {
      DOMParser parser = createDOMParser();
      parser.parse(new InputSource(new StringReader(decodedResponse)));
      response =
          (Response)
              XMLObjectProviderRegistrySupport.getUnmarshallerFactory()
                  .getUnmarshaller(parser.getDocument().getDocumentElement())
                  .unmarshall(parser.getDocument().getDocumentElement());
    } catch (IOException | SAXException | UnmarshallingException ex) {
      throw new SamlException("Cannot decode xml encoded response", ex);
    }

    validateResponse(response);
    validateAssertion(response);
    validateSignature(response);

    Assertion assertion = response.getAssertions().get(0);
    return new SamlResponse(assertion);
  }

  /**
   * Redirects an {@link HttpServletResponse} to the configured identity provider.
   *
   * @param response   The {@link HttpServletResponse}.
   * @param relayState Optional relay state that will be passed along.
   * @throws IOException   thrown if an IO error occurs.
   * @throws SamlException thrown is an unexpected error occurs.
   */
  public void redirectToIdentityProvider(HttpServletResponse response, String relayState)
      throws IOException, SamlException {
    Map values = new HashMap<>();
    values.put("SAMLRequest", getSamlRequest());
    if (relayState != null) {
      values.put("RelayState", relayState);
    }

    BrowserUtils.postUsingBrowser(identityProviderUrl, response, values);
  }

  /**
   * Processes a POST containing the SAML response.
   *
   * @param request the {@link HttpServletRequest}.
   * @return An {@link SamlResponse} object containing information decoded from the SAML response.
   * @throws SamlException thrown is an unexpected error occurs.
   */
  public SamlResponse processPostFromIdentityProvider(HttpServletRequest request)
      throws SamlException {
    String encodedResponse = request.getParameter("SAMLResponse");
    return decodeAndValidateSamlResponse(encodedResponse);
  }

  /**
   * Constructs an SAML client using XML metadata obtained from the identity provider. 

When * using Okta as an identity provider, it is possible to pass null to relyingPartyIdentifier and * assertionConsumerServiceUrl; they will be inferred from the metadata provider XML. * * @param relyingPartyIdentifier the identifier for the relying party. * @param assertionConsumerServiceUrl the url where the identity provider will post back the * SAML response. * @param metadata the XML metadata obtained from the identity provider. * @return The created {@link SamlClient}. * @throws SamlException thrown if any error occur while loading the metadata information. */ public static SamlClient fromMetadata( String relyingPartyIdentifier, String assertionConsumerServiceUrl, Reader metadata) throws SamlException { return fromMetadata( relyingPartyIdentifier, assertionConsumerServiceUrl, metadata, SamlIdpBinding.POST); } /** * Constructs an SAML client using XML metadata obtained from the identity provider.

When * using Okta as an identity provider, it is possible to pass null to relyingPartyIdentifier and * assertionConsumerServiceUrl; they will be inferred from the metadata provider XML. * * @param relyingPartyIdentifier the identifier for the relying party. * @param assertionConsumerServiceUrl the url where the identity provider will post back the * SAML response. * @param metadata the XML metadata obtained from the identity provider. * @param samlBinding the HTTP method to use for binding to the IdP. * @return The created {@link SamlClient}. * @throws SamlException thrown if any error occur while loading the metadata information. */ public static SamlClient fromMetadata( String relyingPartyIdentifier, String assertionConsumerServiceUrl, Reader metadata, SamlIdpBinding samlBinding) throws SamlException { return fromMetadata( relyingPartyIdentifier, assertionConsumerServiceUrl, metadata, samlBinding, null); } /** * Constructs an SAML client using XML metadata obtained from the identity provider.

When * using Okta as an identity provider, it is possible to pass null to relyingPartyIdentifier and * assertionConsumerServiceUrl; they will be inferred from the metadata provider XML. * * @param relyingPartyIdentifier the identifier for the relying party. * @param assertionConsumerServiceUrl the url where the identity provider will post back the * SAML response. * @param metadata the XML metadata obtained from the identity provider. * @param samlBinding the HTTP method to use for binding to the IdP. * @param certificates list of certificates. * @return The created {@link SamlClient}. * @throws SamlException thrown if any error occur while loading the metadata information. */ public static SamlClient fromMetadata( String relyingPartyIdentifier, String assertionConsumerServiceUrl, Reader metadata, SamlIdpBinding samlBinding, List certificates) throws SamlException { ensureOpenSamlIsInitialized(); DOMMetadataResolver metadataResolver = createMetadataResolver(metadata); EntityDescriptor entityDescriptor = getEntityDescriptor(metadataResolver); IDPSSODescriptor idpSsoDescriptor = getIDPSSODescriptor(entityDescriptor); SingleSignOnService idpBinding = null; if (idpSsoDescriptor.getSingleSignOnServices() != null && !idpSsoDescriptor.getSingleSignOnServices().isEmpty()) { idpBinding = getIdpBinding(idpSsoDescriptor, samlBinding); } List x509Certificates = getCertificates(idpSsoDescriptor); boolean isOkta = entityDescriptor.getEntityID().contains(".okta.com"); if (relyingPartyIdentifier == null) { // Okta's own toolkit uses the entity ID as a relying party identifier, so if we // detect that the IDP is Okta let's tolerate a null value for this parameter. if (isOkta) { relyingPartyIdentifier = entityDescriptor.getEntityID(); } else { throw new IllegalArgumentException("relyingPartyIdentifier"); } } if (idpBinding != null && assertionConsumerServiceUrl == null && isOkta) { // Again, Okta's own toolkit uses this value for the assertion consumer url, which // kinda makes no sense since this is supposed to be a url pointing to a server // outside Okta, but it probably just straight ignores this and use the one from // it's own config anyway. assertionConsumerServiceUrl = idpBinding.getLocation(); } if (certificates != null) { // Adding certificates given to this method // because some idp metadata file does not embedded signing certificate x509Certificates.addAll(certificates); } String identityProviderUrl; if (idpBinding != null) { identityProviderUrl = idpBinding.getLocation(); } else { identityProviderUrl = assertionConsumerServiceUrl; } String responseIssuer = entityDescriptor.getEntityID(); return new SamlClient( relyingPartyIdentifier, assertionConsumerServiceUrl, identityProviderUrl, responseIssuer, x509Certificates, samlBinding); } private void validateResponse(Response response) throws SamlException { try { new ResponseSchemaValidator().validate(response); } catch (ValidationException ex) { throw new SamlException("The response schema validation failed", ex); } if (!response.getIssuer().getValue().equals(responseIssuer)) { throw new SamlException("The response issuer didn't match the expected value"); } String statusCode = response.getStatus().getStatusCode().getValue(); if (!statusCode.equals("urn:oasis:names:tc:SAML:2.0:status:Success")) { throw new SamlException("Invalid status code: " + statusCode); } } private void validateAssertion(Response response) throws SamlException { if (response.getAssertions().size() != 1) { throw new SamlException("The response doesn't contain exactly 1 assertion"); } Assertion assertion = response.getAssertions().get(0); if (!assertion.getIssuer().getValue().equals(responseIssuer)) { throw new SamlException("The assertion issuer didn't match the expected value"); } if (assertion.getSubject().getNameID() == null) { throw new SamlException( "The NameID value is missing from the SAML response; this is likely an IDP configuration issue"); } enforceConditions(assertion.getConditions()); } private void enforceConditions(Conditions conditions) throws SamlException { DateTime now = this.now != null ? this.now : DateTime.now(); DateTime notBefore = conditions.getNotBefore(); DateTime skewedNotBefore = notBefore.minus(notBeforeSkew); if (now.isBefore(skewedNotBefore)) { throw new SamlException("The assertion cannot be used before " + notBefore.toString()); } DateTime notOnOrAfter = conditions.getNotOnOrAfter(); if (now.isAfter(notOnOrAfter)) { throw new SamlException("The assertion cannot be used after " + notOnOrAfter.toString()); } } private void validateSignature(Response response) throws SamlException { Signature responseSignature = response.getSignature(); Signature assertionSignature = response.getAssertions().get(0).getSignature(); if (responseSignature == null && assertionSignature == null) { throw new SamlException("No signature is present in either response or assertion"); } if (responseSignature != null && !validate(responseSignature)) { throw new SamlException("The response signature is invalid"); } if (assertionSignature != null && !validate(assertionSignature)) { throw new SamlException("The assertion signature is invalid"); } } private boolean validate(Signature signature) { if (signature == null) { return false; } // It's fine if any of the credentials match the signature return credentials .stream() .anyMatch( c -> { try { SignatureValidator.validate(signature, c); return true; } catch (SignatureException ex) { return false; } }); } public synchronized static void ensureOpenSamlIsInitialized() throws SamlException { if (!initializedOpenSaml) { try { InitializationService.initialize(); initializedOpenSaml = true; } catch (Throwable ex) { throw new SamlException("Error while initializing the Open SAML library", ex); } } } private static DOMParser createDOMParser() throws SamlException { DOMParser parser = new DOMParser() { { try { setFeature(INCLUDE_COMMENTS_FEATURE, false); } catch (Throwable ex) { throw new SamlException( "Cannot disable comments parsing to mitigate https://www.kb.cert.org/vuls/id/475445", ex); } } }; return parser; } private static DOMMetadataResolver createMetadataResolver(Reader metadata) throws SamlException { try { DOMParser parser = createDOMParser(); parser.parse(new InputSource(metadata)); DOMMetadataResolver resolver = new DOMMetadataResolver(parser.getDocument().getDocumentElement()); resolver.setId( "componentId"); // The resolver needs an ID for the initialization to go through. resolver.initialize(); return resolver; } catch (IOException | SAXException | ComponentInitializationException ex) { throw new SamlException("Cannot load identity provider metadata", ex); } } private static EntityDescriptor getEntityDescriptor(DOMMetadataResolver metadata) throws SamlException { List entityDescriptors = new ArrayList<>(); metadata.forEach(entityDescriptors::add); if (entityDescriptors.size() != 1) { throw new SamlException("Bad entity descriptor count: " + entityDescriptors.size()); } return entityDescriptors.get(0); } private static IDPSSODescriptor getIDPSSODescriptor(EntityDescriptor entityDescriptor) throws SamlException { IDPSSODescriptor idpssoDescriptor = entityDescriptor.getIDPSSODescriptor("urn:oasis:names:tc:SAML:2.0:protocol"); if (idpssoDescriptor == null) { throw new SamlException("Cannot retrieve IDP SSO descriptor"); } return idpssoDescriptor; } private static SingleSignOnService getIdpBinding( IDPSSODescriptor idpSsoDescriptor, SamlIdpBinding samlBinding) throws SamlException { return idpSsoDescriptor .getSingleSignOnServices() .stream() .filter( x -> x.getBinding() .equals("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-" + samlBinding.toString())) .findAny() .orElseThrow(() -> new SamlException("Cannot find HTTP-POST SSO binding in metadata")); } private static List getCertificates(IDPSSODescriptor idpSsoDescriptor) throws SamlException { List certificates; try { certificates = idpSsoDescriptor .getKeyDescriptors() .stream() .filter(x -> x.getUse() == UsageType.SIGNING) .flatMap(SamlClient::getDatasWithCertificates) .map(SamlClient::getFirstCertificate) .collect(Collectors.toList()); } catch (Exception e) { throw new SamlException("Exception in getCertificates", e); } return certificates; } private static Stream getDatasWithCertificates(KeyDescriptor descriptor) { return descriptor .getKeyInfo() .getX509Datas() .stream() .filter(d -> d.getX509Certificates().size() > 0); } private static X509Certificate getFirstCertificate(X509Data data) { try { org.opensaml.xmlsec.signature.X509Certificate cert = data.getX509Certificates().stream().findFirst().orElse(null); if (cert != null) { return KeyInfoSupport.getCertificate(cert); } } catch (CertificateException e) { logger.error("Exception in getFirstCertificate", e); } return null; } private static Credential getCredential(X509Certificate certificate) { BasicX509Credential credential = new BasicX509Credential(certificate); credential.setCRLs(Collections.emptyList()); return credential; } private static XMLObject buildSamlObject(QName qname) { return XMLObjectProviderRegistrySupport.getBuilderFactory() .getBuilder(qname) .buildObject(qname); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy