Browse Source

SAML Assertion validation should propagate errors: #7375 and #7375

Fixes gh-7377
Fixes gh-7375

https://github.com/spring-projects/spring-security/issues/7377
https://github.com/spring-projects/spring-security/issues/7375
Filip Hanik 6 years ago
parent
commit
d472e99528

+ 2 - 0
saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle

@@ -9,4 +9,6 @@ dependencies {
     compile("org.opensaml:opensaml-saml-impl")
 
     provided 'javax.servlet:javax.servlet-api'
+
+	testCompile powerMock2Dependencies
 }

+ 159 - 79
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProvider.java

@@ -16,16 +16,12 @@
 package org.springframework.security.saml2.provider.service.authentication;
 
 import org.springframework.core.convert.converter.Converter;
-import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
 import org.springframework.security.authentication.AuthenticationProvider;
-import org.springframework.security.authentication.AuthenticationServiceException;
-import org.springframework.security.authentication.InsufficientAuthenticationException;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
-import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.security.saml2.Saml2Exception;
 import org.springframework.security.saml2.credentials.Saml2X509Credential;
 import org.springframework.util.Assert;
@@ -79,18 +75,59 @@ import java.util.Set;
 import static java.lang.String.format;
 import static java.util.Collections.singleton;
 import static java.util.Collections.singletonList;
+import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.DECRYPTION_ERROR;
+import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.INVALID_DESTINATION;
+import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.INVALID_ISSUER;
+import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.MALFORMED_RESPONSE_DATA;
+import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.SUBJECT_NOT_FOUND;
+import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.UNKNOWN_RESPONSE_CLASS;
+import static org.springframework.security.saml2.provider.service.authentication.Saml2ErrorCodes.USERNAME_NOT_FOUND;
 import static org.springframework.util.Assert.notNull;
 import static org.springframework.util.StringUtils.hasText;
 
 /**
+ * Implementation of {@link AuthenticationProvider} for SAML authentications when receiving a
+ * {@code Response} object containing an {@code Assertion}. This implementation uses
+ * the {@code OpenSAML 3} library.
+ *
+ * <p>
+ *  The {@link OpenSamlAuthenticationProvider} supports {@link Saml2AuthenticationToken} objects
+ *  that contain a SAML response in its decoded XML format {@link Saml2AuthenticationToken#getSaml2Response()}
+ *  along with the information about the asserting party, the identity provider (IDP), as well as
+ *  the relying party, the service provider (SP, this application).
+ * </p>
+ * <p>
+ *   The {@link Saml2AuthenticationToken} will be processed into a SAML Response object.
+ *   The SAML response object can be signed. If the Response is signed, a signature will not be required on the assertion.
+ * </p>
+ * <p>
+ *   While a response object can contain a list of assertion, this provider will only leverage
+ *   the first valid assertion for the purpose of authentication. Assertions that do not pass validation
+ *   will be ignored. If no valid assertions are found a {@link Saml2AuthenticationException} is thrown.
+ * </p>
+ * <p>
+ *   This provider supports two types of encrypted SAML elements
+ *   <ul>
+ *     <li><a href="https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=17">EncryptedAssertion</a></li>
+ *     <li><a href="https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=14">EncryptedID</a></li>
+ *   </ul>
+ *   If the assertion is encrypted, then signature validation on the assertion is no longer required.
+ * </p>
+ * <p>
+ *   This provider does not perform an X509 certificate validation on the configured asserting party, IDP, verification
+ *   certificates.
+ * </p>
  * @since 5.2
+ * @see <a href="https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=38">SAML 2 StatusResponse</a>
+ * @see <a href="https://wiki.shibboleth.net/confluence/display/OS30/Home">OpenSAML 3</a>
  */
 public final class OpenSamlAuthenticationProvider implements AuthenticationProvider {
 
 	private static Log logger = LogFactory.getLog(OpenSamlAuthenticationProvider.class);
 
 	private final OpenSamlImplementation saml = OpenSamlImplementation.getInstance();
-	private Converter<Assertion, Collection<? extends GrantedAuthority>> authoritiesExtractor = (a -> singletonList(new SimpleGrantedAuthority("ROLE_USER")));
+	private Converter<Assertion, Collection<? extends GrantedAuthority>> authoritiesExtractor =
+			(a -> singletonList(new SimpleGrantedAuthority("ROLE_USER")));
 	private GrantedAuthoritiesMapper authoritiesMapper = (a -> a);
 	private Duration responseTimeValidationSkew = Duration.ofMinutes(5);
 
@@ -138,20 +175,16 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi
 			Saml2AuthenticationToken token = (Saml2AuthenticationToken) authentication;
 			String xml = token.getSaml2Response();
 			Response samlResponse = getSaml2Response(xml);
-
 			Assertion assertion = validateSaml2Response(token, token.getRecipientUri(), samlResponse);
-			final String username = getUsername(token, assertion);
-			if (username == null) {
-				throw new UsernameNotFoundException("Assertion [" +
-						assertion.getID() +
-						"] is missing a user identifier");
-			}
+			String username = getUsername(token, assertion);
 			return new Saml2Authentication(
 					() -> username, token.getSaml2Response(),
 					this.authoritiesMapper.mapAuthorities(getAssertionAuthorities(assertion))
 			);
-		}catch (Saml2Exception | IllegalArgumentException e) {
-			throw new AuthenticationServiceException(e.getMessage(), e);
+		} catch (Saml2AuthenticationException e) {
+			throw e;
+		} catch (Exception e) {
+			throw authException(Saml2ErrorCodes.INTERNAL_VALIDATION_ERROR, e.getMessage(), e);
 		}
 	}
 
@@ -167,88 +200,111 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi
 		return this.authoritiesExtractor.convert(assertion);
 	}
 
-	private String getUsername(Saml2AuthenticationToken token, Assertion assertion) {
-		final Subject subject = assertion.getSubject();
+	private String getUsername(Saml2AuthenticationToken token, Assertion assertion) throws Saml2AuthenticationException {
+		String username = null;
+		Subject subject = assertion.getSubject();
 		if (subject == null) {
-			return null;
+			throw authException(SUBJECT_NOT_FOUND, "Assertion [" + assertion.getID() + "] is missing a subject");
 		}
 		if (subject.getNameID() != null) {
-			return subject.getNameID().getValue();
+			username = subject.getNameID().getValue();
 		}
-		if (subject.getEncryptedID() != null) {
+		else if (subject.getEncryptedID() != null) {
 			NameID nameId = decrypt(token, subject.getEncryptedID());
-			return nameId.getValue();
+			username = nameId.getValue();
 		}
-		return null;
+		if (username == null) {
+			throw authException(USERNAME_NOT_FOUND, "Assertion [" + assertion.getID() + "] is missing a user identifier");
+		}
+		return username;
 	}
 
 	private Assertion validateSaml2Response(Saml2AuthenticationToken token,
 											String recipient,
-											Response samlResponse) throws AuthenticationException {
+											Response samlResponse) throws Saml2AuthenticationException {
+		//optional validation if the response contains a destination
 		if (hasText(samlResponse.getDestination()) && !recipient.equals(samlResponse.getDestination())) {
-			throw new Saml2Exception("Invalid SAML response destination: " + samlResponse.getDestination());
+			throw authException(INVALID_DESTINATION, "Invalid SAML response destination: " + samlResponse.getDestination());
 		}
 
-		final String issuer = samlResponse.getIssuer().getValue();
+		String issuer = samlResponse.getIssuer().getValue();
 		if (logger.isDebugEnabled()) {
-			logger.debug("Processing SAML response from " + issuer);
+			logger.debug("Validating SAML response from " + issuer);
 		}
-		if (token == null) {
-			throw new Saml2Exception(format("SAML 2 Provider for %s was not found.", issuer));
+		if (!hasText(issuer) || (!issuer.equals(token.getIdpEntityId()))) {
+			String message = String.format("Response issuer '%s' doesn't match '%s'", issuer, token.getIdpEntityId());
+			throw authException(INVALID_ISSUER, message);
 		}
+		Saml2AuthenticationException lastValidationError = null;
+
 		boolean responseSigned = hasValidSignature(samlResponse, token);
 		for (Assertion a : samlResponse.getAssertions()) {
 			if (logger.isDebugEnabled()) {
 				logger.debug("Checking plain assertion validity " + a);
 			}
-			if (isValidAssertion(recipient, a, token, !responseSigned)) {
-				if (logger.isDebugEnabled()) {
-					logger.debug("Found valid assertion. Skipping potential others.");
-				}
+			try {
+				validateAssertion(recipient, a, token, !responseSigned);
 				return a;
+			} catch (Saml2AuthenticationException e) {
+				lastValidationError = e;
 			}
 		}
 		for (EncryptedAssertion ea : samlResponse.getEncryptedAssertions()) {
 			if (logger.isDebugEnabled()) {
 				logger.debug("Checking encrypted assertion validity " + ea);
 			}
-
-			Assertion a = decrypt(token, ea);
-			if (isValidAssertion(recipient, a, token, false)) {
-				if (logger.isDebugEnabled()) {
-					logger.debug("Found valid encrypted assertion. Skipping potential others.");
-				}
+			try {
+				Assertion a = decrypt(token, ea);
+				validateAssertion(recipient, a, token, false);
 				return a;
+			} catch (Saml2AuthenticationException e) {
+				lastValidationError = e;
 			}
 		}
-		throw new InsufficientAuthenticationException("Unable to find a valid assertion");
+		if (lastValidationError != null) {
+			throw lastValidationError;
+		}
+		else {
+			throw authException(MALFORMED_RESPONSE_DATA, "No assertions found in response.");
+		}
 	}
 
-	private boolean hasValidSignature(SignableSAMLObject samlResponse, Saml2AuthenticationToken token) {
-		if (!samlResponse.isSigned()) {
+	private boolean hasValidSignature(SignableSAMLObject samlObject, Saml2AuthenticationToken token) {
+		if (!samlObject.isSigned()) {
+			if (logger.isDebugEnabled()) {
+				logger.debug("SAML object is not signed, no signatures found");
+			}
 			return false;
 		}
 
-		final List<X509Certificate> verificationKeys = getVerificationKeys(token);
+		List<X509Certificate> verificationKeys = getVerificationCertificates(token);
 		if (verificationKeys.isEmpty()) {
 			return false;
 		}
 
-		for (X509Certificate key : verificationKeys) {
-			final Credential credential = getVerificationCredential(key);
+		for (X509Certificate certificate : verificationKeys) {
+			Credential credential = getVerificationCredential(certificate);
 			try {
-				SignatureValidator.validate(samlResponse.getSignature(), credential);
+				SignatureValidator.validate(samlObject.getSignature(), credential);
+				if (logger.isDebugEnabled()) {
+					logger.debug("Valid signature found in SAML object:"+samlObject.getClass().getName());
+				}
 				return true;
 			}
 			catch (SignatureException ignored) {
-				logger.debug("Signature validation failed", ignored);
+				if (logger.isTraceEnabled()) {
+					logger.trace("Signature validation failed with cert:"+certificate.toString(), ignored);
+				}
+				else if (logger.isDebugEnabled()) {
+					logger.debug("Signature validation failed with cert:"+certificate.toString());
+				}
 			}
 		}
 		return false;
 	}
 
-	private boolean isValidAssertion(String recipient, Assertion a, Saml2AuthenticationToken token, boolean signatureRequired) {
-		final SAML20AssertionValidator validator = getAssertionValidator(token);
+	private void validateAssertion(String recipient, Assertion a, Saml2AuthenticationToken token, boolean signatureRequired) {
+		SAML20AssertionValidator validator = getAssertionValidator(token);
 		Map<String, Object> validationParams = new HashMap<>();
 		validationParams.put(SAML2AssertionValidationParameters.SIGNATURE_REQUIRED, false);
 		validationParams.put(
@@ -267,55 +323,78 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi
 			if (logger.isDebugEnabled()) {
 				logger.debug(format("Assertion [%s] does not a valid signature.", a.getID()));
 			}
-			return false;
+			throw authException(Saml2ErrorCodes.INVALID_SIGNATURE, "Assertion doesn't have a valid signature.");
 		}
+		//ensure that OpenSAML doesn't attempt signature validation, already performed
 		a.setSignature(null);
 
-		// validation for recipient
+		//remainder of assertion validation
 		ValidationContext vctx = new ValidationContext(validationParams);
 		try {
-			final ValidationResult result = validator.validate(a, vctx);
-			final boolean valid = result.equals(ValidationResult.VALID);
+			ValidationResult result = validator.validate(a, vctx);
+			boolean valid = result.equals(ValidationResult.VALID);
 			if (!valid) {
 				if (logger.isDebugEnabled()) {
-					logger.debug(format("Failed to validate assertion from %s with user %s", token.getIdpEntityId(),
-							getUsername(token, a)
-					));
+					logger.debug(format("Failed to validate assertion from %s", token.getIdpEntityId()));
 				}
+				throw authException(Saml2ErrorCodes.INVALID_ASSERTION, vctx.getValidationFailureMessage());
 			}
-			return valid;
 		}
 		catch (AssertionValidationException e) {
 			if (logger.isDebugEnabled()) {
 				logger.debug("Failed to validate assertion:", e);
 			}
-			return false;
+			throw authException(Saml2ErrorCodes.INTERNAL_VALIDATION_ERROR, e.getMessage(), e);
 		}
 
 	}
 
-	private Response getSaml2Response(String xml) throws Saml2Exception, AuthenticationException {
-		final Object result = this.saml.resolve(xml);
-		if (result == null) {
-			throw new AuthenticationCredentialsNotFoundException("SAMLResponse returned null object");
-		}
-		else if (result instanceof Response) {
-			return (Response) result;
+	private Response getSaml2Response(String xml) throws Saml2Exception, Saml2AuthenticationException {
+		try {
+			Object result = this.saml.resolve(xml);
+			if (result instanceof Response) {
+				return (Response) result;
+			}
+			else {
+				throw authException(UNKNOWN_RESPONSE_CLASS, "Invalid response class:" + result.getClass().getName());
+			}
+		} catch (Saml2Exception x) {
+			throw authException(MALFORMED_RESPONSE_DATA, x.getMessage(), x);
 		}
-		throw new IllegalArgumentException("Invalid response class:"+result.getClass().getName());
+
+	}
+
+	private Saml2Error validationError(String code, String description) {
+		return new Saml2Error(
+				code,
+				description
+		);
+	}
+
+	private Saml2AuthenticationException authException(String code, String description) throws Saml2AuthenticationException {
+		return new Saml2AuthenticationException(
+				validationError(code, description)
+		);
+	}
+
+
+	private Saml2AuthenticationException authException(String code, String description, Exception cause) throws Saml2AuthenticationException {
+		return new Saml2AuthenticationException(
+				validationError(code, description),
+				cause
+		);
 	}
 
 	private SAML20AssertionValidator getAssertionValidator(Saml2AuthenticationToken provider) {
 		List<ConditionValidator> conditions = Collections.singletonList(new AudienceRestrictionConditionValidator());
-		final BearerSubjectConfirmationValidator subjectConfirmationValidator =
-				new BearerSubjectConfirmationValidator();
+		BearerSubjectConfirmationValidator subjectConfirmationValidator = new BearerSubjectConfirmationValidator();
 
 		List<SubjectConfirmationValidator> subjects = Collections.singletonList(subjectConfirmationValidator);
 		List<StatementValidator> statements = Collections.emptyList();
 
 		Set<Credential> credentials = new HashSet<>();
-		for (X509Certificate key : getVerificationKeys(provider)) {
-			final Credential cred = getVerificationCredential(key);
+		for (X509Certificate key : getVerificationCertificates(provider)) {
+			Credential cred = getVerificationCredential(key);
 			credentials.add(cred);
 		}
 		CredentialResolver credentialsResolver = new CollectionCredentialResolver(credentials);
@@ -345,37 +424,38 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi
 		return decrypter;
 	}
 
-	private Assertion decrypt(Saml2AuthenticationToken token, EncryptedAssertion assertion) {
-		Saml2Exception last = null;
+	private Assertion decrypt(Saml2AuthenticationToken token, EncryptedAssertion assertion)
+			throws Saml2AuthenticationException {
+		Saml2AuthenticationException last = null;
 		List<Saml2X509Credential> decryptionCredentials = getDecryptionCredentials(token);
 		if (decryptionCredentials.isEmpty()) {
-			throw new Saml2Exception("No valid decryption credentials found.");
+			throw authException(DECRYPTION_ERROR, "No valid decryption credentials found.");
 		}
 		for (Saml2X509Credential key : decryptionCredentials) {
-			final Decrypter decrypter = getDecrypter(key);
+			Decrypter decrypter = getDecrypter(key);
 			try {
 				return decrypter.decrypt(assertion);
 			}
 			catch (DecryptionException e) {
-				last = new Saml2Exception(e);
+				last = authException(DECRYPTION_ERROR, e.getMessage(), e);
 			}
 		}
 		throw last;
 	}
 
-	private NameID decrypt(Saml2AuthenticationToken token, EncryptedID assertion) {
-		Saml2Exception last = null;
+	private NameID decrypt(Saml2AuthenticationToken token, EncryptedID assertion) throws Saml2AuthenticationException {
+		Saml2AuthenticationException last = null;
 		List<Saml2X509Credential> decryptionCredentials = getDecryptionCredentials(token);
 		if (decryptionCredentials.isEmpty()) {
-			throw new Saml2Exception("No valid decryption credentials found.");
+			throw authException(DECRYPTION_ERROR, "No valid decryption credentials found.");
 		}
 		for (Saml2X509Credential key : decryptionCredentials) {
-			final Decrypter decrypter = getDecrypter(key);
+			Decrypter decrypter = getDecrypter(key);
 			try {
 				return (NameID) decrypter.decrypt(assertion);
 			}
 			catch (DecryptionException e) {
-				last = new Saml2Exception(e);
+				last = authException(DECRYPTION_ERROR, e.getMessage(), e);
 			}
 		}
 		throw last;
@@ -391,7 +471,7 @@ public final class OpenSamlAuthenticationProvider implements AuthenticationProvi
 		return result;
 	}
 
-	private List<X509Certificate> getVerificationKeys(Saml2AuthenticationToken token) {
+	private List<X509Certificate> getVerificationCertificates(Saml2AuthenticationToken token) {
 		List<X509Certificate> result = new LinkedList<>();
 		for (Saml2X509Credential c : token.getX509Credentials()) {
 			if (c.isSignatureVerficationCredential()) {

+ 106 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticationException.java

@@ -0,0 +1,106 @@
+/*
+ * Copyright 2002-2019 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.util.Assert;
+
+/**
+ * This exception is thrown for all SAML 2.0 related {@link Authentication} errors.
+ *
+ * <p>
+ * There are a number of scenarios where an error may occur, for example:
+ * <ul>
+ *  <li>The response or assertion request is missing or malformed</li>
+ *  <li>Missing or invalid subject</li>
+ *  <li>Missing or invalid signatures</li>
+ *  <li>The time period validation for the assertion fails</li>
+ *  <li>One of the assertion conditions was not met</li>
+ *  <li>Decryption failed</li>
+ *  <li>Unable to locate a subject identifier, commonly known as username</li>
+ * </ul>
+ *
+ * @since 5.2
+ */
+public class Saml2AuthenticationException extends AuthenticationException {
+	private Saml2Error error;
+
+	/**
+	 * Constructs a {@code Saml2AuthenticationException} using the provided parameters.
+	 *
+	 * @param error the {@link Saml2Error SAML 2.0 Error}
+	 */
+	public Saml2AuthenticationException(Saml2Error error) {
+		this(error, error.getDescription());
+	}
+
+	/**
+	 * Constructs a {@code Saml2AuthenticationException} using the provided parameters.
+	 *
+	 * @param error the {@link Saml2Error SAML 2.0 Error}
+	 * @param cause the root cause
+	 */
+	public Saml2AuthenticationException(Saml2Error error, Throwable cause) {
+		this(error, cause.getMessage(), cause);
+	}
+
+	/**
+	 * Constructs a {@code Saml2AuthenticationException} using the provided parameters.
+	 *
+	 * @param error the {@link Saml2Error SAML 2.0 Error}
+	 * @param message the detail message
+	 */
+	public Saml2AuthenticationException(Saml2Error error, String message) {
+		super(message);
+		this.setError(error);
+	}
+
+	/**
+	 * Constructs a {@code Saml2AuthenticationException} using the provided parameters.
+	 *
+	 * @param error the {@link Saml2Error SAML 2.0 Error}
+	 * @param message the detail message
+	 * @param cause the root cause
+	 */
+	public Saml2AuthenticationException(Saml2Error error, String message, Throwable cause) {
+		super(message, cause);
+		this.setError(error);
+	}
+
+	/**
+	 * Returns the {@link Saml2Error SAML 2.0 Error}.
+	 *
+	 * @return the {@link Saml2Error}
+	 */
+	public Saml2Error getError() {
+		return this.error;
+	}
+
+	private void setError(Saml2Error error) {
+		Assert.notNull(error, "error cannot be null");
+		this.error = error;
+	}
+
+	@Override
+	public String toString() {
+		final StringBuffer sb = new StringBuffer("Saml2AuthenticationException{");
+		sb.append("error=").append(error);
+		sb.append('}');
+		return sb.toString();
+	}
+}

+ 75 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Error.java

@@ -0,0 +1,75 @@
+/*
+ * Copyright 2002-2019 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication;
+
+import org.springframework.security.core.SpringSecurityCoreVersion;
+import org.springframework.util.Assert;
+
+import java.io.Serializable;
+
+/**
+ * A representation of an SAML 2.0 Error.
+ *
+ * <p>
+ * At a minimum, an error response will contain an error code.
+ * The commonly used error code are defined in this class
+ * or a new codes can be defined in the future as arbitrary strings.
+ * </p>
+ * @since 5.2
+ */
+public class Saml2Error implements Serializable {
+	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
+
+	private final String errorCode;
+	private final String description;
+
+	/**
+	 * Constructs a {@code Saml2Error} using the provided parameters.
+	 *
+	 * @param errorCode the error code
+	 * @param description the error description
+	 */
+	public Saml2Error(String errorCode, String description) {
+		Assert.hasText(errorCode, "errorCode cannot be empty");
+		this.errorCode = errorCode;
+		this.description = description;
+	}
+
+	/**
+	 * Returns the error code.
+	 *
+	 * @return the error code
+	 */
+	public final String getErrorCode() {
+		return this.errorCode;
+	}
+
+	/**
+	 * Returns the error description.
+	 *
+	 * @return the error description
+	 */
+	public final String getDescription() {
+		return this.description;
+	}
+
+	@Override
+	public String toString() {
+		return "[" + this.getErrorCode() + "] " +
+				(this.getDescription() != null ? this.getDescription() : "");
+	}
+}

+ 96 - 0
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ErrorCodes.java

@@ -0,0 +1,96 @@
+/*
+ * Copyright 2002-2019 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication;
+
+/**
+ * A list of SAML known 2 error codes used during SAML authentication.
+ *
+ * @since 5.2
+ */
+public interface Saml2ErrorCodes {
+	/**
+	 * SAML Data does not represent a SAML 2 Response object.
+	 * A valid XML object was received, but that object was not a
+	 * SAML 2 Response object of type {@code ResponseType} per specification
+	 * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=46
+	 */
+	String UNKNOWN_RESPONSE_CLASS = "unknown_response_class";
+	/**
+	 * The response data is malformed or incomplete.
+	 * An invalid XML object was received, and XML unmarshalling failed.
+	 */
+	String MALFORMED_RESPONSE_DATA = "malformed_response_data";
+	/**
+	 * Response destination does not match the request URL.
+	 * A SAML 2 response object was received at a URL that
+	 * did not match the URL stored in the {code Destination} attribute
+	 * in the Response object.
+	 * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=38
+	 */
+	String INVALID_DESTINATION = "invalid_destination";
+	/**
+	 * The assertion was not valid.
+	 * The assertion used for authentication failed validation.
+	 * Details around the failure will be present in the error description.
+	 */
+	String INVALID_ASSERTION = "invalid_assertion";
+	/**
+	 * The signature of response or assertion was invalid.
+	 * Either the response or the assertion was missing a signature
+	 * or the signature could not be verified using the system's
+	 * configured credentials. Most commonly the IDP's
+	 * X509 certificate.
+	 */
+	String INVALID_SIGNATURE = "invalid_signature";
+	/**
+	 * The assertion did not contain a subject element.
+	 * The subject element, type SubjectType, contains
+	 * a {@code NameID} or an {@code EncryptedID} that is used
+	 * to assign the authenticated principal an identifier,
+	 * typically a username.
+	 *
+	 * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=18
+	 */
+	String SUBJECT_NOT_FOUND = "subject_not_found";
+	/**
+	 * The subject did not contain a user identifier
+	 * The assertion contained a subject element, but the subject
+	 * element did not have a {@code NameID} or {@code EncryptedID}
+	 * element
+	 *
+	 * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=18
+	 */
+	String USERNAME_NOT_FOUND = "username_not_found";
+	/**
+	 * The system failed to decrypt an assertion or a name identifier.
+	 * This error code will be thrown if the decryption of either a
+	 * {@code EncryptedAssertion} or {@code EncryptedID} fails.
+	 * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=17
+	 */
+	String DECRYPTION_ERROR = "decryption_error";
+	/**
+	 * An Issuer element contained a value that didn't
+	 * https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=15
+	 */
+	String INVALID_ISSUER = "invalid_issuer";
+	/**
+	 * An error happened during validation.
+	 * Used when internal, non classified, errors are caught during the
+	 * authentication process.
+	 */
+	String INTERNAL_VALIDATION_ERROR = "internal_validation_error";
+}

+ 149 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/credentials/Saml2X509CredentialTests.java

@@ -0,0 +1,149 @@
+/*
+ * Copyright 2002-2019 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.credentials;
+
+import org.springframework.security.converter.RsaKeyConverters;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.io.ByteArrayInputStream;
+import java.security.PrivateKey;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.DECRYPTION;
+import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.ENCRYPTION;
+import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.SIGNING;
+import static org.springframework.security.saml2.credentials.Saml2X509Credential.Saml2X509CredentialType.VERIFICATION;
+
+public class Saml2X509CredentialTests {
+
+	@Rule
+	public ExpectedException exception = ExpectedException.none();
+
+	private Saml2X509Credential credential;
+	private PrivateKey key;
+	private X509Certificate certificate;
+
+	@Before
+	public void setup() throws Exception {
+		String keyData = "-----BEGIN PRIVATE KEY-----\n" +
+				"MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBANG7v8QjQGU3MwQE\n" +
+				"VUBxvH6Uuiy/MhZT7TV0ZNjyAF2ExA1gpn3aUxx6jYK5UnrpxRRE/KbeLucYbOhK\n" +
+				"cDECt77Rggz5TStrOta0BQTvfluRyoQtmQ5Nkt6Vqg7O2ZapFt7k64Sal7AftzH6\n" +
+				"Q2BxWN1y04bLdDrH4jipqRj/2qEFAgMBAAECgYEAj4ExY1jjdN3iEDuOwXuRB+Nn\n" +
+				"x7pC4TgntE2huzdKvLJdGvIouTArce8A6JM5NlTBvm69mMepvAHgcsiMH1zGr5J5\n" +
+				"wJz23mGOyhM1veON41/DJTVG+cxq4soUZhdYy3bpOuXGMAaJ8QLMbQQoivllNihd\n" +
+				"vwH0rNSK8LTYWWPZYIECQQDxct+TFX1VsQ1eo41K0T4fu2rWUaxlvjUGhK6HxTmY\n" +
+				"8OMJptunGRJL1CUjIb45Uz7SP8TPz5FwhXWsLfS182kRAkEA3l+Qd9C9gdpUh1uX\n" +
+				"oPSNIxn5hFUrSTW1EwP9QH9vhwb5Vr8Jrd5ei678WYDLjUcx648RjkjhU9jSMzIx\n" +
+				"EGvYtQJBAMm/i9NR7IVyyNIgZUpz5q4LI21rl1r4gUQuD8vA36zM81i4ROeuCly0\n" +
+				"KkfdxR4PUfnKcQCX11YnHjk9uTFj75ECQEFY/gBnxDjzqyF35hAzrYIiMPQVfznt\n" +
+				"YX/sDTE2AdVBVGaMj1Cb51bPHnNC6Q5kXKQnj/YrLqRQND09Q7ParX0CQQC5NxZr\n" +
+				"9jKqhHj8yQD6PlXTsY4Occ7DH6/IoDenfdEVD5qlet0zmd50HatN2Jiqm5ubN7CM\n" +
+				"INrtuLp4YHbgk1mi\n" +
+				"-----END PRIVATE KEY-----";
+		key = RsaKeyConverters.pkcs8().convert(new ByteArrayInputStream(keyData.getBytes(UTF_8)));
+		final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+		String certificateData = "-----BEGIN CERTIFICATE-----\n" +
+				"MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBhMC\n" +
+				"VVMxEzARBgNVBAgMCldhc2hpbmd0b24xEjAQBgNVBAcMCVZhbmNvdXZlcjEdMBsG\n" +
+				"A1UECgwUU3ByaW5nIFNlY3VyaXR5IFNBTUwxCzAJBgNVBAsMAnNwMSAwHgYDVQQD\n" +
+				"DBdzcC5zcHJpbmcuc2VjdXJpdHkuc2FtbDAeFw0xODA1MTQxNDMwNDRaFw0yODA1\n" +
+				"MTExNDMwNDRaMIGEMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjES\n" +
+				"MBAGA1UEBwwJVmFuY291dmVyMR0wGwYDVQQKDBRTcHJpbmcgU2VjdXJpdHkgU0FN\n" +
+				"TDELMAkGA1UECwwCc3AxIDAeBgNVBAMMF3NwLnNwcmluZy5zZWN1cml0eS5zYW1s\n" +
+				"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRu7/EI0BlNzMEBFVAcbx+lLos\n" +
+				"vzIWU+01dGTY8gBdhMQNYKZ92lMceo2CuVJ66cUURPym3i7nGGzoSnAxAre+0YIM\n" +
+				"+U0razrWtAUE735bkcqELZkOTZLelaoOztmWqRbe5OuEmpewH7cx+kNgcVjdctOG\n" +
+				"y3Q6x+I4qakY/9qhBQIDAQABMA0GCSqGSIb3DQEBCwUAA4GBAAeViTvHOyQopWEi\n" +
+				"XOfI2Z9eukwrSknDwq/zscR0YxwwqDBMt/QdAODfSwAfnciiYLkmEjlozWRtOeN+\n" +
+				"qK7UFgP1bRl5qksrYX5S0z2iGJh0GvonLUt3e20Ssfl5tTEDDnAEUMLfBkyaxEHD\n" +
+				"RZ/nbTJ7VTeZOSyRoVn5XHhpuJ0B\n" +
+				"-----END CERTIFICATE-----";
+		certificate = (X509Certificate) factory
+				.generateCertificate(new ByteArrayInputStream(certificateData.getBytes(UTF_8)));
+	}
+
+	@Test
+	public void constructorWhenRelyingPartyWithCredentialsThenItSucceeds() {
+		new Saml2X509Credential(key, certificate, SIGNING);
+		new Saml2X509Credential(key, certificate, SIGNING, DECRYPTION);
+		new Saml2X509Credential(key, certificate, DECRYPTION);
+	}
+
+	@Test
+	public void constructorWhenAssertingPartyWithCredentialsThenItSucceeds() {
+		new Saml2X509Credential(certificate, VERIFICATION);
+		new Saml2X509Credential(certificate, VERIFICATION, ENCRYPTION);
+		new Saml2X509Credential(certificate, ENCRYPTION);
+	}
+
+	@Test
+	public void constructorWhenRelyingPartyWithoutCredentialsThenItFails() {
+		exception.expect(IllegalArgumentException.class);
+		new Saml2X509Credential(null, (X509Certificate) null, SIGNING);
+	}
+
+	@Test
+	public void constructorWhenRelyingPartyWithoutPrivateKeyThenItFails() {
+		exception.expect(IllegalArgumentException.class);
+		new Saml2X509Credential(null, certificate, SIGNING);
+	}
+
+	@Test
+	public void constructorWhenRelyingPartyWithoutCertificateThenItFails() {
+		exception.expect(IllegalArgumentException.class);
+		new Saml2X509Credential(key, null, SIGNING);
+	}
+
+	@Test
+	public void constructorWhenAssertingPartyWithoutCertificateThenItFails() {
+		exception.expect(IllegalArgumentException.class);
+		new Saml2X509Credential(null, SIGNING);
+	}
+
+	@Test
+	public void constructorWhenRelyingPartyWithEncryptionUsageThenItFails() {
+		exception.expect(IllegalStateException.class);
+		new Saml2X509Credential(key, certificate, ENCRYPTION);
+	}
+
+	@Test
+	public void constructorWhenRelyingPartyWithVerificationUsageThenItFails() {
+		exception.expect(IllegalStateException.class);
+		new Saml2X509Credential(key, certificate, VERIFICATION);
+	}
+
+	@Test
+	public void constructorWhenAssertingPartyWithSigningUsageThenItFails() {
+		exception.expect(IllegalStateException.class);
+		new Saml2X509Credential(certificate, SIGNING);
+	}
+
+	@Test
+	public void constructorWhenAssertingPartyWithDecryptionUsageThenItFails() {
+		exception.expect(IllegalStateException.class);
+		new Saml2X509Credential(certificate, DECRYPTION);
+	}
+
+
+}

+ 456 - 0
saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/OpenSamlAuthenticationProviderTests.java

@@ -0,0 +1,456 @@
+/*
+ * Copyright 2002-2019 the original author or authors.
+ *
+ * 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
+ *
+ *      https://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 org.springframework.security.saml2.provider.service.authentication;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.Saml2Exception;
+
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.opensaml.saml.common.assertion.AssertionValidationException;
+import org.opensaml.saml.common.assertion.ValidationContext;
+import org.opensaml.saml.common.assertion.ValidationResult;
+import org.opensaml.saml.saml2.assertion.SAML20AssertionValidator;
+import org.opensaml.saml.saml2.core.Assertion;
+import org.opensaml.saml.saml2.core.EncryptedAssertion;
+import org.opensaml.saml.saml2.core.EncryptedID;
+import org.opensaml.saml.saml2.core.Issuer;
+import org.opensaml.saml.saml2.core.Response;
+import org.opensaml.saml.saml2.core.Subject;
+import org.powermock.api.mockito.PowerMockito;
+import org.powermock.core.classloader.annotations.PrepareForTest;
+import org.powermock.modules.junit4.PowerMockRunner;
+
+import java.util.Collections;
+
+import static java.util.Collections.emptyList;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.powermock.api.mockito.PowerMockito.doReturn;
+import static org.powermock.api.mockito.PowerMockito.mock;
+import static org.powermock.api.mockito.PowerMockito.when;
+import static org.springframework.test.util.AssertionErrors.assertTrue;
+import static org.springframework.util.StringUtils.hasText;
+
+@RunWith(PowerMockRunner.class)
+@PrepareForTest({OpenSamlImplementation.class, OpenSamlAuthenticationProvider.class})
+public class OpenSamlAuthenticationProviderTests {
+
+	private OpenSamlAuthenticationProvider provider;
+	private OpenSamlImplementation saml;
+
+	@Rule
+	ExpectedException exception = ExpectedException.none();
+	private Saml2AuthenticationToken token;
+
+	@Before
+	public void setup() {
+		saml = PowerMockito.mock(OpenSamlImplementation.class);
+		PowerMockito.mockStatic(OpenSamlImplementation.class);
+		when(OpenSamlImplementation.getInstance()).thenReturn(saml);
+
+		provider = new OpenSamlAuthenticationProvider();
+		token = new Saml2AuthenticationToken(
+				"responseXml",
+				"recipientUri",
+				"idpEntityId",
+				"localSpEntityId",
+				emptyList()
+		);
+	}
+
+	@Test
+	public void supportsWhenSaml2AuthenticationTokenThenReturnTrue() {
+
+		assertTrue(
+				OpenSamlAuthenticationProvider.class + "should support " + token.getClass(),
+				provider.supports(token.getClass())
+		);
+	}
+
+	@Test
+	public void supportsWhenNotSaml2AuthenticationTokenThenReturnFalse() {
+		assertTrue(
+				OpenSamlAuthenticationProvider.class + "should not support " + Authentication.class,
+				!provider.supports(Authentication.class)
+		);
+	}
+
+	@Test
+	public void authenticateWhenUnknownDataClassThenThrowAuthenticationException() {
+		when(saml.resolve(any(String.class))).thenReturn(mock(Assertion.class));
+		exception.expect(authenticationMatcher(Saml2ErrorCodes.UNKNOWN_RESPONSE_CLASS));
+		provider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenXmlErrorThenThrowAuthenticationException() {
+		when(saml.resolve(any(String.class))).thenThrow(new Saml2Exception("test"));
+		exception.expect(authenticationMatcher(Saml2ErrorCodes.MALFORMED_RESPONSE_DATA));
+		provider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenInvalidDestinationThenThrowAuthenticationException() {
+		final Response response = mock(Response.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn("invalidRecipient");
+		exception.expect(authenticationMatcher(Saml2ErrorCodes.INVALID_DESTINATION));
+		provider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenNoAssertionsPresentThenThrowAuthenticationException() {
+		final Response response = mock(Response.class);
+		final Issuer issuer = mock(Issuer.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn(token.getRecipientUri());
+		when(response.isSigned()).thenReturn(false);
+		when(response.getAssertions()).thenReturn(emptyList());
+		when(response.getEncryptedAssertions()).thenReturn(emptyList());
+		when(response.getIssuer()).thenReturn(issuer);
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+		exception.expect(
+				authenticationMatcher(
+						Saml2ErrorCodes.MALFORMED_RESPONSE_DATA,
+						"No assertions found in response."
+				)
+		);
+		provider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenInvalidSignatureThenThrowAuthenticationException() {
+		final Response response = mock(Response.class);
+		final Issuer issuer = mock(Issuer.class);
+		final Assertion assertion = mock(Assertion.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn(token.getRecipientUri());
+		when(response.isSigned()).thenReturn(false);
+		when(response.getAssertions()).thenReturn(Collections.singletonList(assertion));
+		when(response.getEncryptedAssertions()).thenReturn(emptyList());
+		when(response.getIssuer()).thenReturn(issuer);
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+
+		exception.expect(
+				authenticationMatcher(
+						Saml2ErrorCodes.INVALID_SIGNATURE
+				)
+		);
+		provider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenOpenSAMLValidationErrorThenThrowAuthenticationException() throws Exception {
+		final Response response = mock(Response.class);
+		final Issuer issuer = mock(Issuer.class);
+		final Assertion assertion = mock(Assertion.class);
+		final SAML20AssertionValidator validator = mock(SAML20AssertionValidator.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn(token.getRecipientUri());
+		when(response.isSigned()).thenReturn(false);
+		when(response.getAssertions()).thenReturn(Collections.singletonList(assertion));
+		when(response.getEncryptedAssertions()).thenReturn(emptyList());
+		when(response.getIssuer()).thenReturn(issuer);
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+
+
+		OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider);
+		doReturn(true).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Assertion.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		doReturn(false).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Response.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		doReturn(validator).when(spyProvider, "getAssertionValidator", any(Saml2AuthenticationToken.class));
+		when(validator.validate(
+				any(Assertion.class),
+				any(ValidationContext.class)
+		)).thenReturn(ValidationResult.INVALID);
+		exception.expect(
+				authenticationMatcher(
+						Saml2ErrorCodes.INVALID_ASSERTION
+				)
+		);
+		spyProvider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenInternalErrorThenCatchAndThrowAuthenticationException() throws Exception {
+		final Response response = mock(Response.class);
+		final Issuer issuer = mock(Issuer.class);
+		final Assertion assertion = mock(Assertion.class);
+		final SAML20AssertionValidator validator = mock(SAML20AssertionValidator.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn(token.getRecipientUri());
+		when(response.isSigned()).thenReturn(false);
+		when(response.getAssertions()).thenReturn(Collections.singletonList(assertion));
+		when(response.getEncryptedAssertions()).thenReturn(emptyList());
+		when(response.getIssuer()).thenReturn(issuer);
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+
+
+		OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider);
+		doReturn(true).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Assertion.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		doReturn(false).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Response.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		doReturn(validator).when(spyProvider, "getAssertionValidator", any(Saml2AuthenticationToken.class));
+		when(validator.validate(
+				any(Assertion.class),
+				any(ValidationContext.class)
+		)).thenThrow(new AssertionValidationException());
+		exception.expect(
+				authenticationMatcher(
+						Saml2ErrorCodes.INTERNAL_VALIDATION_ERROR
+				)
+		);
+		spyProvider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenMissingSubjectThenThrowAuthenticationException() throws Exception {
+		final Response response = mock(Response.class);
+		final Issuer issuer = mock(Issuer.class);
+		final Assertion assertion = mock(Assertion.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn(token.getRecipientUri());
+		when(response.isSigned()).thenReturn(false);
+		when(response.getAssertions()).thenReturn(Collections.singletonList(assertion));
+		when(response.getEncryptedAssertions()).thenReturn(emptyList());
+		when(response.getIssuer()).thenReturn(issuer);
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+
+
+		OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider);
+		doReturn(true).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Assertion.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		doReturn(false).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Response.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		PowerMockito.doNothing()
+				.when(
+						spyProvider,
+						"validateAssertion",
+						anyString(),
+						any(Assertion.class),
+						any(Saml2AuthenticationToken.class),
+						anyBoolean()
+				);
+
+		exception.expect(
+				authenticationMatcher(
+						Saml2ErrorCodes.SUBJECT_NOT_FOUND
+				)
+		);
+		spyProvider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenUsernameMissingThenThrowAuthenticationException() throws Exception {
+		final Response response = mock(Response.class);
+		final Issuer issuer = mock(Issuer.class);
+		final Assertion assertion = mock(Assertion.class);
+		final Subject subject = mock(Subject.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn(token.getRecipientUri());
+		when(response.isSigned()).thenReturn(false);
+		when(response.getAssertions()).thenReturn(Collections.singletonList(assertion));
+		when(response.getEncryptedAssertions()).thenReturn(emptyList());
+		when(response.getIssuer()).thenReturn(issuer);
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+		when(assertion.getSubject()).thenReturn(subject);
+
+
+		OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider);
+		doReturn(true).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Assertion.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		doReturn(false).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Response.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		PowerMockito.doNothing()
+				.when(
+						spyProvider,
+						"validateAssertion",
+						anyString(),
+						any(Assertion.class),
+						any(Saml2AuthenticationToken.class),
+						anyBoolean()
+				);
+
+		exception.expect(
+				authenticationMatcher(
+						Saml2ErrorCodes.USERNAME_NOT_FOUND
+				)
+		);
+		spyProvider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenDecryptionKeysAreMissingThenThrowAuthenticationException() throws Exception {
+		final Response response = mock(Response.class);
+		final Issuer issuer = mock(Issuer.class);
+		final Assertion assertion = mock(Assertion.class);
+		final Subject subject = mock(Subject.class);
+		final EncryptedID nameID = mock(EncryptedID.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn(token.getRecipientUri());
+		when(response.isSigned()).thenReturn(false);
+		when(response.getAssertions()).thenReturn(Collections.singletonList(assertion));
+		when(response.getEncryptedAssertions()).thenReturn(emptyList());
+		when(response.getIssuer()).thenReturn(issuer);
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+		when(assertion.getSubject()).thenReturn(subject);
+		when(subject.getEncryptedID()).thenReturn(nameID);
+
+
+		OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider);
+		doReturn(true).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Assertion.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		doReturn(false).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Response.class),
+				any(Saml2AuthenticationToken.class)
+		);
+		PowerMockito.doNothing()
+				.when(
+						spyProvider,
+						"validateAssertion",
+						anyString(),
+						any(Assertion.class),
+						any(Saml2AuthenticationToken.class),
+						anyBoolean()
+				);
+
+		exception.expect(
+				authenticationMatcher(
+						Saml2ErrorCodes.DECRYPTION_ERROR,
+						"No valid decryption credentials found."
+				)
+		);
+		spyProvider.authenticate(token);
+	}
+
+	@Test
+	public void authenticateWhenDecryptionKeyIsMissingThenThrowAuthenticationException() throws Exception {
+		final Response response = mock(Response.class);
+		final Issuer issuer = mock(Issuer.class);
+		final EncryptedAssertion assertion = mock(EncryptedAssertion.class);
+		when(saml.resolve(any(String.class))).thenReturn(response);
+		when(response.getDestination()).thenReturn(token.getRecipientUri());
+		when(response.isSigned()).thenReturn(false);
+		when(response.getIssuer()).thenReturn(issuer);
+		when(issuer.getValue()).thenReturn(token.getIdpEntityId());
+		when(response.getEncryptedAssertions()).thenReturn(Collections.singletonList(assertion));
+
+		OpenSamlAuthenticationProvider spyProvider = PowerMockito.spy(this.provider);
+		doReturn(false).when(
+				spyProvider,
+				"hasValidSignature",
+				any(Response.class),
+				any(Saml2AuthenticationToken.class)
+		);
+
+		exception.expect(
+				authenticationMatcher(
+						Saml2ErrorCodes.DECRYPTION_ERROR,
+						"No valid decryption credentials found."
+				)
+		);
+		spyProvider.authenticate(token);
+	}
+
+
+	private BaseMatcher<Saml2AuthenticationException> authenticationMatcher(String code) {
+		return authenticationMatcher(code, null);
+	}
+
+	private BaseMatcher<Saml2AuthenticationException> authenticationMatcher(String code, String description) {
+		return new BaseMatcher<Saml2AuthenticationException>() {
+			private Object value = null;
+
+			@Override
+			public boolean matches(Object item) {
+				if (!(item instanceof Saml2AuthenticationException)) {
+					value = item;
+					return false;
+				}
+				Saml2AuthenticationException ex = (Saml2AuthenticationException) item;
+				if (!code.equals(ex.getError().getErrorCode())) {
+					value = item;
+					return false;
+				}
+				if (hasText(description)) {
+					if (!description.equals(ex.getError().getDescription())) {
+						value = item;
+						return false;
+					}
+				}
+				return true;
+			}
+
+			@Override
+			public void describeTo(Description description) {
+				description.appendText("Expecting a " + Saml2AuthenticationException.class.getName() +
+						" with code:" + code + " and description:" + description
+				)
+						.appendValue(value);
+			}
+		};
+	}
+
+}

+ 147 - 59
samples/boot/saml2login/src/integration-test/java/org/springframework/security/samples/Saml2LoginIntegrationTests.java

@@ -22,11 +22,15 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.context.annotation.ComponentScan;
 import org.springframework.http.MediaType;
+import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
 import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.util.AssertionErrors;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.ResultMatcher;
 
 import net.shibboleth.utilities.java.support.xml.SerializeSupport;
+import org.hamcrest.Matcher;
 import org.joda.time.DateTime;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -62,9 +66,11 @@ import java.security.cert.CertificateException;
 import java.security.cert.CertificateFactory;
 import java.security.cert.X509Certificate;
 import java.util.UUID;
+import javax.servlet.http.HttpSession;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.startsWith;
 import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildConditions;
 import static org.springframework.security.samples.OpenSamlActionTestingSupport.buildIssuer;
@@ -74,6 +80,9 @@ import static org.springframework.security.samples.OpenSamlActionTestingSupport.
 import static org.springframework.security.samples.OpenSamlActionTestingSupport.encryptNameId;
 import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
 import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
+import static org.springframework.security.web.WebAttributes.AUTHENTICATION_EXCEPTION;
+import static org.springframework.test.util.AssertionErrors.assertEquals;
+import static org.springframework.test.util.AssertionErrors.assertTrue;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@@ -86,6 +95,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 public class Saml2LoginIntegrationTests {
 
 	static final String LOCAL_SP_ENTITY_ID = "http://localhost:8080/saml2/service-provider-metadata/simplesamlphp";
+	static final String USERNAME = "testuser@spring.security.saml";
 
 	@Autowired
 	MockMvc mockMvc;
@@ -97,21 +107,21 @@ public class Saml2LoginIntegrationTests {
 	}
 
 	@Test
-	public void redirectToLoginPageSingleProvider() throws Exception {
+	public void applicationAccessWhenSingleProviderAndUnauthenticatedThenRedirectsToAuthNRequest() throws Exception {
 		mockMvc.perform(get("http://localhost:8080/some/url"))
 				.andExpect(status().is3xxRedirection())
 				.andExpect(redirectedUrl("http://localhost:8080/saml2/authenticate/simplesamlphp"));
 	}
 
 	@Test
-	public void testAuthNRequest() throws Exception {
+	public void authenticateRequestWhenUnauthenticatedThenRespondsWithRedirectAuthNRequestXML() throws Exception {
 		mockMvc.perform(get("http://localhost:8080/saml2/authenticate/simplesamlphp"))
 				.andExpect(status().is3xxRedirection())
 				.andExpect(header().string("Location", startsWith("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php?SAMLRequest=")));
 	}
 
 	@Test
-	public void testRelayState() throws Exception {
+	public void authenticateRequestWhenRelayStateThenRespondsWithRedirectAndEncodedRelayState() throws Exception {
 		mockMvc.perform(
 				get("http://localhost:8080/saml2/authenticate/simplesamlphp")
 						.param("RelayState", "relay state value with spaces")
@@ -122,96 +132,136 @@ public class Saml2LoginIntegrationTests {
 	}
 
 	@Test
-	public void signedResponse() throws Exception {
-		final String username = "testuser@spring.security.saml";
-		Assertion assertion = buildAssertion(username);
+	public void authenticateWhenResponseIsSignedThenItSucceeds() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
 		Response response = buildResponse(assertion);
 		signXmlObject(response, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING));
-		String xml = toXml(response);
-		mockMvc.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp")
-				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
-				.param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8))))
-				.andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/"))
-				.andExpect(authenticated().withUsername(username));
+		sendResponse(response, "/")
+				.andExpect(authenticated().withUsername(USERNAME));
 	}
 
 	@Test
-	public void signedAssertion() throws Exception {
-		final String username = "testuser@spring.security.saml";
-		Assertion assertion = buildAssertion(username);
+	public void authenticateWhenAssertionIsThenItSignedSucceeds() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
 		Response response = buildResponse(assertion);
 		signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING));
-		String xml = toXml(response);
-		final ResultActions actions = mockMvc
-				.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp")
-						.contentType(MediaType.APPLICATION_FORM_URLENCODED)
-						.param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8))))
-				.andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/"))
-				.andExpect(authenticated().withUsername(username));
+		sendResponse(response, "/")
+				.andExpect(authenticated().withUsername(USERNAME));
 	}
 
 	@Test
-	public void unsigned() throws Exception {
-		Assertion assertion = buildAssertion("testuser@spring.security.saml");
+	public void authenticateWhenXmlObjectIsNotSignedThenItFails() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
 		Response response = buildResponse(assertion);
-		String xml = toXml(response);
-		mockMvc.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp")
-				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
-				.param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8))))
-				.andExpect(status().is3xxRedirection())
-				.andExpect(redirectedUrl("/login?error"))
+		sendResponse(response, "/login?error")
 				.andExpect(unauthenticated());
 	}
 
 	@Test
-	public void signedResponseEncryptedAssertion() throws Exception {
-		final String username = "testuser@spring.security.saml";
-		Assertion assertion = buildAssertion(username);
+	public void authenticateWhenResponseIsSignedAndAssertionIsEncryptedThenItSucceeds() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
 		EncryptedAssertion encryptedAssertion =
 				OpenSamlActionTestingSupport.encryptAssertion(assertion, decodeCertificate(spCertificate));
 		Response response = buildResponse(encryptedAssertion);
 		signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING));
-		String xml = toXml(response);
-		final ResultActions actions = mockMvc
-				.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp")
-						.contentType(MediaType.APPLICATION_FORM_URLENCODED)
-						.param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8))))
-				.andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/"))
-				.andExpect(authenticated().withUsername(username));
+		sendResponse(response, "/")
+				.andExpect(authenticated().withUsername(USERNAME));
 	}
 
 	@Test
-	public void unsignedResponseEncryptedAssertion() throws Exception {
-		final String username = "testuser@spring.security.saml";
-		Assertion assertion = buildAssertion(username);
+	public void authenticateWhenResponseIsNotSignedAndAssertionIsEncryptedThenItSucceeds() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
 		EncryptedAssertion encryptedAssertion =
 				OpenSamlActionTestingSupport.encryptAssertion(assertion, decodeCertificate(spCertificate));
 		Response response = buildResponse(encryptedAssertion);
-		String xml = toXml(response);
-		final ResultActions actions = mockMvc
-				.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp")
-						.contentType(MediaType.APPLICATION_FORM_URLENCODED)
-						.param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8))))
-				.andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/"))
-				.andExpect(authenticated().withUsername(username));
+		sendResponse(response, "/")
+				.andExpect(authenticated().withUsername(USERNAME));
 	}
 
 	@Test
-	public void signedResponseEncryptedNameId() throws Exception {
-		final String username = "testuser@spring.security.saml";
-		Assertion assertion = buildAssertion(username);
+	public void authenticateWhenResponseIsSignedAndNameIDisEncryptedThenItSucceeds() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
 		final EncryptedID nameId = encryptNameId(assertion.getSubject().getNameID(), decodeCertificate(spCertificate));
 		assertion.getSubject().setEncryptedID(nameId);
 		assertion.getSubject().setNameID(null);
 		Response response = buildResponse(assertion);
 		signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING));
+		sendResponse(response, "/")
+				.andExpect(authenticated().withUsername(USERNAME));
+	}
+
+	@Test
+	public void authenticateWhenSignatureKeysDontMatchThenItFails() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
+		Response response = buildResponse(assertion);
+		signXmlObject(assertion, getSigningCredential(spCertificate, spPrivateKey, UsageType.SIGNING));
+		sendResponse(response, "/login?error")
+				.andExpect(
+						saml2AuthenticationExceptionMatcher(
+								"invalid_signature",
+								equalTo("Assertion doesn't have a valid signature.")
+						)
+				);
+	}
+
+	@Test
+	public void authenticateWhenNotOnOrAfterDontMatchThenItFails() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
+		assertion.getConditions().setNotOnOrAfter(DateTime.now().minusDays(1));
+		Response response = buildResponse(assertion);
+		signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING));
+		sendResponse(response, "/login?error")
+				.andExpect(
+						saml2AuthenticationExceptionMatcher(
+								"invalid_assertion",
+								containsString("Assertion 'assertion' with NotOnOrAfter condition of")
+						)
+				);
+	}
+
+	@Test
+	public void authenticateWhenNotOnOrBeforeDontMatchThenItFails() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
+		assertion.getConditions().setNotBefore(DateTime.now().plusDays(1));
+		Response response = buildResponse(assertion);
+		signXmlObject(assertion, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING));
+		sendResponse(response, "/login?error")
+				.andExpect(
+						saml2AuthenticationExceptionMatcher(
+								"invalid_assertion",
+								containsString("Assertion 'assertion' with NotBefore condition of")
+						)
+				);
+	}
+
+	@Test
+	public void authenticateWhenIssuerIsInvalidThenItFails() throws Exception {
+		Assertion assertion = buildAssertion(USERNAME);
+		Response response = buildResponse(assertion);
+		response.getIssuer().setValue("invalid issuer");
+		signXmlObject(response, getSigningCredential(idpCertificate, idpPrivateKey, UsageType.SIGNING));
+		sendResponse(response, "/login?error")
+				.andExpect(unauthenticated())
+				.andExpect(
+						saml2AuthenticationExceptionMatcher(
+								"invalid_issuer",
+								containsString(
+										"Response issuer 'invalid issuer' doesn't match "+
+										"'https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php'"
+								)
+						)
+				);
+	}
+
+	private ResultActions sendResponse(
+			Response response,
+			String redirectUrl) throws Exception {
 		String xml = toXml(response);
-		final ResultActions actions = mockMvc
-				.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp")
-						.contentType(MediaType.APPLICATION_FORM_URLENCODED)
-						.param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8))))
-				.andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/"))
-				.andExpect(authenticated().withUsername(username));
+		return mockMvc.perform(post("http://localhost:8080/login/saml2/sso/simplesamlphp")
+				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+				.param("SAMLResponse", OpenSamlActionTestingSupport.encode(xml.getBytes(UTF_8))))
+				.andExpect(status().is3xxRedirection())
+				.andExpect(redirectedUrl(redirectUrl));
 	}
 
 	private Response buildResponse(Assertion assertion) {
@@ -359,4 +409,42 @@ public class Saml2LoginIntegrationTests {
 			"RZ/nbTJ7VTeZOSyRoVn5XHhpuJ0B\n" +
 			"-----END CERTIFICATE-----";
 
+	private String spPrivateKey = "-----BEGIN PRIVATE KEY-----\n" +
+			"MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBANG7v8QjQGU3MwQE\n" +
+			"VUBxvH6Uuiy/MhZT7TV0ZNjyAF2ExA1gpn3aUxx6jYK5UnrpxRRE/KbeLucYbOhK\n" +
+			"cDECt77Rggz5TStrOta0BQTvfluRyoQtmQ5Nkt6Vqg7O2ZapFt7k64Sal7AftzH6\n" +
+			"Q2BxWN1y04bLdDrH4jipqRj/2qEFAgMBAAECgYEAj4ExY1jjdN3iEDuOwXuRB+Nn\n" +
+			"x7pC4TgntE2huzdKvLJdGvIouTArce8A6JM5NlTBvm69mMepvAHgcsiMH1zGr5J5\n" +
+			"wJz23mGOyhM1veON41/DJTVG+cxq4soUZhdYy3bpOuXGMAaJ8QLMbQQoivllNihd\n" +
+			"vwH0rNSK8LTYWWPZYIECQQDxct+TFX1VsQ1eo41K0T4fu2rWUaxlvjUGhK6HxTmY\n" +
+			"8OMJptunGRJL1CUjIb45Uz7SP8TPz5FwhXWsLfS182kRAkEA3l+Qd9C9gdpUh1uX\n" +
+			"oPSNIxn5hFUrSTW1EwP9QH9vhwb5Vr8Jrd5ei678WYDLjUcx648RjkjhU9jSMzIx\n" +
+			"EGvYtQJBAMm/i9NR7IVyyNIgZUpz5q4LI21rl1r4gUQuD8vA36zM81i4ROeuCly0\n" +
+			"KkfdxR4PUfnKcQCX11YnHjk9uTFj75ECQEFY/gBnxDjzqyF35hAzrYIiMPQVfznt\n" +
+			"YX/sDTE2AdVBVGaMj1Cb51bPHnNC6Q5kXKQnj/YrLqRQND09Q7ParX0CQQC5NxZr\n" +
+			"9jKqhHj8yQD6PlXTsY4Occ7DH6/IoDenfdEVD5qlet0zmd50HatN2Jiqm5ubN7CM\n" +
+			"INrtuLp4YHbgk1mi\n" +
+			"-----END PRIVATE KEY-----";
+
+	private static ResultMatcher saml2AuthenticationExceptionMatcher(
+			String code,
+			Matcher<String> message
+	) {
+		return result -> {
+			final HttpSession session = result.getRequest().getSession(false);
+			AssertionErrors.assertNotNull("HttpSession", session);
+			Object exception = session.getAttribute(AUTHENTICATION_EXCEPTION);
+			AssertionErrors.assertNotNull(AUTHENTICATION_EXCEPTION, exception);
+			if (!(exception instanceof Saml2AuthenticationException)) {
+				AssertionErrors.fail(
+						"Invalid exception type",
+						Saml2AuthenticationException.class,
+						exception.getClass().getName()
+				);
+			}
+			Saml2AuthenticationException se = (Saml2AuthenticationException) exception;
+			assertEquals("SAML 2 Error Code", code, se.getError().getErrorCode());
+			assertTrue("SAML 2 Error Description", message.matches(se.getError().getDescription()));
+		};
+	}
 }