SOAP backchannel logout for SAML protocol

Closes #16293
This commit is contained in:
lpa 2022-11-25 15:27:20 +01:00 committed by Michal Hajas
parent 38a46726e4
commit 3cd413dee1
16 changed files with 611 additions and 102 deletions

View file

@ -212,6 +212,28 @@ public class BaseSAML2BindingBuilder<T extends BaseSAML2BindingBuilder> {
}
}
public static class BaseSoapBindingBuilder {
protected Document document;
protected BaseSAML2BindingBuilder builder;
public BaseSoapBindingBuilder(BaseSAML2BindingBuilder builder, Document document) throws ProcessingException {
this.builder = builder;
this.document = document;
if (builder.signAssertions) {
builder.signAssertion(document);
}
if (builder.encrypt) builder.encryptDocument(document);
if (builder.sign) {
builder.signDocument(document);
}
}
public Document getDocument() {
return document;
}
}
public BaseRedirectBindingBuilder redirectBinding(Document document) throws ProcessingException {
return new BaseRedirectBindingBuilder(this, document);
@ -222,7 +244,9 @@ public class BaseSAML2BindingBuilder<T extends BaseSAML2BindingBuilder> {
}
public BaseSoapBindingBuilder soapBinding(Document document) throws ProcessingException {
return new BaseSoapBindingBuilder(this, document);
}
public String getSAMLNSPrefix(Document samlResponseDocument) {
Node assertionElement = samlResponseDocument.getDocumentElement()

View file

@ -66,6 +66,7 @@ import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.namespace.QName;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Writer;
@ -393,6 +394,21 @@ public class SAML2Response {
}
/**
* Get the Underlying SAML2Object from a document
* @param samlDocument a Document containing a SAML2Object
* @return a SAMLDocumentHolder
* @throws ProcessingException
* @throws ParsingException
*/
public static SAMLDocumentHolder getSAML2ObjectFromDocument(Document samlDocument) throws ProcessingException, ParsingException {
SAMLParser samlParser = SAMLParser.getInstance();
JAXPValidationUtil.checkSchemaValidation(samlDocument);
SAML2Object responseType = (SAML2Object) samlParser.parse(samlDocument);
return new SAMLDocumentHolder(responseType, samlDocument);
}
/**
* Convert an EncryptedElement into a Document
*
@ -423,7 +439,7 @@ public class SAML2Response {
* @throws ConfigurationException
* @throws ProcessingException
*/
public Document convert(StatusResponseType responseType) throws ProcessingException, ConfigurationException,
public static Document convert(StatusResponseType responseType) throws ProcessingException, ConfigurationException,
ParsingException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();

View file

@ -194,6 +194,8 @@ public class EntityDescriptorDescriptionConverter implements ClientDescriptionCo
if (logoutPost != null) attributes.put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, logoutPost);
String logoutRedirect = getLogoutLocation(spDescriptorType, JBossSAMLURIConstants.SAML_HTTP_REDIRECT_BINDING.get());
if (logoutRedirect != null) attributes.put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, logoutRedirect);
String logoutSoap = getLogoutLocation(spDescriptorType, JBossSAMLURIConstants.SAML_SOAP_BINDING.get());
if (logoutSoap != null) attributes.put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, logoutSoap);
String assertionConsumerServicePostBinding = getServiceURL(spDescriptorType, JBossSAMLURIConstants.SAML_HTTP_POST_BINDING.get());
if (assertionConsumerServicePostBinding != null) {

View file

@ -75,6 +75,7 @@ public class IDPMetadataDescriptor {
spIDPDescriptor.addSingleLogoutService(new EndpointType(SAML_HTTP_POST_BINDING.getUri(), logoutEndpoint));
spIDPDescriptor.addSingleLogoutService(new EndpointType(SAML_HTTP_REDIRECT_BINDING.getUri(), logoutEndpoint));
spIDPDescriptor.addSingleLogoutService(new EndpointType(SAML_HTTP_ARTIFACT_BINDING.getUri(), logoutEndpoint));
spIDPDescriptor.addSingleLogoutService(new EndpointType(SAML_SOAP_BINDING.getUri(), logoutEndpoint));
spIDPDescriptor.addSingleSignOnService(new EndpointType(SAML_HTTP_POST_BINDING.getUri(), loginPostEndpoint));
spIDPDescriptor.addSingleSignOnService(new EndpointType(SAML_HTTP_REDIRECT_BINDING.getUri(), loginRedirectEndpoint));
spIDPDescriptor.addSingleSignOnService(new EndpointType(SAML_SOAP_BINDING.getUri(), loginPostEndpoint));

View file

@ -19,6 +19,7 @@ package org.keycloak.protocol.saml;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.saml.profile.util.Soap;
import org.keycloak.saml.BaseSAML2BindingBuilder;
import org.keycloak.saml.common.constants.GeneralConstants;
import org.keycloak.saml.common.exceptions.ConfigurationException;
@ -95,6 +96,22 @@ public class JaxrsSAML2BindingBuilder extends BaseSAML2BindingBuilder<JaxrsSAML2
}
public static class SoapBindingBuilder extends BaseSoapBindingBuilder {
public SoapBindingBuilder(JaxrsSAML2BindingBuilder builder, Document document) throws ProcessingException {
super(builder, document);
}
public Response response() throws ConfigurationException, ProcessingException, IOException {
try {
Soap.SoapMessageBuilder messageBuilder = Soap.createMessage();
messageBuilder.addToBody(document);
return messageBuilder.build();
} catch (Exception e) {
throw new RuntimeException("Error while creating SAML response.", e);
}
}
}
@Override
public RedirectBindingBuilder redirectBinding(Document document) throws ProcessingException {
return new RedirectBindingBuilder(this, document);
@ -105,7 +122,8 @@ public class JaxrsSAML2BindingBuilder extends BaseSAML2BindingBuilder<JaxrsSAML2
return new PostBindingBuilder(this, document);
}
@Override
public SoapBindingBuilder soapBinding(Document document) throws ProcessingException {
return new SoapBindingBuilder(this, document);
}
}

View file

@ -23,6 +23,7 @@ import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.jboss.logging.Logger;
import org.keycloak.broker.saml.SAMLDataMarshaller;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.crypto.Algorithm;
@ -57,6 +58,7 @@ import org.keycloak.protocol.saml.mappers.SAMLLoginResponseMapper;
import org.keycloak.protocol.saml.mappers.SAMLNameIdMapper;
import org.keycloak.protocol.saml.mappers.SAMLRoleListMapper;
import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor;
import org.keycloak.protocol.saml.profile.util.Soap;
import org.keycloak.saml.SAML2ErrorResponseBuilder;
import org.keycloak.saml.SAML2LoginResponseBuilder;
import org.keycloak.saml.SAML2LogoutRequestBuilder;
@ -71,6 +73,8 @@ import org.keycloak.saml.common.exceptions.ParsingException;
import org.keycloak.saml.common.exceptions.ProcessingException;
import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer;
import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request;
import org.keycloak.saml.processing.api.saml.v2.response.SAML2Response;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.saml.processing.core.util.KeycloakKeySamlExtensionGenerator;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.managers.AuthenticationSessionManager;
@ -86,6 +90,8 @@ import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import javax.xml.soap.SOAPException;
import javax.xml.soap.SOAPMessage;
import java.io.IOException;
import java.net.URI;
import java.security.PrivateKey;
@ -116,6 +122,7 @@ public class SamlProtocol implements LoginProtocol {
public static final String SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE = "saml_single_logout_service_url_post";
public static final String SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE = "saml_single_logout_service_url_artifact";
public static final String SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE = "saml_single_logout_service_url_redirect";
public static final String SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE = "saml_single_logout_service_url_soap";
public static final String SAML_ARTIFACT_RESOLUTION_SERVICE_URL_ATTRIBUTE = "saml_artifact_resolution_service_url";
public static final String LOGIN_PROTOCOL = "saml";
public static final String SAML_BINDING = "saml_binding";
@ -612,8 +619,14 @@ public class SamlProtocol implements LoginProtocol {
public static String getLogoutServiceUrl(KeycloakSession session, ClientModel client, String bindingType, boolean backChannelLogout) {
String logoutServiceUrl = null;
if (SAML_SOAP_BINDING.equals(bindingType)) {
// standard backchannel logout; cannot do front channel with SOAP binding
// we do not allow this URL to be set through the management URL (it is a purely backend-oriented URL)
logoutServiceUrl = client.getAttribute(SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE);
return logoutServiceUrl == null || logoutServiceUrl.trim().equals("") ? null : logoutServiceUrl;
} else if (!backChannelLogout && useArtifactForLogout(client)) {
// backchannel logout doesn't support sending artifacts
if (!backChannelLogout && useArtifactForLogout(client)) {
logoutServiceUrl = client.getAttribute(SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE);
} else if (SAML_POST_BINDING.equals(bindingType)) {
logoutServiceUrl = client.getAttribute(SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE);
@ -752,6 +765,32 @@ public class SamlProtocol implements LoginProtocol {
public Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
ClientModel client = clientSession.getClient();
SamlClient samlClient = new SamlClient(client);
// real backchannel logout if SOAP binding is supported (#9548)
String soapLogoutUrl = getLogoutServiceUrl(session, client, SAML_SOAP_BINDING, true);
if (soapLogoutUrl != null) {
try {
LogoutRequestType logoutRequest = createLogoutRequest(soapLogoutUrl, clientSession, client);
Document samlLogoutRequest = createBindingBuilder(samlClient, false).soapBinding(SAML2Request.convert(logoutRequest)).getDocument();
SOAPMessage soapResponse = Soap.createMessage().addToBody(samlLogoutRequest).call(soapLogoutUrl);
Document logoutResponse = Soap.extractSoapMessage(soapResponse);
SAMLDocumentHolder samlDocResponse = SAML2Response.getSAML2ObjectFromDocument(logoutResponse);
if (!validateLogoutResponse(logoutRequest, samlDocResponse, client)) {
return Response.serverError().build();
}
return Response.ok().build();
} catch (SOAPException e) {
logger.warnf(e, "Logout failed for client %s", client.getClientId());
return Response.serverError().build();
} catch (Exception e) {
logger.warn("failed to execute saml soap logout", e);
return Response.serverError().build();
}
}
logger.warnf("Can't do SOAP backchannel logout. No SingleLogoutService SOAP Binding registered for client %s; fallback on legacy backchannel logout",
client.getClientId());
// legacy backchannel logout implementation (send POST / Redirect binding directly to the logout endpoint without going through the browser)
String logoutUrl = getLogoutServiceUrl(session, client, SAML_POST_BINDING, true);
if (logoutUrl == null) {
logger.warnf("Can't do backchannel logout. No SingleLogoutService POST Binding registered for client: %s",
@ -805,6 +844,36 @@ public class SamlProtocol implements LoginProtocol {
return Response.ok().build();
}
/**
* Validate the logout response received by the client through the backchannel
*/
private boolean validateLogoutResponse(LogoutRequestType logoutRequest, SAMLDocumentHolder holder, ClientModel client) {
if (!(holder.getSamlObject() instanceof StatusResponseType)) {
logger.warn("Logout response format is not valid");
return false;
}
if (new SamlClient(client).requiresClientSignature()) {
try {
SamlProtocolUtils.verifyDocumentSignature(client, holder.getSamlDocument());
} catch (VerificationException ex) {
logger.warnf("Logout response from client %s contains invalid signature", client.getClientId());
return false;
}
}
StatusResponseType statusResponse = (StatusResponseType) holder.getSamlObject();
String issuer = statusResponse.getIssuer().getValue();
if (!client.getClientId().equals(issuer)) {
logger.warn("Logout response contains wrong 'issuer' value");
return false;
}
// check inResponseTo field of response
if (!logoutRequest.getID().equals(statusResponse.getInResponseTo())) {
logger.warn("Logout response contains wrong 'inResponseTo' value");
return false;
}
return true;
}
protected LogoutRequestType createLogoutRequest(String logoutUrl, AuthenticatedClientSessionModel clientSession, ClientModel client, NodeGenerator... extensions) throws ConfigurationException {
// build userPrincipal with subject used at login
SAML2LogoutRequestBuilder logoutBuilder = new SAML2LogoutRequestBuilder().assertionExpiration(realm.getAccessCodeLifespan()).issuer(getResponseIssuer(realm))

View file

@ -172,23 +172,25 @@ public class SamlService extends AuthorizationEndpointBase {
// and we want to turn it off.
protected boolean redirectToAuthentication;
protected abstract Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters);
protected Response basicChecks(String samlRequest, String samlResponse, String artifact) {
logger.tracef("basicChecks(%s, %s, %s)%s", samlRequest, samlResponse, artifact, getShortStackTrace());
if (!checkSsl()) {
event.event(EventType.LOGIN);
event.error(Errors.SSL_REQUIRED);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.HTTPS_REQUIRED);
return error(session, null, Response.Status.BAD_REQUEST, Messages.HTTPS_REQUIRED);
}
if (!realm.isEnabled()) {
event.event(EventType.LOGIN_ERROR);
event.error(Errors.REALM_DISABLED);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.REALM_NOT_ENABLED);
return error(session, null, Response.Status.BAD_REQUEST, Messages.REALM_NOT_ENABLED);
}
if (samlRequest == null && samlResponse == null && artifact == null) {
event.event(EventType.LOGIN);
event.error(Errors.SAML_TOKEN_NOT_FOUND);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
return null;
@ -205,7 +207,7 @@ public class SamlService extends AuthorizationEndpointBase {
if (! (holder.getSamlObject() instanceof StatusResponseType)) {
event.detail(Details.REASON, Errors.INVALID_SAML_RESPONSE);
event.error(Errors.INVALID_SAML_RESPONSE);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
StatusResponseType statusResponse = (StatusResponseType) holder.getSamlObject();
@ -214,12 +216,12 @@ public class SamlService extends AuthorizationEndpointBase {
statusResponse.getDestination() == null && containsUnencryptedSignature(holder)) {
event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION);
event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
if (! destinationValidator.validate(this.getExpectedDestinationUri(session), statusResponse.getDestination())) {
event.detail(Details.REASON, Errors.INVALID_DESTINATION);
event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
AuthenticationManager.AuthResult authResult = authManager.authenticateIdentityCookie(session, realm, false);
@ -227,7 +229,7 @@ public class SamlService extends AuthorizationEndpointBase {
logger.warn("Unknown saml response.");
event.event(EventType.LOGOUT);
event.error(Errors.INVALID_TOKEN);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
// assume this is a logout response
UserSessionModel userSession = authResult.getSession();
@ -236,7 +238,7 @@ public class SamlService extends AuthorizationEndpointBase {
logger.warn("UserSession is not tagged as logging out.");
event.event(EventType.LOGOUT);
event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
String issuer = statusResponse.getIssuer().getValue();
ClientModel client = realm.getClientByClientId(issuer);
@ -244,13 +246,13 @@ public class SamlService extends AuthorizationEndpointBase {
event.event(EventType.LOGOUT);
event.client(issuer);
event.error(Errors.CLIENT_NOT_FOUND);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.CLIENT_NOT_FOUND);
return error(session, null, Response.Status.BAD_REQUEST, Messages.CLIENT_NOT_FOUND);
}
if (!isClientProtocolCorrect(client)) {
event.event(EventType.LOGOUT);
event.error(Errors.INVALID_CLIENT);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, "Wrong client protocol.");
return error(session, null, Response.Status.BAD_REQUEST, "Wrong client protocol.");
}
session.getContext().setClient(client);
@ -265,7 +267,7 @@ public class SamlService extends AuthorizationEndpointBase {
if (documentHolder == null) {
event.event(EventType.LOGIN);
event.error(Errors.INVALID_TOKEN);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
@ -281,7 +283,7 @@ public class SamlService extends AuthorizationEndpointBase {
event.event(EventType.LOGIN);
event.error(Errors.INVALID_TOKEN);
event.detail(Details.REASON, "Unhandled SAML document type: " + (samlObject == null ? "<null>" : samlObject.getClass().getSimpleName()));
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
RequestAbstractType requestAbstractType = (RequestAbstractType) samlObject;
@ -304,7 +306,7 @@ public class SamlService extends AuthorizationEndpointBase {
} catch (VerificationException e) {
SamlService.logger.error("request validation failed", e);
event.error(Errors.INVALID_SIGNATURE);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUESTER);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUESTER);
}
logger.debug("verified request");
@ -312,7 +314,7 @@ public class SamlService extends AuthorizationEndpointBase {
requestAbstractType.getDestination() == null && containsUnencryptedSignature(documentHolder)) {
event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION);
event.error(Errors.INVALID_REQUEST);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
if (samlObject instanceof AuthnRequestType) {
@ -353,7 +355,7 @@ public class SamlService extends AuthorizationEndpointBase {
event.event(EventType.LOGIN);
event.detail(Details.REASON, e.getMessage());
event.error(Errors.INVALID_SAML_ARTIFACT);
asyncResponse.resume(ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST));
asyncResponse.resume(error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST));
return;
}
@ -393,7 +395,7 @@ public class SamlService extends AuthorizationEndpointBase {
event.event(EventType.LOGIN);
event.detail(Details.REASON, e.getMessage());
event.error(Errors.IDENTITY_PROVIDER_ERROR);
asyncResponse.resume(ErrorPage.error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST));
asyncResponse.resume(error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST));
return;
}
}
@ -412,7 +414,7 @@ public class SamlService extends AuthorizationEndpointBase {
SamlClient samlClient = new SamlClient(client);
if (! validateDestination(requestAbstractType, samlClient, Errors.INVALID_SAML_AUTHN_REQUEST)) {
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
String bindingType = getBindingType(requestAbstractType);
@ -441,7 +443,7 @@ public class SamlService extends AuthorizationEndpointBase {
if (redirect == null) {
event.error(Errors.INVALID_REDIRECT_URI);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REDIRECT_URI);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REDIRECT_URI);
}
AuthenticationSessionModel authSession = createAuthenticationSession(client, relayState);
@ -472,7 +474,7 @@ public class SamlService extends AuthorizationEndpointBase {
} else {
event.detail(Details.REASON, Errors.UNSUPPORTED_NAMEID_FORMAT);
event.error(Errors.INVALID_SAML_AUTHN_REQUEST);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.UNSUPPORTED_NAME_ID_FORMAT);
return error(session, null, Response.Status.BAD_REQUEST, Messages.UNSUPPORTED_NAME_ID_FORMAT);
}
}
@ -532,7 +534,7 @@ public class SamlService extends AuthorizationEndpointBase {
protected Response logoutRequest(LogoutRequestType logoutRequest, ClientModel client, String relayState) {
SamlClient samlClient = new SamlClient(client);
if (! validateDestination(logoutRequest, samlClient, Errors.INVALID_SAML_LOGOUT_REQUEST)) {
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
// authenticate identity cookie, but ignore an access token timeout as we're logging out anyways.
@ -598,9 +600,18 @@ public class SamlService extends AuthorizationEndpointBase {
}
try {
event.event(EventType.LOGOUT)
.detail(Details.AUTH_METHOD, userSession.getAuthMethod())
.client(session.getContext().getClient())
.user(userSession.getUser())
.session(userSession)
.detail(Details.USERNAME, userSession.getLoginUsername())
.detail(Details.RESPONSE_MODE, getBindingType());
authManager.backchannelLogout(session, realm, userSession, session.getContext().getUri(), clientConnection, headers, true);
event.success();
} catch (Exception e) {
logger.warn("Failure with backchannel logout", e);
event.error("Failure with backchannel logout");
}
}
@ -628,6 +639,8 @@ public class SamlService extends AuthorizationEndpointBase {
try {
if (postBinding) {
return binding.postBinding(builder.buildDocument()).response(logoutBindingUri);
} else if (SamlProtocol.SAML_SOAP_BINDING.equals(logoutBinding)) {
return binding.soapBinding(builder.buildDocument()).response();
} else {
return binding.redirectBinding(builder.buildDocument()).response(logoutBindingUri);
}
@ -704,10 +717,46 @@ public class SamlService extends AuthorizationEndpointBase {
final URI baseUri = session.getContext().getUri().getBaseUri();
return Urls.samlRequestEndpoint(baseUri, realmName);
}
private Response checkClientValidity(ClientModel client) {
if (client == null) {
event.event(EventType.LOGIN);
event.detail(Details.REASON, "Cannot_match_source_hash");
event.error(Errors.CLIENT_NOT_FOUND);
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
if (!client.isEnabled()) {
event.event(EventType.LOGIN);
event.error(Errors.CLIENT_DISABLED);
return error(session, null, Response.Status.BAD_REQUEST, Messages.LOGIN_REQUESTER_NOT_ENABLED);
}
if (client.isBearerOnly()) {
event.event(EventType.LOGIN);
event.error(Errors.NOT_ALLOWED);
return error(session, null, Response.Status.BAD_REQUEST, Messages.BEARER_ONLY);
}
if (!client.isStandardFlowEnabled()) {
event.event(EventType.LOGIN);
event.error(Errors.NOT_ALLOWED);
return error(session, null, Response.Status.BAD_REQUEST, Messages.STANDARD_FLOW_DISABLED);
}
if (!isClientProtocolCorrect(client)) {
event.event(EventType.LOGIN);
event.error(Errors.INVALID_CLIENT);
return error(session, null, Response.Status.BAD_REQUEST, "Wrong client protocol.");
}
return null;
}
}
protected class PostBindingProtocol extends BindingProtocol {
@Override
protected Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters) {
return ErrorPage.error(session, authenticationSession, status, message, parameters);
}
@Override
protected String encodeSamlDocument(Document samlDocument) throws ProcessingException {
try {
@ -748,6 +797,11 @@ public class SamlService extends AuthorizationEndpointBase {
protected class RedirectBindingProtocol extends BindingProtocol {
@Override
protected Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters) {
return ErrorPage.error(session, authenticationSession, status, message, parameters);
}
@Override
protected String encodeSamlDocument(Document samlDocument) throws ProcessingException {
try {
@ -883,37 +937,6 @@ public class SamlService extends AuthorizationEndpointBase {
return false;
}
private Response checkClientValidity(ClientModel client) {
if (client == null) {
event.event(EventType.LOGIN);
event.detail(Details.REASON, "Cannot_match_source_hash");
event.error(Errors.CLIENT_NOT_FOUND);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
if (!client.isEnabled()) {
event.event(EventType.LOGIN);
event.error(Errors.CLIENT_DISABLED);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.LOGIN_REQUESTER_NOT_ENABLED);
}
if (client.isBearerOnly()) {
event.event(EventType.LOGIN);
event.error(Errors.NOT_ALLOWED);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.BEARER_ONLY);
}
if (!client.isStandardFlowEnabled()) {
event.event(EventType.LOGIN);
event.error(Errors.NOT_ALLOWED);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.STANDARD_FLOW_DISABLED);
}
if (!isClientProtocolCorrect(client)) {
event.event(EventType.LOGIN);
event.error(Errors.INVALID_CLIENT);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, "Wrong client protocol.");
}
return null;
}
@GET
@Path("clients/{client}")
@Produces(MediaType.TEXT_HTML_UTF_8)

View file

@ -37,6 +37,7 @@ import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.common.exceptions.ConfigurationException;
import org.keycloak.saml.common.exceptions.ProcessingException;
import org.keycloak.saml.validators.DestinationValidator;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.w3c.dom.Document;
@ -68,11 +69,22 @@ public class SamlEcpProfileService extends SamlService {
public Response authenticate(Document soapMessage) {
try {
return new PostBindingProtocol() {
@Override
protected Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters) {
return Soap.createFault().code("error").reason(message).build();
}
@Override
protected String getBindingType(AuthnRequestType requestAbstractType) {
return SamlProtocol.SAML_SOAP_BINDING;
}
@Override
protected String getBindingType() {
return SamlProtocol.SAML_SOAP_BINDING;
}
@Override
protected boolean isDestinationRequired() {
return false;

View file

@ -23,6 +23,7 @@ import org.apache.http.entity.ContentType;
import org.keycloak.saml.processing.core.saml.v2.util.DocumentUtil;
import org.keycloak.saml.processing.web.util.PostBindingUtil;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import javax.ws.rs.core.MediaType;
@ -101,7 +102,7 @@ public final class Soap {
public static Document extractSoapMessage(SOAPMessage soapMessage) {
try {
SOAPBody soapBody = soapMessage.getSOAPBody();
Node authnRequestNode = soapBody.getFirstChild();
Node authnRequestNode = getFirstChild(soapBody);
Document document = DocumentUtil.createDocument();
document.appendChild(document.importNode(authnRequestNode, true));
return document;
@ -110,6 +111,20 @@ public final class Soap {
}
}
/**
* Get the first direct child that is an XML element.
* In case of pretty-printed XML (with newlines and spaces), this method skips non-element objects (e.g. text)
* to really fetch the next XML tag.
*/
public static Node getFirstChild(Node parent) {
Node n = parent.getFirstChild();
while (n != null && !(n instanceof Element)) {
n = n.getNextSibling();
}
if (n == null) return null;
return n;
}
public static class SoapMessageBuilder {
private final SOAPMessage message;
private final SOAPBody body;

View file

@ -75,6 +75,7 @@ import javax.xml.soap.SOAPException;
import javax.xml.soap.SOAPHeader;
import javax.xml.soap.SOAPHeaderElement;
import javax.xml.soap.SOAPMessage;
import javax.xml.ws.soap.SOAPFaultException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@ -348,11 +349,10 @@ public class SamlClient {
@Override
public SAMLDocumentHolder extractResponse(CloseableHttpResponse response, String realmPublicKey) throws IOException {
assertThat(response, statusCodeIsHC(200));
MessageFactory messageFactory = null;
try {
messageFactory = MessageFactory.newInstance();
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) {
MessageFactory messageFactory = MessageFactory.newInstance();
SOAPMessage soapMessage = messageFactory.createMessage(null, response.getEntity().getContent());
SOAPBody soapBody = soapMessage.getSOAPBody();
Node authnRequestNode = soapBody.getFirstChild();
@ -365,6 +365,15 @@ public class SamlClient {
SAML2Object responseType = (SAML2Object) samlParser.parse(document);
return new SAMLDocumentHolder(responseType, document);
} else if (statusCode == 500) {
MessageFactory messageFactory = MessageFactory.newInstance();
SOAPMessage soapMessage = messageFactory.createMessage(null, response.getEntity().getContent());
SOAPBody soapBody = soapMessage.getSOAPBody();
throw new SOAPFaultException(soapBody.getFault());
} else {
throw new RuntimeException("Unexpected response status code (" + statusCode + ")");
}
} catch (SOAPException | ConfigurationException | ProcessingException | ParsingException e) {
throw new RuntimeException(e);
}

View file

@ -171,9 +171,14 @@ public class SamlClientBuilder {
return addStepBuilder(new CreateAuthnRequestStepBuilder(authServerSamlUrl, authnRequestDocument, requestBinding, this));
}
/** Issues the given AuthnRequest to the SAML endpoint */
public CreateLogoutRequestStepBuilder logoutRequest(URI authServerSamlUrl, String issuer, Binding requestBinding) {
return addStepBuilder(new CreateLogoutRequestStepBuilder(authServerSamlUrl, issuer, requestBinding, this));
/** Issues the given LogoutRequest to the SAML endpoint */
public CreateLogoutRequestStepBuilder logoutRequest(URI logoutServerSamlUrl, String issuer, Binding requestBinding) {
return addStepBuilder(new CreateLogoutRequestStepBuilder(logoutServerSamlUrl, issuer, requestBinding, this));
}
/** Issues the given LogoutRequest to the SAML endpoint */
public CreateLogoutRequestStepBuilder logoutRequest(URI logoutServerSamlUrl, String issuer, Binding requestBinding, boolean skipSignature) {
return addStepBuilder(new CreateLogoutRequestStepBuilder(logoutServerSamlUrl, issuer, requestBinding, this, skipSignature));
}
/** Issues the given SAML document to the SAML endpoint */

View file

@ -35,7 +35,7 @@ import org.apache.http.impl.client.CloseableHttpClient;
*/
public class CreateLogoutRequestStepBuilder extends SamlDocumentStepBuilder<LogoutRequestType, CreateLogoutRequestStepBuilder> {
private final URI authServerSamlUrl;
private final URI logoutServerSamlUrl;
private final String issuer;
private final Binding requestBinding;
@ -46,13 +46,23 @@ public class CreateLogoutRequestStepBuilder extends SamlDocumentStepBuilder<Logo
private String signingPrivateKeyPem;
private String signingCertificate;
public CreateLogoutRequestStepBuilder(URI authServerSamlUrl, String issuer, Binding requestBinding, SamlClientBuilder clientBuilder) {
private boolean skipSignature;
public CreateLogoutRequestStepBuilder(URI logoutServerSamlUrl, String issuer, Binding requestBinding, SamlClientBuilder clientBuilder) {
super(clientBuilder);
this.authServerSamlUrl = authServerSamlUrl;
this.logoutServerSamlUrl = logoutServerSamlUrl;
this.issuer = issuer;
this.requestBinding = requestBinding;
}
public CreateLogoutRequestStepBuilder(URI logoutServerSamlUrl, String issuer, Binding requestBinding, SamlClientBuilder clientBuilder, boolean skipSignature) {
super(clientBuilder);
this.logoutServerSamlUrl = logoutServerSamlUrl;
this.issuer = issuer;
this.requestBinding = requestBinding;
this.skipSignature = skipSignature;
}
public String sessionIndex() {
return sessionIndex.get();
}
@ -109,7 +119,7 @@ public class CreateLogoutRequestStepBuilder extends SamlDocumentStepBuilder<Logo
@Override
public HttpUriRequest perform(CloseableHttpClient client, URI currentURI, CloseableHttpResponse currentResponse, HttpClientContext context) throws Exception {
SAML2LogoutRequestBuilder builder = new SAML2LogoutRequestBuilder()
.destination(authServerSamlUrl == null ? null : authServerSamlUrl.toString())
.destination(logoutServerSamlUrl == null ? null : logoutServerSamlUrl.toString())
.issuer(issuer)
.sessionIndex(sessionIndex())
.nameId(nameId());
@ -121,9 +131,9 @@ public class CreateLogoutRequestStepBuilder extends SamlDocumentStepBuilder<Logo
return null;
}
return this.signingPrivateKeyPem == null
? requestBinding.createSamlUnsignedRequest(authServerSamlUrl, relayState(), DocumentUtil.getDocument(transformed))
: requestBinding.createSamlSignedRequest(authServerSamlUrl, relayState(), DocumentUtil.getDocument(transformed), signingPrivateKeyPem, signingPublicKeyPem, signingCertificate);
return this.signingPrivateKeyPem == null || skipSignature
? requestBinding.createSamlUnsignedRequest(logoutServerSamlUrl, relayState(), DocumentUtil.getDocument(transformed))
: requestBinding.createSamlSignedRequest(logoutServerSamlUrl, relayState(), DocumentUtil.getDocument(transformed), signingPrivateKeyPem, signingPublicKeyPem, signingCertificate);
}
}

View file

@ -0,0 +1,124 @@
package org.keycloak.testsuite.util.saml;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.dom.saml.v2.protocol.LogoutRequestType;
import org.keycloak.dom.saml.v2.protocol.StatusResponseType;
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.profile.util.Soap;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.saml.SAML2LogoutResponseBuilder;
import org.keycloak.saml.SignatureAlgorithm;
import org.keycloak.saml.processing.api.saml.v2.response.SAML2Response;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.w3c.dom.Document;
import javax.ws.rs.core.HttpHeaders;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.security.PrivateKey;
import java.security.PublicKey;
public class SamlBackchannelLogoutReceiver implements AutoCloseable {
private final HttpServer server;
private LogoutRequestType logoutRequest;
private final String url;
private final ClientRepresentation samlClient;
private final PublicKey publicKey;
private final PrivateKey privateKey;
public SamlBackchannelLogoutReceiver(int port, ClientRepresentation samlClient, String publicKeyStr, String privateKeyStr) {
this.samlClient = samlClient;
publicKey = publicKeyStr == null ? null : org.keycloak.testsuite.util.KeyUtils.publicKeyFromString(publicKeyStr);
privateKey = privateKeyStr == null ? null : org.keycloak.testsuite.util.KeyUtils.privateKeyFromString(privateKeyStr);
try {
InetSocketAddress address = new InetSocketAddress(InetAddress.getByName("localhost"), port);
server = HttpServer.create(address, 0);
this.url = "http://" + address.getHostString() + ":" + port;
} catch (IOException e) {
throw new RuntimeException("Cannot create http server", e);
}
server.createContext("/", new SamlBackchannelLogoutReceiver.SamlBackchannelLogoutHandler());
server.setExecutor(null);
server.start();
}
public SamlBackchannelLogoutReceiver(int port, ClientRepresentation samlClient) {
this(port, samlClient, null, null);
}
public String getUrl() {
return url;
}
public boolean isLogoutRequestReceived() {
return logoutRequest != null;
}
public LogoutRequestType getLogoutRequest() {
return logoutRequest;
}
@Override
public void close() throws Exception {
server.stop(0);
}
private class SamlBackchannelLogoutHandler implements HttpHandler {
public void handle(HttpExchange t) throws IOException {
try {
t.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "text/xml");
t.sendResponseHeaders(200, 0);
Document request = Soap.extractSoapMessage(t.getRequestBody());
SAMLDocumentHolder samlDoc = SAML2Response.getSAML2ObjectFromDocument(request);
if (!(samlDoc.getSamlObject() instanceof LogoutRequestType)) {
throw new RuntimeException("SamlBackchannelLogoutReceiver received a message that was not LogoutRequestType");
}
logoutRequest = (LogoutRequestType) samlDoc.getSamlObject();
StatusResponseType logoutResponse = new SAML2LogoutResponseBuilder()
.issuer(samlClient.getClientId())
.logoutRequestID(logoutRequest.getID())
.buildModel();
JaxrsSAML2BindingBuilder soapBinding = new JaxrsSAML2BindingBuilder(null);
if (requiresClientSignature(samlClient)) {
soapBinding.signatureAlgorithm(getSignatureAlgorithm(samlClient))
.signWith(KeyUtils.createKeyId(privateKey), privateKey, publicKey, null)
.signDocument();
}
Document doc = soapBinding.soapBinding(SAML2Response.convert(logoutResponse)).getDocument();
// send logout response
OutputStream os = t.getResponseBody();
os.write(Soap.createMessage().addToBody(doc).getBytes());
os.close();
} catch (Exception ex) {
t.sendResponseHeaders(500, 0);
}
}
}
private SignatureAlgorithm getSignatureAlgorithm(ClientRepresentation client) {
String alg = client.getAttributes().get(SamlConfigAttributes.SAML_SIGNATURE_ALGORITHM);
if (alg != null) {
SignatureAlgorithm algorithm = SignatureAlgorithm.valueOf(alg);
if (algorithm != null)
return algorithm;
}
return SignatureAlgorithm.RSA_SHA256;
}
public boolean requiresClientSignature(ClientRepresentation client) {
return "true".equals(client.getAttributes().get(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE));
}
}

View file

@ -26,6 +26,7 @@ import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.client.registration.HttpErrorException;
import org.keycloak.events.Errors;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.protocol.saml.mappers.AttributeStatementHelper;
import org.keycloak.protocol.saml.util.ArtifactBindingUtils;
import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation;
@ -107,7 +108,8 @@ public class SAMLClientRegistrationTest extends AbstractClientRegistrationTest {
"https://LoadBalancer-9.siroe.com:3443/federation/Consumer/metaAlias/sp/artifact"
));
assertThat(response.getAttributes().get("saml_single_logout_service_url_redirect"), is("https://LoadBalancer-9.siroe.com:3443/federation/SPSloRedirect/metaAlias/sp"));
assertThat(response.getAttributes().get(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE), is("https://LoadBalancer-9.siroe.com:3443/federation/SPSloRedirect/metaAlias/sp"));
assertThat(response.getAttributes().get(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE), is("https://LoadBalancer-9.siroe.com:3443/federation/SPSloSoap/metaAlias/sp"));
assertThat(response.getAttributes().get(SamlConfigAttributes.SAML_ARTIFACT_BINDING_IDENTIFIER), is(ArtifactBindingUtils.computeArtifactBindingIdentifierString("loadbalancer-9.siroe.com")));
Assert.assertNotNull(response.getProtocolMappers());

View file

@ -20,8 +20,6 @@ import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.broker.saml.SAMLIdentityProviderConfig;
import org.keycloak.broker.saml.SAMLIdentityProviderFactory;
import org.keycloak.dom.saml.v2.SAML2Object;
import org.keycloak.dom.saml.v2.assertion.AssertionType;
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
import org.keycloak.dom.saml.v2.assertion.NameIDType;
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
import org.keycloak.dom.saml.v2.protocol.LogoutRequestType;
@ -29,6 +27,7 @@ import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.dom.saml.v2.protocol.StatusResponseType;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
@ -54,22 +53,25 @@ import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.UriBuilderException;
import javax.xml.transform.dom.DOMSource;
import javax.xml.ws.soap.SOAPFaultException;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.testsuite.util.saml.SamlBackchannelLogoutReceiver;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.testsuite.util.Matchers.*;
import static org.keycloak.testsuite.util.SamlClient.Binding.*;
@ -89,11 +91,13 @@ public class LogoutTest extends AbstractSamlTest {
private ClientRepresentation salesRep;
private ClientRepresentation sales2Rep;
private ClientRepresentation salesSigRep;
@Before
public void setup() {
salesRep = adminClient.realm(REALM_NAME).clients().findByClientId(SAML_CLIENT_ID_SALES_POST).get(0);
sales2Rep = adminClient.realm(REALM_NAME).clients().findByClientId(SAML_CLIENT_ID_SALES_POST2).get(0);
salesSigRep = adminClient.realm(REALM_NAME).clients().findByClientId(SAML_CLIENT_ID_SALES_POST_SIG).get(0);
adminClient.realm(REALM_NAME)
.clients().get(salesRep.getId())
@ -148,6 +152,22 @@ public class LogoutTest extends AbstractSamlTest {
}).build();
}
private SamlClientBuilder prepareLogIntoTwoAppsSig() {
return new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, SAML_ASSERTION_CONSUMER_URL_SALES_POST, POST).build()
.login().user(bburkeUser).build()
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, POST)
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY).build()
.login().sso(true).build() // This is a formal step
.processSamlResponse(POST).transformObject(so -> {
assertThat(so, isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
return null; // Do not follow the redirect to the app from the returned response
}).build();
}
@Test
public void testLogoutDifferentBrowser() {
// This is in fact the same as admin logging out a session from admin console.
@ -195,6 +215,139 @@ public class LogoutTest extends AbstractSamlTest {
assertLogoutEvent(SAML_CLIENT_ID_SALES_POST);
}
/**
* Logout triggered with POST binding, with 2 clients to logout in the SLO process.
* One of the client is configured with backchannel logout + SOAP logout URL
*/
@Test
public void testSoapBackchannelLogout() {
try (SamlBackchannelLogoutReceiver backchannelLogoutReceiver = new SamlBackchannelLogoutReceiver(8082, sales2Rep);
Closeable sales2 = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST2)
.setFrontchannelLogout(false)
.setAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, backchannelLogoutReceiver.getUrl())
.setAttribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "true") // sign logout requests
.setAttribute(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true") // Force NameID to username
.setAttribute(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username") // Force NameID to username
.update();
) {
SAMLDocumentHolder samlResponse = prepareLogIntoTwoApps()
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, POST)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
.build()
.getSamlResponse(POST);
assertThat(samlResponse.getSamlObject(), isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
assertLogoutEvent(SAML_CLIENT_ID_SALES_POST);
// check that the logout request sent to the client is compliant and signed
assertTrue(backchannelLogoutReceiver.isLogoutRequestReceived());
LogoutRequestType logoutRequest = backchannelLogoutReceiver.getLogoutRequest();
assertNotNull(backchannelLogoutReceiver.getLogoutRequest().getSignature());
// check nameID
assertEquals(logoutRequest.getNameID().getValue(), bburkeUser.getUsername());
} catch (Exception ex) {
fail("unexpected error");
}
}
/**
* Logout triggered with POST binding, with 2 clients to logout in the SLO process.
* One of the client is configured with backchannel logout + SOAP logout URL
* This client is also configured with "client signature required" --> a signature is expected on the logout response
*/
@Test
public void testSoapBackchannelLogoutSignedResponseFromClient() {
try (SamlBackchannelLogoutReceiver backchannelLogoutReceiver = new SamlBackchannelLogoutReceiver(8082, salesSigRep, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY, SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY);
Closeable salesSig = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG)
.setFrontchannelLogout(false)
.setAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, backchannelLogoutReceiver.getUrl())
.setAttribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "true") // sign logout requests
.setAttribute(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true") // Force NameID to username
.setAttribute(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username") // Force NameID to username
.update();
) {
SAMLDocumentHolder samlResponse = prepareLogIntoTwoAppsSig()
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, POST)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
.build()
.getSamlResponse(POST);
assertThat(samlResponse.getSamlObject(), isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
assertLogoutEvent(SAML_CLIENT_ID_SALES_POST);
} catch (Exception ex) {
fail("unexpected error");
}
}
/** Logout triggered with SOAP binding, request is properly signed */
@Test
public void testSoapBackchannelLogoutFromSamlClient() {
try (
Closeable sales = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG)
.setFrontchannelLogout(false)
.setAttribute(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true") // Force NameID to username
.setAttribute(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username") // Force NameID to username
.update();
) {
SAMLDocumentHolder samlLogoutResponse = prepareLogIntoTwoAppsSig()
.clearCookies() // remove cookies, since SOAP calls do not embed cookie normally
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, SOAP)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
.build()
.getSamlResponse(SOAP);
assertThat(samlLogoutResponse.getSamlObject(), isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
assertSoapLogoutEvent(SAML_CLIENT_ID_SALES_POST_SIG);
} catch (Exception ex) {
ex.printStackTrace();
fail("unexpected error");
}
}
/** Logout triggered with SOAP binding, request is wrongly not signed --> ensure an error is thrown */
@Test
public void testSoapBackchannelLogoutFromSamlClientUnsignedRequest() {
try (
Closeable sales = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG)
.setFrontchannelLogout(false)
.setAttribute(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true") // Force NameID to username
.setAttribute(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username") // Force NameID to username
.update();
) {
try {
SAMLDocumentHolder samlLogoutResponse = prepareLogIntoTwoAppsSig()
.clearCookies() // remove cookies, since SOAP calls do not embed cookie normally
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, SOAP, true)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
.build()
.getSamlResponse(SOAP);
fail("should have triggered an error");
} catch (RuntimeException ex) {
// exception expected since the request is not signed
if (ex.getCause() instanceof SOAPFaultException) {
SOAPFaultException sfe = (SOAPFaultException) ex.getCause();
assertThat(sfe.getFault().getFaultString(), is("invalidRequesterMessage"));
}
}
assertSoapLogoutErrorEvent(SAML_CLIENT_ID_SALES_POST_SIG);
} catch (Exception ex) {
ex.printStackTrace();
fail("unexpected error");
}
}
@Test
public void testFrontchannelLogoutNoLogoutServiceUrlSetInSameBrowser() {
adminClient.realm(REALM_NAME)
@ -327,6 +480,28 @@ public class LogoutTest extends AbstractSamlTest {
assertNotNull(logoutEvent.getDetails().get(SamlProtocol.SAML_LOGOUT_REQUEST_ID));
}
private void assertSoapLogoutEvent(String clientId) {
List<EventRepresentation> logoutEvents = adminClient.realm(REALM_NAME)
.getEvents(Arrays.asList(EventType.LOGOUT.name()), clientId, null, null, null, null, null, null);
assertFalse(logoutEvents.isEmpty());
assertEquals(1, logoutEvents.size());
EventRepresentation logoutEvent = logoutEvents.get(0);
assertEquals(bburkeUser.getUsername(), logoutEvent.getDetails().get(Details.USERNAME));
assertEquals(SamlProtocol.SAML_SOAP_BINDING, logoutEvent.getDetails().get(Details.RESPONSE_MODE));
assertEquals("saml", logoutEvent.getDetails().get(Details.AUTH_METHOD));
}
private void assertSoapLogoutErrorEvent(String clientId) {
List<EventRepresentation> logoutEvents = adminClient.realm(REALM_NAME)
.getEvents(Arrays.asList(EventType.LOGOUT_ERROR.name()), null, null, null, null, null, null, null);
assertFalse(logoutEvents.isEmpty());
assertEquals(1, logoutEvents.size());
}
private IdentityProviderRepresentation addIdentityProvider() {
IdentityProviderRepresentation identityProvider = IdentityProviderBuilder.create()
.providerId(SAMLIdentityProviderFactory.PROVIDER_ID)

View file

@ -137,12 +137,13 @@ public class SOAPBindingTest extends AbstractSamlTest {
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.clearCookies()
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
.build()
.executeAndTransform(POST::extractResponse);
.executeAndTransform(SOAP::extractResponse);
assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));
@ -164,11 +165,12 @@ public class SOAPBindingTest extends AbstractSamlTest {
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.clearCookies()
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
.build()
.executeAndTransform(POST::extractResponse);
.executeAndTransform(SOAP::extractResponse);
assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));
@ -184,6 +186,7 @@ public class SOAPBindingTest extends AbstractSamlTest {
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.clearCookies()
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
@ -193,7 +196,7 @@ public class SOAPBindingTest extends AbstractSamlTest {
return logoutRequestType;
})
.build()
.executeAndTransform(POST::extractResponse);
.executeAndTransform(SOAP::extractResponse);
assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));
@ -215,6 +218,7 @@ public class SOAPBindingTest extends AbstractSamlTest {
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.clearCookies()
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
@ -223,7 +227,7 @@ public class SOAPBindingTest extends AbstractSamlTest {
return logoutRequestType;
})
.build()
.executeAndTransform(POST::extractResponse);
.executeAndTransform(SOAP::extractResponse);
assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));