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

se.litsec.swedisheid.opensaml.saml2.signservice.SADParser Maven / Gradle / Ivy

/*
 * Copyright 2016-2018 Litsec AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package se.litsec.swedisheid.opensaml.saml2.signservice;

import java.io.IOException;
import java.nio.charset.Charset;
import java.security.cert.X509Certificate;
import java.text.ParseException;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

import org.joda.time.DateTime;
import org.opensaml.core.xml.io.MarshallingException;
import org.opensaml.saml.saml2.core.Assertion;
import org.opensaml.saml.saml2.core.Attribute;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.opensaml.security.credential.UsageType;
import org.opensaml.security.x509.X509Credential;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory;
import com.nimbusds.jose.proc.JWSVerifierFactory;
import com.nimbusds.jwt.SignedJWT;

import net.shibboleth.utilities.java.support.resolver.ResolverException;
import se.litsec.opensaml.saml2.attribute.AttributeUtils;
import se.litsec.opensaml.saml2.metadata.MetadataUtils;
import se.litsec.opensaml.saml2.metadata.provider.MetadataProvider;
import se.litsec.opensaml.saml2.metadata.provider.StaticMetadataProvider;
import se.litsec.swedisheid.opensaml.saml2.attribute.AttributeConstants;
import se.litsec.swedisheid.opensaml.saml2.signservice.SADValidationException.ErrorCode;
import se.litsec.swedisheid.opensaml.saml2.signservice.sap.SAD;
import se.litsec.swedisheid.opensaml.saml2.signservice.sap.SADRequest;

/**
 * Class for parsing and validation of SAD JWT:s.
 * 
 * @author Martin Lindström ([email protected])
 */
public class SADParser {

  // Hidden constructor
  private SADParser() {
  }

  /**
   * Parses the supplied (encoded) JWT and returns the contained JWT.
   * 

* Note: The parse method does not peform any validation. Use the {@link SADValidator} class for this purpose. *

* * @param sadJwt * the signed JWT holding the SAD * @return the SAD object * @throws IOException * for parsing errors */ public static SAD parse(String sadJwt) throws IOException { try { SignedJWT signedJwt = SignedJWT.parse(sadJwt); String payload = signedJwt.getPayload().toBase64URL().toString(); return SAD.fromJson(new String(Base64.getUrlDecoder().decode(payload), Charset.forName("UTF-8"))); } catch (ParseException e) { throw new IOException(e); } } /** * Returns a SAD validator initialized with a set of certificates that are to be used for JWT signature validation. * These certificates are the IdP signing certificates obtained from the IdP metadata entry. * * @param validationCertificates * certificate(s) to be used when verifying the JWT signature * @return a {@code SADValidator} instance */ public static SADValidator getValidator(X509Certificate... validationCertificates) { return new SADValidator(validationCertificates); } /** * Returns a SAD validator initialized with a {@link MetadataProvider} instance. During JWT signature validation the * IdP signature certificate will be obtained from the IdP metadata entry held by the metadata provider. * * @param metadataProvider * metadata provider * @return a {@code SADValidator} instance */ public static SADValidator getValidator(MetadataProvider metadataProvider) { return new SADValidator(metadataProvider); } /** * Returns a SAD validator initialized with the IdP {@link EntityDescriptor} (metadata) from which the IdP signing * key/certificate will be read (needed for JWT signature validation). * * @param idpMetadata * the IdP metadata * @return a {@code SADValidator} instance */ public static SADValidator getValidator(EntityDescriptor idpMetadata) { return new SADValidator(idpMetadata); } /** * A validator for verifying the SAD JWT. * * @author Martin Lindström ([email protected]) */ public static class SADValidator { /** Logger instance. */ private Logger logger = LoggerFactory.getLogger(SADValidator.class); /** The certificate(s) to use when verifying the JWT signature. */ private List validationCertificates; /** A provider for federation metadata (in which we later will locate the IdP signing keys). */ private MetadataProvider metadataProvider; private static final JWSVerifierFactory verifierFactory = new DefaultJWSVerifierFactory(); /** * Constructor initializing the validator with a set of certificates that are to be used for JWT signature * validation. These certificates are the IdP signing certificates obtained from the IdP metadata entry. * * @param certificates * certificate(s) to be used when verifying the JWT signature */ public SADValidator(X509Certificate... certificates) { this.validationCertificates = Arrays.asList(certificates); } /** * Constructor creating a SAD validator initialized with a {@link MetadataProvider} instance. During JWT signature * validation the IdP signature certificate will be obtained from the IdP metadata entry held by the metadata * provider. * * @param metadataProvider * metadata provider */ public SADValidator(MetadataProvider metadataProvider) { this.metadataProvider = metadataProvider; } /** * Creates a SAD validator initialized with the IdP {@link EntityDescriptor} (metadata) from which the IdP signing * key/certificate will be read (needed for JWT signature validation). * * @param idpMetadata * the IdP metadata */ public SADValidator(EntityDescriptor idpMetadata) { try { this.metadataProvider = new StaticMetadataProvider(idpMetadata); } catch (MarshallingException e) { throw new SecurityException("Invalid IdP metadata", e); } } /** * A method that validates the SAD issued in an {@code Assertion} based on the {@code AuthnRequest} containing a * {@code SADRequest}. * * @param authnRequest * the {@code AuthnRequest} holding the {@code SADRequest} * @param assertion * the {@code Assertion} holding the {@code sad} attribute (as a encoded JWT) * @return a {@code SAD} object, or {@code null} if no SAD was requested (and issued) * @throws SADValidationException * for SAD validation errors * @throws IllegalArgumentException * if the supplied {@code AuthnRequest} does not contain a {@code SADRequest} extension, or is invalid by * other means (e.g., missing LoA) * @see #validate(String, String, String, String, String, String, int, String) */ public SAD validate(AuthnRequest authnRequest, Assertion assertion) throws SADValidationException, IllegalArgumentException { long now = System.currentTimeMillis() / 1000; // First locate the SADRequest extension. // SADRequest sadRequest = null; if (authnRequest.getExtensions() != null) { sadRequest = authnRequest.getExtensions() .getUnknownXMLObjects() .stream() .filter(SADRequest.class::isInstance) .map(SADRequest.class::cast) .findFirst() .orElse(null); } if (sadRequest == null) { String msg = String.format("AuthnRequest '%s' does not contain a SADRequest", authnRequest.getID()); logger.info(msg); throw new IllegalArgumentException(msg); } // Next, locate the SAD attribute. // if (assertion.getAttributeStatements().isEmpty()) { String msg = String.format("Assertion '%s' does not contain any attributes (and thus no SAD)", assertion.getID()); logger.info(msg); throw new SADValidationException(ErrorCode.NO_SAD_ATTRIBUTE, msg); } List attributes = assertion.getAttributeStatements().get(0).getAttributes(); Attribute sadAttribute = AttributeUtils.getAttribute(AttributeConstants.ATTRIBUTE_NAME_SAD, attributes).orElse(null); if (sadAttribute == null) { String msg = String.format("Assertion '%s' does not contain a SAD attribute", assertion.getID()); logger.info(msg); throw new SADValidationException(ErrorCode.NO_SAD_ATTRIBUTE, msg); } // Parse the JWT and SAD. // SignedJWT signedJwt; SAD sad; try { signedJwt = SignedJWT.parse(AttributeUtils.getAttributeStringValue(sadAttribute)); String payload = signedJwt.getPayload().toBase64URL().toString(); sad = SAD.fromJson(new String(Base64.getUrlDecoder().decode(payload), Charset.forName("UTF-8"))); } catch (ParseException | IOException e) { throw new SADValidationException(ErrorCode.JWT_PARSE_ERROR, "Failed to parse SAD JWT", e); } // The SAD contains an attribute name reference for the attribute that the issuing IdP // used as the user subject. Let's find that attribute value ... // if (sad.getSeElnSadext() == null) { String msg = "seElnSadext extension claims are missing from SAD"; logger.info(msg); throw new SADValidationException(ErrorCode.BAD_SAD_FORMAT, msg); } if (sad.getSeElnSadext().getAttributeName() == null) { String msg = "SAD does not contain the attribute name (attr) for the subject"; logger.info(msg); throw new SADValidationException(ErrorCode.BAD_SAD_FORMAT, msg); } Attribute subjectAttribute = AttributeUtils.getAttribute(sad.getSeElnSadext().getAttributeName(), attributes).orElse(null); if (subjectAttribute == null) { String msg = String.format("Assertion '%s' does not contain a '%s' attribute - this is listed as the subject attribute in the SAD", assertion.getID(), sad.getSeElnSadext().getAttributeName()); logger.info(msg); throw new SADValidationException(ErrorCode.MISSING_SUBJECT_ATTRIBUTE, msg); } // Next, get hold of the AuthnContextClassRef holding the LoA. // We want to compare that with the 'loa' from the SAD. // String loa = getLoa(assertion); if (loa == null) { String msg = String.format("Assertion '%s' does not contain a LoA URI", assertion.getID()); logger.error(msg); throw new IllegalArgumentException(msg); } if (sadRequest.getDocCount() == null) { throw new IllegalArgumentException("Bad SADRequest - missing DocCount"); } // Now, validate! // return this.validate(signedJwt, sad, now, assertion.getIssuer().getValue(), /* The IdP entityID = issuer of the SAD. */ sadRequest.getRequesterID(), /* The requester ID = expected recipient ID of the SAD. */ AttributeUtils.getAttributeStringValue(subjectAttribute), /* The expected subject name. */ loa, /* The expected LoA. */ sadRequest.getID(), /* The expected in-response-to ID. */ sadRequest.getDocCount().intValue(), /* The expected number of documents indicated in the SAD. */ sadRequest.getSignRequestID()); /* The SignRequest ID. */ } /** * Validates a SAD based on expected data. If the {@code AuthnRequest} and issued {@code Assertion} is available, * the method {@link #validate(AuthnRequest, Assertion)} is a better option. * *

* Note: It is assumed that the supplied {@code expectedSubject} parameter is a attribute value read from the * assertion having the attribute name indicated in the 'attr' field of the SAD. If this attribute name is not known * in advance, the SAD needs to be parsed ({@link SADParser#parse(String)}) so that the 'attr' field can be read, * and the correct attribute value be located from the assertion. *

* * @param sadJwt * the encoded SAD JWT (found in the sad attribute of a received assertion) * @param idpEntityID * the entityID of the issuing IdP (the issuer of the received assertion holding the sad attribute) * @param expectedRecipientEntityID * the entityID of the recipient (the signature service SP that issued the SADRequest) * @param expectedSubject * the expected subject name (user ID). See note above * @param expectedLoa * the expected level of assurance to be found in the SAD (should be the LoA found in the assertion) * @param sadRequestID * the ID of the {@code SADRequest} extension that was sent to the IdP * @param expectedNoDocs * expected number of documents (from the {@code DocCount} element of the {@code SADRequest} * @param signRequestID * ID for the {@code SignRequest} that was included in the {@code SADRequest} * @return a SAD object * @throws SADValidationException * for validation errors */ public SAD validate(String sadJwt, String idpEntityID, String expectedRecipientEntityID, String expectedSubject, String expectedLoa, String sadRequestID, int expectedNoDocs, String signRequestID) throws SADValidationException { long now = System.currentTimeMillis() / 1000; try { // Parse the JWT // SignedJWT signedJwt = SignedJWT.parse(sadJwt); // Next, parse the SAD. // String payload = signedJwt.getPayload().toBase64URL().toString(); SAD sad = SAD.fromJson(new String(Base64.getUrlDecoder().decode(payload), Charset.forName("UTF-8"))); return this.validate(signedJwt, sad, now, idpEntityID, expectedRecipientEntityID, expectedSubject, expectedLoa, sadRequestID, expectedNoDocs, signRequestID); } catch (ParseException | IOException e) { throw new SADValidationException(ErrorCode.JWT_PARSE_ERROR, "Failed to parse SAD JWT", e); } } /** * Validates the supplied SAD JWT. * * @param signedJwt * the SAD JWT * @param sad * the SAD (parsed for pre-checks) * @param now * the current time (seconds since 1970-01-01) * @param idpEntityID * the entityID of the issuing IdP (the issuer of the received assertion holding the sad attribute) * @param expectedRecipientEntityID * the entityID of the recipient (the signature service SP that issued the SADRequest) * @param expectedSubject * the expected subject name (user ID). See note above * @param expectedLoa * the expected level of assurance to be found in the SAD (should be the LoA found in the assertion) * @param sadRequestID * the ID of the {@code SADRequest} extension that was sent to the IdP * @param expectedNoDocs * expected number of documents (from the {@code DocCount} element of the {@code SADRequest} * @param signRequestID * ID for the {@code SignRequest} that was included in the {@code SADRequest} * @return a SAD object * @throws SADValidationException * for validation errors */ private SAD validate(SignedJWT signedJwt, SAD sad, long now, String idpEntityID, String expectedRecipientEntityID, String expectedSubject, String expectedLoa, String sadRequestID, int expectedNoDocs, String signRequestID) throws SADValidationException { // Verify the JWT signature // this.verifyJwtSignature(signedJwt, idpEntityID); // Ensure that we have a JWT ID. // if (sad.getJwtId() == null || sad.getJwtId().isEmpty()) { String msg = "Invalid SAD JWT - jti is missing"; logger.info(msg); throw new SADValidationException(ErrorCode.BAD_SAD_FORMAT, msg); } // Make sure that the SAD issuer is the same as the IdP that issued the Assertion // that contained the SAD. // if (!Objects.equals(idpEntityID, sad.getIssuer())) { String msg = String.format("SAD contains issuer '%s' - expected '%s'", sad.getIssuer(), idpEntityID); logger.info(msg); throw new SADValidationException(ErrorCode.VALIDATION_BAD_ISSUER, msg); } // Make sure that this SAD was issued for "me". // if (!Objects.equals(expectedRecipientEntityID, sad.getAudience())) { String msg = String.format("SAD contains audience '%s' - expected '%s'", sad.getAudience(), expectedRecipientEntityID); logger.info(msg); throw new SADValidationException(ErrorCode.VALIDATION_BAD_AUDIENCE, msg); } // Make sure that the SAD is still valid. // if (sad.getExpiry() == null || sad.getIssuedAt() == null) { String msg = "SAD is missing 'exp' and/or 'iat' - Invalid SAD"; logger.info(msg); throw new SADValidationException(ErrorCode.BAD_SAD_FORMAT, msg); } if (sad.getExpiry() < now) { String msg = String.format("SAD has expired - expiration: '%s', current time: '%s'", sad.getExpiryDateTime(), new DateTime(now * 1000L)); logger.info(msg); throw new SADValidationException(ErrorCode.SAD_EXPIRED, msg); } if (sad.getIssuedAt() > now) { String msg = String.format("SAD is not yet valid - issue-time: '%s', current time: '%s'", sad.getIssuedAtDateTime(), new DateTime(now * 1000L)); logger.info(msg); throw new SADValidationException(ErrorCode.BAD_SAD_FORMAT, msg); } // Assert that we received a SAD for the expected subject ID (userID). // if (!Objects.equals(expectedSubject, sad.getSubject())) { String msg = String.format("SAD contains subject '%s' - expected '%s'", sad.getSubject(), expectedSubject); logger.info(msg); throw new SADValidationException(ErrorCode.VALIDATION_BAD_SUBJECT, msg); } // Ensure SAD format. // if (sad.getSeElnSadext() == null) { String msg = "seElnSadext extension claims are missing from SAD"; logger.info(msg); throw new SADValidationException(ErrorCode.BAD_SAD_FORMAT, msg); } // Assert that the SAD was issued based on the given SAD request. // if (!Objects.equals(sadRequestID, sad.getSeElnSadext().getInResponseTo())) { String msg = String.format("SAD contains in-response-to (irt) '%s' - expected SAD to belong to SADRequest with ID '%s'", sad.getSeElnSadext().getInResponseTo(), sadRequestID); logger.info(msg); throw new SADValidationException(ErrorCode.VALIDATION_BAD_IRT, msg); } // Assert that the SAD was issued under the LoA that we expects (should be the same as found in the assertion). // if (!Objects.equals(expectedLoa, sad.getSeElnSadext().getLoa())) { String msg = String.format("SAD contains LoA '%s' - expected '%s'", sad.getSeElnSadext().getLoa(), expectedLoa); logger.info(msg); throw new SADValidationException(ErrorCode.VALIDATION_BAD_LOA, msg); } // Assert the the number of documents indicated in the SAD corresponds with the number given in the SADRequest. // if (!Objects.equals(Integer.valueOf(expectedNoDocs), sad.getSeElnSadext().getNumberOfDocuments())) { String msg = String.format("SAD indicated '%s' number of documents - expected '%d'", sad.getSeElnSadext().getNumberOfDocuments(), expectedNoDocs); logger.info(msg); throw new SADValidationException(ErrorCode.VALIDATION_BAD_DOCS, msg); } // Assert that the given SignRequest ID corresponds with the SAD reqid. // if (!Objects.equals(signRequestID, sad.getSeElnSadext().getRequestID())) { String msg = String.format("SAD contains SignRequest ID (reqid) '%s' - expected '%s'", sad.getSeElnSadext().getRequestID(), signRequestID); logger.info(msg); throw new SADValidationException(ErrorCode.VALIDATION_BAD_SIGNREQUESTID, msg); } logger.debug("SAD with ID '{}' was successfully validated", sad.getJwtId()); return sad; } /** * Verifies the signature on the supplied SAD JWT. * * @param sadJwt * the SAD JWT * @param idpEntityID * the entityID of the IdP that signed the JWT * @throws SADValidationException * for signature validation errors */ public void verifyJwtSignature(String sadJwt, String idpEntityID) throws SADValidationException { try { this.verifyJwtSignature(SignedJWT.parse(sadJwt), idpEntityID); } catch (ParseException e) { throw new SADValidationException(ErrorCode.JWT_PARSE_ERROR, "Failed to parse SAD JWT", e); } } /** * Verifies the signature on the supplied SAD JWT. * * @param sadJwt * the SAD JWT * @param idpEntityID * the entityID of the IdP that signed the JWT * @throws SADValidationException * for signature validation errors */ private void verifyJwtSignature(SignedJWT signedJwt, String idpEntityID) throws SADValidationException { try { // Verify the JWT signature // List idpCerts = this.getValidationCertificates(idpEntityID); if (idpCerts.isEmpty()) { throw new SADValidationException(ErrorCode.SIGNATURE_VALIDATION_ERROR, "No suitable IdP signature certificate was found - can not verify SAD JWT signature"); } logger.debug("Verifying SAD JWT signature. Will try {} IdP key(s) ...", idpCerts.size()); boolean verificationSuccess = false; for (X509Certificate idpCert : idpCerts) { try { JWSVerifier verifier = verifierFactory.createJWSVerifier(signedJwt.getHeader(), idpCert.getPublicKey()); if (verifier.verify(signedJwt.getHeader(), signedJwt.getSigningInput(), signedJwt.getSignature())) { logger.debug("SAD JWT signature successfully verified"); verificationSuccess = true; break; } } catch (JOSEException e) { logger.debug("Failed to perform signature validation of SAD JWT - {}", e.getMessage()); logger.trace("", e); } } if (!verificationSuccess) { throw new SADValidationException(ErrorCode.SIGNATURE_VALIDATION_ERROR, "Signature on SAD JWT could not be validated using any of the IdP certificates found"); } } catch (ResolverException e) { throw new SADValidationException(ErrorCode.SIGNATURE_VALIDATION_ERROR, "Failed to find validation certificate", e); } } /** * Returns a list of possible IdP validation certificates to use when verifying the SAD signature. * * @param idpEntityID * the IdP entityID * @return a list of certificates * @throws ResolverException * for metadata resolver errors */ private List getValidationCertificates(String idpEntityID) throws ResolverException { if (this.validationCertificates != null && !this.validationCertificates.isEmpty()) { return this.validationCertificates; } else if (this.metadataProvider != null) { Optional metadata = this.metadataProvider.getEntityDescriptor(idpEntityID); if (!metadata.isPresent()) { logger.warn("No metadata found for IdP '{}' - cannot find key to use when verifying SAD JWT signature", idpEntityID); return Collections.emptyList(); } List creds = MetadataUtils.getMetadataCertificates(metadata.get(), UsageType.SIGNING); return creds.stream().map(X509Credential::getEntityCertificate).collect(Collectors.toList()); } else { return Collections.emptyList(); } } /** * Returns the LoA (level of assurance) URI from the supplied assertion. * * @param assertion * the assertion * @return the LoA URI, or {@code null} */ private static String getLoa(Assertion assertion) { try { return assertion.getAuthnStatements().get(0).getAuthnContext().getAuthnContextClassRef().getAuthnContextClassRef(); } catch (Exception e) { return null; } } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy