KEYCLOAK-17935 SAML Client - Validate InResponseTo attribute
This commit is contained in:
parent
13f7831a77
commit
f5123cb51b
3 changed files with 168 additions and 0 deletions
|
@ -31,6 +31,8 @@ import org.keycloak.dom.saml.v2.assertion.AttributeStatementType;
|
||||||
import org.keycloak.dom.saml.v2.assertion.AttributeType;
|
import org.keycloak.dom.saml.v2.assertion.AttributeType;
|
||||||
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
|
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
|
||||||
import org.keycloak.dom.saml.v2.assertion.NameIDType;
|
import org.keycloak.dom.saml.v2.assertion.NameIDType;
|
||||||
|
import org.keycloak.dom.saml.v2.assertion.SubjectConfirmationDataType;
|
||||||
|
import org.keycloak.dom.saml.v2.assertion.SubjectConfirmationType;
|
||||||
import org.keycloak.dom.saml.v2.assertion.SubjectType;
|
import org.keycloak.dom.saml.v2.assertion.SubjectType;
|
||||||
import org.keycloak.dom.saml.v2.protocol.LogoutRequestType;
|
import org.keycloak.dom.saml.v2.protocol.LogoutRequestType;
|
||||||
import org.keycloak.dom.saml.v2.protocol.RequestAbstractType;
|
import org.keycloak.dom.saml.v2.protocol.RequestAbstractType;
|
||||||
|
@ -442,6 +444,16 @@ public class SAMLEndpoint {
|
||||||
assertionElement = DocumentUtil.getElement(holder.getSamlDocument(), new QName(JBossSAMLConstants.ASSERTION.get()));
|
assertionElement = DocumentUtil.getElement(holder.getSamlDocument(), new QName(JBossSAMLConstants.ASSERTION.get()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate InResponseTo attribute: must match the generated request ID
|
||||||
|
String expectedRequestId = authSession.getClientNote(SamlProtocol.SAML_REQUEST_ID);
|
||||||
|
final boolean inResponseToValidationSuccess = validateInResponseToAttribute(responseType, expectedRequestId);
|
||||||
|
if (!inResponseToValidationSuccess)
|
||||||
|
{
|
||||||
|
event.event(EventType.IDENTITY_PROVIDER_RESPONSE);
|
||||||
|
event.error(Errors.INVALID_SAML_RESPONSE);
|
||||||
|
return ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.INVALID_REQUESTER);
|
||||||
|
}
|
||||||
|
|
||||||
boolean signed = AssertionUtil.isSignedElement(assertionElement);
|
boolean signed = AssertionUtil.isSignedElement(assertionElement);
|
||||||
final boolean assertionSignatureNotExistsWhenRequired = config.isWantAssertionsSigned() && !signed;
|
final boolean assertionSignatureNotExistsWhenRequired = config.isWantAssertionsSigned() && !signed;
|
||||||
final boolean signatureNotValid = signed && config.isValidateSignature() && !AssertionUtil.isSignatureValid(assertionElement, getIDPKeyLocator());
|
final boolean signatureNotValid = signed && config.isValidateSignature() && !AssertionUtil.isSignatureValid(assertionElement, getIDPKeyLocator());
|
||||||
|
@ -782,4 +794,65 @@ public class SAMLEndpoint {
|
||||||
SubjectType.STSubType subType = subject.getSubType();
|
SubjectType.STSubType subType = subject.getSubType();
|
||||||
return subType != null ? (NameIDType) subType.getBaseID() : null;
|
return subType != null ? (NameIDType) subType.getBaseID() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean validateInResponseToAttribute(ResponseType responseType, String expectedRequestId) {
|
||||||
|
// If we are not expecting a request ID, don't bother
|
||||||
|
if (expectedRequestId == null || expectedRequestId.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// We are expecting a request ID so we are in SP-initiated login, attribute InResponseTo must be present
|
||||||
|
if (responseType.getInResponseTo() == null) {
|
||||||
|
logger.error("Response Validation Error: InResponseTo attribute was expected but not present in received response");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribute is present, proceed with validation
|
||||||
|
// 1) Attribute Response > InResponseTo must not be empty
|
||||||
|
String responseInResponseToValue = responseType.getInResponseTo();
|
||||||
|
if (responseInResponseToValue.isEmpty()) {
|
||||||
|
logger.error("Response Validation Error: InResponseTo attribute was expected but it is empty in received response");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Attribute Response > InResponseTo must match request ID
|
||||||
|
if (!responseInResponseToValue.equals(expectedRequestId)) {
|
||||||
|
logger.error("Response Validation Error: received InResponseTo attribute does not match the expected request ID");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If present, Assertion > Subject > Confirmation > SubjectConfirmationData > InResponseTo must also be validated
|
||||||
|
if (responseType.getAssertions().isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
SubjectType subjectElement = responseType.getAssertions().get(0).getAssertion().getSubject();
|
||||||
|
if (subjectElement != null) {
|
||||||
|
if (subjectElement.getConfirmation() != null && !subjectElement.getConfirmation().isEmpty())
|
||||||
|
{
|
||||||
|
SubjectConfirmationType subjectConfirmationElement = subjectElement.getConfirmation().get(0);
|
||||||
|
|
||||||
|
if (subjectConfirmationElement != null) {
|
||||||
|
SubjectConfirmationDataType subjectConfirmationDataElement = subjectConfirmationElement.getSubjectConfirmationData();
|
||||||
|
|
||||||
|
if (subjectConfirmationDataElement != null) {
|
||||||
|
if (subjectConfirmationDataElement.getInResponseTo() != null) {
|
||||||
|
// 3) Assertion > Subject > Confirmation > SubjectConfirmationData > InResponseTo is empty
|
||||||
|
String subjectConfirmationDataInResponseToValue = subjectConfirmationDataElement.getInResponseTo();
|
||||||
|
if (subjectConfirmationDataInResponseToValue.isEmpty()) {
|
||||||
|
logger.error("Response Validation Error: SubjectConfirmationData InResponseTo attribute was expected but it is empty in received response");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Assertion > Subject > Confirmation > SubjectConfirmationData > InResponseTo does not match request ID
|
||||||
|
if (!subjectConfirmationDataInResponseToValue.equals(expectedRequestId)) {
|
||||||
|
logger.error("Response Validation Error: received SubjectConfirmationData InResponseTo attribute does not match the expected request ID");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,7 @@ import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
|
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
|
||||||
|
import org.keycloak.protocol.saml.SamlProtocol;
|
||||||
import org.keycloak.protocol.saml.SamlService;
|
import org.keycloak.protocol.saml.SamlService;
|
||||||
import org.keycloak.protocol.saml.SamlSessionUtils;
|
import org.keycloak.protocol.saml.SamlSessionUtils;
|
||||||
import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor;
|
import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor;
|
||||||
|
@ -170,6 +171,9 @@ public class SAMLIdentityProvider extends AbstractIdentityProvider<SAMLIdentityP
|
||||||
destinationUrl = authnRequest.getDestination().toString();
|
destinationUrl = authnRequest.getDestination().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save the current RequestID in the Auth Session as we need to verify it against the ID returned from the IdP
|
||||||
|
request.getAuthenticationSession().setClientNote(SamlProtocol.SAML_REQUEST_ID, authnRequest.getID());
|
||||||
|
|
||||||
if (postBinding) {
|
if (postBinding) {
|
||||||
return binding.postBinding(authnRequestBuilder.toDocument()).request(destinationUrl);
|
return binding.postBinding(authnRequestBuilder.toDocument()).request(destinationUrl);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -22,6 +22,7 @@ import org.keycloak.saml.common.exceptions.ConfigurationException;
|
||||||
import org.keycloak.saml.common.exceptions.ParsingException;
|
import org.keycloak.saml.common.exceptions.ParsingException;
|
||||||
import org.keycloak.saml.common.exceptions.ProcessingException;
|
import org.keycloak.saml.common.exceptions.ProcessingException;
|
||||||
import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request;
|
import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request;
|
||||||
|
import org.keycloak.saml.processing.core.parsers.saml.protocol.SAMLProtocolQNames;
|
||||||
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
|
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
|
||||||
import org.keycloak.testsuite.saml.AbstractSamlTest;
|
import org.keycloak.testsuite.saml.AbstractSamlTest;
|
||||||
import org.keycloak.testsuite.util.SamlClient;
|
import org.keycloak.testsuite.util.SamlClient;
|
||||||
|
@ -33,16 +34,19 @@ import java.util.HashMap;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
|
|
||||||
import org.hamcrest.Matchers;
|
import org.hamcrest.Matchers;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.w3c.dom.Document;
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
import static org.hamcrest.Matchers.hasItems;
|
import static org.hamcrest.Matchers.hasItems;
|
||||||
import static org.hamcrest.Matchers.not;
|
import static org.hamcrest.Matchers.not;
|
||||||
import static org.junit.Assert.assertThat;
|
import static org.junit.Assert.assertThat;
|
||||||
import static org.keycloak.testsuite.saml.RoleMapperTest.ROLE_ATTRIBUTE_NAME;
|
import static org.keycloak.testsuite.saml.RoleMapperTest.ROLE_ATTRIBUTE_NAME;
|
||||||
import static org.keycloak.testsuite.util.Matchers.isSamlResponse;
|
import static org.keycloak.testsuite.util.Matchers.isSamlResponse;
|
||||||
|
import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC;
|
||||||
import static org.keycloak.testsuite.util.SamlStreams.assertionsUnencrypted;
|
import static org.keycloak.testsuite.util.SamlStreams.assertionsUnencrypted;
|
||||||
import static org.keycloak.testsuite.util.SamlStreams.attributeStatements;
|
import static org.keycloak.testsuite.util.SamlStreams.attributeStatements;
|
||||||
import static org.keycloak.testsuite.util.SamlStreams.attributesUnecrypted;
|
import static org.keycloak.testsuite.util.SamlStreams.attributesUnecrypted;
|
||||||
|
@ -337,4 +341,91 @@ public final class KcSamlBrokerTest extends AbstractAdvancedBrokerTest {
|
||||||
assertThat(attributeValues, hasItems(EMPTY_ATTRIBUTE_ROLE));
|
assertThat(attributeValues, hasItems(EMPTY_ATTRIBUTE_ROLE));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KEYCLOAK-17935
|
||||||
|
@Test
|
||||||
|
public void loginInResponseToMismatch() throws Exception {
|
||||||
|
AuthnRequestType loginRep = SamlClient.createLoginRequestDocument(AbstractSamlTest.SAML_CLIENT_ID_SALES_POST + ".dot/ted", getConsumerRoot() + "/sales-post/saml", null);
|
||||||
|
|
||||||
|
Document doc = SAML2Request.convert(loginRep);
|
||||||
|
|
||||||
|
new SamlClientBuilder()
|
||||||
|
.authnRequest(getConsumerSamlEndpoint(bc.consumerRealmName()), doc, Binding.POST).build() // Request to consumer IdP
|
||||||
|
.login().idp(bc.getIDPAlias()).build()
|
||||||
|
|
||||||
|
.processSamlResponse(Binding.POST) // AuthnRequest to producer IdP
|
||||||
|
.targetAttributeSamlRequest()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
.login().user(bc.getUserLogin(), bc.getUserPassword()).build()
|
||||||
|
|
||||||
|
.processSamlResponse(Binding.POST) // Response from producer IdP
|
||||||
|
.transformDocument(this::tamperInResponseTo)
|
||||||
|
.build()
|
||||||
|
.execute(hr -> assertThat(hr, statusCodeIsHC(Response.Status.BAD_REQUEST))); // Response from consumer IdP
|
||||||
|
}
|
||||||
|
|
||||||
|
// KEYCLOAK-17935
|
||||||
|
@Test
|
||||||
|
public void loginInResponseToMissing() throws Exception {
|
||||||
|
AuthnRequestType loginRep = SamlClient.createLoginRequestDocument(AbstractSamlTest.SAML_CLIENT_ID_SALES_POST + ".dot/ted", getConsumerRoot() + "/sales-post/saml", null);
|
||||||
|
|
||||||
|
Document doc = SAML2Request.convert(loginRep);
|
||||||
|
|
||||||
|
new SamlClientBuilder()
|
||||||
|
.authnRequest(getConsumerSamlEndpoint(bc.consumerRealmName()), doc, Binding.POST).build() // Request to consumer IdP
|
||||||
|
.login().idp(bc.getIDPAlias()).build()
|
||||||
|
|
||||||
|
.processSamlResponse(Binding.POST) // AuthnRequest to producer IdP
|
||||||
|
.targetAttributeSamlRequest()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
.login().user(bc.getUserLogin(), bc.getUserPassword()).build()
|
||||||
|
|
||||||
|
.processSamlResponse(Binding.POST) // Response from producer IdP
|
||||||
|
.transformDocument(this::removeInResponseTo)
|
||||||
|
.build()
|
||||||
|
.execute(hr -> assertThat(hr, statusCodeIsHC(Response.Status.BAD_REQUEST))); // Response from consumer IdP
|
||||||
|
}
|
||||||
|
|
||||||
|
// KEYCLOAK-17935
|
||||||
|
@Test
|
||||||
|
public void loginInResponseToEmpty() throws Exception {
|
||||||
|
AuthnRequestType loginRep = SamlClient.createLoginRequestDocument(AbstractSamlTest.SAML_CLIENT_ID_SALES_POST + ".dot/ted", getConsumerRoot() + "/sales-post/saml", null);
|
||||||
|
|
||||||
|
Document doc = SAML2Request.convert(loginRep);
|
||||||
|
|
||||||
|
new SamlClientBuilder()
|
||||||
|
.authnRequest(getConsumerSamlEndpoint(bc.consumerRealmName()), doc, Binding.POST).build() // Request to consumer IdP
|
||||||
|
.login().idp(bc.getIDPAlias()).build()
|
||||||
|
|
||||||
|
.processSamlResponse(Binding.POST) // AuthnRequest to producer IdP
|
||||||
|
.targetAttributeSamlRequest()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
.login().user(bc.getUserLogin(), bc.getUserPassword()).build()
|
||||||
|
|
||||||
|
.processSamlResponse(Binding.POST) // Response from producer IdP
|
||||||
|
.transformDocument(this::clearInResponseTo)
|
||||||
|
.build()
|
||||||
|
.execute(hr -> assertThat(hr, statusCodeIsHC(Response.Status.BAD_REQUEST))); // Response from consumer IdP
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document tamperInResponseTo(Document orig) {
|
||||||
|
Element rootElement = orig.getDocumentElement();
|
||||||
|
rootElement.setAttribute(SAMLProtocolQNames.ATTR_IN_RESPONSE_TO.getQName().getLocalPart(), "TAMPERED_" + rootElement.getAttribute("InResponseTo"));
|
||||||
|
return orig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document removeInResponseTo(Document orig) {
|
||||||
|
Element rootElement = orig.getDocumentElement();
|
||||||
|
rootElement.removeAttribute(SAMLProtocolQNames.ATTR_IN_RESPONSE_TO.getQName().getLocalPart());
|
||||||
|
return orig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document clearInResponseTo(Document orig) {
|
||||||
|
Element rootElement = orig.getDocumentElement();
|
||||||
|
rootElement.setAttribute(SAMLProtocolQNames.ATTR_IN_RESPONSE_TO.getQName().getLocalPart(), "");
|
||||||
|
return orig;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue