From 891c8e1a12f0fe60db9d2aca6ed5d1dde8b3566f Mon Sep 17 00:00:00 2001 From: R Yamada Date: Wed, 6 Oct 2021 19:02:58 -0300 Subject: [PATCH] [KEYCLOAK-17653] - OIDC Frontchannel logout support --- .../OIDCConfigurationRepresentation.java | 14 +++ .../oidc/OIDCClientRepresentation.java | 10 ++ .../keycloak/forms/login/LoginFormsPages.java | 3 +- .../forms/login/LoginFormsProvider.java | 2 + .../FreeMarkerLoginFormsProvider.java | 15 ++- .../forms/login/freemarker/Templates.java | 2 + .../model/FrontChannelLogoutBean.java | 23 ++++ .../oidc/FrontChannelLogoutHandler.java | 117 ++++++++++++++++++ .../oidc/OIDCAdvancedConfigWrapper.java | 19 +++ .../protocol/oidc/OIDCConfigAttributes.java | 1 + .../protocol/oidc/OIDCLoginProtocol.java | 18 ++- .../context/LogoutRequestContext.java | 5 + .../executor/SecureLogoutExecutor.java | 102 +++++++++++++++ .../executor/SecureLogoutExecutorFactory.java | 68 ++++++++++ .../oidc/DescriptionConverter.java | 6 +- .../managers/AuthenticationManager.java | 9 ++ ...ecutor.ClientPolicyExecutorProviderFactory | 3 +- .../rest/TestApplicationResourceProvider.java | 19 +++ ...estApplicationResourceProviderFactory.java | 3 +- .../resources/TestApplicationResource.java | 5 + .../testsuite/client/ClientPoliciesTest.java | 96 +++++++++++++- .../client/OIDCClientRegistrationTest.java | 2 + .../keycloak/testsuite/forms/LogoutTest.java | 66 ++++++++++ .../oidc/OIDCWellKnownProviderTest.java | 5 + .../messages/admin-messages_en.properties | 3 + .../admin/resources/js/controllers/clients.js | 3 + .../resources/partials/client-detail.html | 9 +- .../theme/base/login/frontchannel-logout.ftl | 30 +++++ .../login/messages/messages_en.properties | 3 + 29 files changed, 646 insertions(+), 15 deletions(-) create mode 100644 services/src/main/java/org/keycloak/forms/login/freemarker/model/FrontChannelLogoutBean.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/FrontChannelLogoutHandler.java create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureLogoutExecutor.java create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureLogoutExecutorFactory.java create mode 100644 themes/src/main/resources/theme/base/login/frontchannel-logout.ftl diff --git a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java index d07706bd9b..f8e0e8f420 100755 --- a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java +++ b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java @@ -49,6 +49,12 @@ public class OIDCConfigurationRepresentation { @JsonProperty("end_session_endpoint") private String logoutEndpoint; + @JsonProperty("frontchannel_logout_session_supported") + private Boolean frontChannelLogoutSessionSupported = true; + + @JsonProperty("frontchannel_logout_supported") + private Boolean frontChannelLogoutSupported = true; + @JsonProperty("jwks_uri") private String jwksUri; @@ -577,4 +583,12 @@ public class OIDCConfigurationRepresentation { public void setAuthorizationEncryptionEncValuesSupported(List authorizationEncryptionEncValuesSupported) { this.authorizationEncryptionEncValuesSupported = authorizationEncryptionEncValuesSupported; } + + public Boolean getFrontChannelLogoutSessionSupported() { + return frontChannelLogoutSessionSupported; + } + + public Boolean getFrontChannelLogoutSupported() { + return frontChannelLogoutSupported; + } } diff --git a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java index bdc1c725bd..7bc938328f 100644 --- a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java @@ -142,6 +142,8 @@ public class OIDCClientRepresentation { // PAR request private Boolean require_pushed_authorization_requests; + private String frontchannel_logout_uri; + public List getRedirectUris() { return redirect_uris; } @@ -559,4 +561,12 @@ public class OIDCClientRepresentation { public void setRequirePushedAuthorizationRequests(Boolean require_pushed_authorization_requests) { this.require_pushed_authorization_requests = require_pushed_authorization_requests; } + + public String getFrontChannelLogoutUri() { + return frontchannel_logout_uri; + } + + public void setFrontChannelLogoutUri(String frontchannel_logout_uri) { + this.frontchannel_logout_uri = frontchannel_logout_uri; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java index 508d41e051..6ef50a6006 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java @@ -26,6 +26,7 @@ public enum LoginFormsPages { LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_EMAIL, OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, REGISTER_USER_PROFILE, INFO, ERROR, ERROR_WEBAUTHN, LOGIN_UPDATE_PROFILE, LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM, - LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE, UPDATE_USER_PROFILE, IDP_REVIEW_USER_PROFILE; + LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE, UPDATE_USER_PROFILE, IDP_REVIEW_USER_PROFILE, + FRONTCHANNEL_LOGOUT; } diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java index 4562f86f63..c30bec59a8 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java @@ -98,6 +98,8 @@ public interface LoginFormsProvider extends Provider { Response createSamlPostForm(); + Response createFrontChannelLogoutPage(); + LoginFormsProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession); LoginFormsProvider setClientSessionCode(String accessCode); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index fe056214c6..460ae3820e 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -32,6 +32,7 @@ import org.keycloak.forms.login.freemarker.model.CodeBean; import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; import org.keycloak.forms.login.freemarker.model.IdpReviewProfileBean; import org.keycloak.forms.login.freemarker.model.LoginBean; +import org.keycloak.forms.login.freemarker.model.FrontChannelLogoutBean; import org.keycloak.forms.login.freemarker.model.OAuthGrantBean; import org.keycloak.forms.login.freemarker.model.ProfileBean; import org.keycloak.forms.login.freemarker.model.RealmBean; @@ -187,7 +188,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { @SuppressWarnings("incomplete-switch") protected Response createResponse(LoginFormsPages page) { - + Theme theme; try { theme = getTheme(); @@ -265,6 +266,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { UpdateProfileContext idpCtx = (UpdateProfileContext) attributes.get(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR); attributes.put("profile", new IdpReviewProfileBean(idpCtx, formData, session)); break; + case FRONTCHANNEL_LOGOUT: + attributes.put("logout", new FrontChannelLogoutBean(session)); + break; } return processTemplate(theme, Templates.getTemplate(page), locale); @@ -273,7 +277,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { private boolean isDynamicUserProfile() { return session.getProvider(UserProfileProvider.class).getConfiguration() != null; } - + @Override public Response createForm(String form) { Theme theme; @@ -565,7 +569,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { if(userCtx != null && userCtx.getUserProfileContext() == UserProfileContext.IDP_REVIEW) return createResponse(LoginFormsPages.IDP_REVIEW_USER_PROFILE); else - return createResponse(LoginFormsPages.UPDATE_USER_PROFILE); + return createResponse(LoginFormsPages.UPDATE_USER_PROFILE); } else { return createResponse(LoginFormsPages.LOGIN_UPDATE_PROFILE); } @@ -636,6 +640,11 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { return createResponse(LoginFormsPages.SAML_POST_FORM); } + @Override + public Response createFrontChannelLogoutPage() { + return createResponse(LoginFormsPages.FRONTCHANNEL_LOGOUT); + } + protected void setMessage(MessageType type, String message, Object... parameters) { messageType = type; messages = new ArrayList<>(); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java b/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java index 40e307b082..3c6e582242 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java @@ -78,6 +78,8 @@ public class Templates { return "update-user-profile.ftl"; case IDP_REVIEW_USER_PROFILE: return "idp-review-user-profile.ftl"; + case FRONTCHANNEL_LOGOUT: + return "frontchannel-logout.ftl"; default: throw new IllegalArgumentException(); } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/FrontChannelLogoutBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/FrontChannelLogoutBean.java new file mode 100644 index 0000000000..6c775326f9 --- /dev/null +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/FrontChannelLogoutBean.java @@ -0,0 +1,23 @@ +package org.keycloak.forms.login.freemarker.model; + +import java.util.List; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.FrontChannelLogoutHandler; + +public class FrontChannelLogoutBean { + + private final FrontChannelLogoutHandler logoutInfo; + + public FrontChannelLogoutBean(KeycloakSession session) { + logoutInfo = FrontChannelLogoutHandler.current(session); + } + + public String getLogoutRedirectUri() { + return logoutInfo.getLogoutRedirectUri(); + } + + public List getClients() { + return logoutInfo.getClients(); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/FrontChannelLogoutHandler.java b/services/src/main/java/org/keycloak/protocol/oidc/FrontChannelLogoutHandler.java new file mode 100644 index 0000000000..c9fbd08ed0 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/FrontChannelLogoutHandler.java @@ -0,0 +1,117 @@ +package org.keycloak.protocol.oidc; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.headers.SecurityHeadersProvider; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.utils.StringUtil; + +public class FrontChannelLogoutHandler { + + public static FrontChannelLogoutHandler current(KeycloakSession session) { + return (FrontChannelLogoutHandler) session.getAttribute(FrontChannelLogoutHandler.class.getName()); + } + + public static FrontChannelLogoutHandler currentOrCreate(KeycloakSession session, AuthenticatedClientSessionModel clientSession) { + FrontChannelLogoutHandler current = current(session); + + if (current == null) { + return new FrontChannelLogoutHandler(session, clientSession); + } + + return current; + } + + private final KeycloakSession session; + private final String sid; + private final String issuer; + private final List clients = new ArrayList<>(); + + private String logoutRedirectUri; + + private FrontChannelLogoutHandler(KeycloakSession session, AuthenticatedClientSessionModel clientSession) { + this.session = session; + this.sid = clientSession.getUserSession().getId(); + this.issuer = clientSession.getNote(OIDCLoginProtocol.ISSUER); + this.session.setAttribute(getClass().getName(), this); + } + + public void addClient(ClientModel client) { + clients.add(new ClientInfo(client)); + } + + public List getClients() { + return clients; + } + + public String getLogoutRedirectUri() { + return logoutRedirectUri; + } + + public Response renderLogoutPage(String redirectUri) { + configureCSP(); + this.logoutRedirectUri = redirectUri; + return session.getProvider(LoginFormsProvider.class).createFrontChannelLogoutPage(); + } + + private void configureCSP() { + StringBuilder allowFrameSrc = new StringBuilder(); + + for (ClientInfo client : clients) { + allowFrameSrc.append(client.frontChannelLogoutUrl.getAuthority()).append(' '); + } + + session.getProvider(SecurityHeadersProvider.class).options().allowAnyFrameAncestor(); + session.getProvider(SecurityHeadersProvider.class).options().allowFrameSrc(allowFrameSrc.toString()); + } + + private URI createFrontChannelLogoutUrl(ClientModel client) { + String frontChannelLogoutUrl = OIDCAdvancedConfigWrapper.fromClientModel(client).getFrontChannelLogoutUrl(); + + if (StringUtil.isBlank(frontChannelLogoutUrl)) { + frontChannelLogoutUrl = client.getBaseUrl(); + } + + if (frontChannelLogoutUrl == null) { + throw new RuntimeException("Client [" + client.getClientId() + "] does not have a valid frontend logout URL"); + } + + UriBuilder builder = UriBuilder.fromUri(frontChannelLogoutUrl); + + builder.queryParam("sid", FrontChannelLogoutHandler.this.sid); + builder.queryParam("iss", FrontChannelLogoutHandler.this.issuer); + + return builder.build(); + } + + public class ClientInfo { + + private final ClientModel client; + private final URI frontChannelLogoutUrl; + + public ClientInfo(ClientModel client) { + this.client = client; + this.frontChannelLogoutUrl = createFrontChannelLogoutUrl(client); + } + + public String getFrontChannelLogoutUrl() { + return frontChannelLogoutUrl.toString(); + } + + public String getName() { + String name = client.getName(); + + if (name == null) { + return client.getClientId(); + } + + return name; + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java index 23317fbdcc..97c7f82bff 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -22,6 +22,7 @@ import org.keycloak.jose.jws.Algorithm; import org.keycloak.models.ClientModel; import org.keycloak.models.Constants; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.utils.StringUtil; import java.util.Arrays; import java.util.Collections; @@ -287,6 +288,24 @@ public class OIDCAdvancedConfigWrapper { setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_REVOKE_OFFLINE_TOKENS, val); } + public void setFrontChannelLogoutUrl(String frontChannelLogoutUrl) { + if (clientRep != null) { + clientRep.setFrontchannelLogout(StringUtil.isNotBlank(frontChannelLogoutUrl)); + } + if (clientModel != null) { + clientModel.setFrontchannelLogout(StringUtil.isNotBlank(frontChannelLogoutUrl)); + } + setAttribute(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI, frontChannelLogoutUrl); + } + + public boolean isFrontChannelLogoutEnabled() { + return clientModel != null && clientModel.isFrontchannelLogout() && StringUtil.isNotBlank(getFrontChannelLogoutUrl()); + } + + public String getFrontChannelLogoutUrl() { + return getAttribute(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI); + } + private String getAttribute(String attrKey) { if (clientModel != null) { return clientModel.getAttribute(attrKey); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java index f37c89c339..094f338e83 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -75,6 +75,7 @@ public final class OIDCConfigAttributes { public static final String AUTHORIZATION_SIGNED_RESPONSE_ALG = "authorization.signed.response.alg"; public static final String AUTHORIZATION_ENCRYPTED_RESPONSE_ALG = "authorization.encrypted.response.alg"; public static final String AUTHORIZATION_ENCRYPTED_RESPONSE_ENC = "authorization.encrypted.response.enc"; + public static final String FRONT_CHANNEL_LOGOUT_URI = "frontchannel.logout.url"; private OIDCConfigAttributes() { } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index 1f9e879670..ccf878a043 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java @@ -31,6 +31,7 @@ import org.keycloak.constants.AdapterConstants; import org.keycloak.events.Details; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; +import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.headers.SecurityHeadersProvider; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; @@ -40,7 +41,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.LoginProtocol; -import org.keycloak.protocol.oidc.grants.device.DeviceGrantType; import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder; import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseType; @@ -338,8 +338,15 @@ public class OIDCLoginProtocol implements LoginProtocol { @Override public Response frontchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { - // todo oidc redirect support - throw new RuntimeException("NOT IMPLEMENTED"); + if (clientSession != null) { + ClientModel client = clientSession.getClient(); + if (OIDCAdvancedConfigWrapper.fromClientModel(client).isFrontChannelLogoutEnabled()) { + FrontChannelLogoutHandler logoutInfo = FrontChannelLogoutHandler.currentOrCreate(session, clientSession); + logoutInfo.addClient(client); + } + clientSession.setAction(AuthenticationSessionModel.Action.LOGGED_OUT.name()); + } + return null; } @Override @@ -351,7 +358,10 @@ public class OIDCLoginProtocol implements LoginProtocol { event.detail(Details.REDIRECT_URI, redirectUri); } event.user(userSession.getUser()).session(userSession).success(); - + FrontChannelLogoutHandler frontChannelLogoutHandler = FrontChannelLogoutHandler.current(session); + if (frontChannelLogoutHandler != null) { + return frontChannelLogoutHandler.renderLogoutPage(redirectUri); + } if (redirectUri != null) { UriBuilder uriBuilder = UriBuilder.fromUri(redirectUri); if (state != null) diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/context/LogoutRequestContext.java b/services/src/main/java/org/keycloak/services/clientpolicy/context/LogoutRequestContext.java index 9e13999639..7de124a76d 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/context/LogoutRequestContext.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/context/LogoutRequestContext.java @@ -17,6 +17,7 @@ package org.keycloak.services.clientpolicy.context; +import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; import org.keycloak.services.clientpolicy.ClientPolicyContext; @@ -33,6 +34,10 @@ public class LogoutRequestContext implements ClientPolicyContext { this.params = params; } + public LogoutRequestContext() { + this(null); + } + @Override public ClientPolicyEvent getEvent() { return ClientPolicyEvent.LOGOUT_REQUEST; diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureLogoutExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureLogoutExecutor.java new file mode 100644 index 0000000000..ef192efa09 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureLogoutExecutor.java @@ -0,0 +1,102 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.services.clientpolicy.executor; + +import java.util.Optional; +import javax.ws.rs.HttpMethod; +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.events.Errors; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.ClientCRUDContext; +import org.keycloak.utils.StringUtil; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SecureLogoutExecutor implements ClientPolicyExecutorProvider { + + private final KeycloakSession session; + private Configuration configuration; + + public SecureLogoutExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public void setupConfiguration(Configuration config) { + this.configuration = config; + } + + @Override + public Class getExecutorConfigurationClass() { + return Configuration.class; + } + + public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation { + @JsonProperty(SecureLogoutExecutorFactory.ALLOW_FRONT_CHANNEL_LOGOUT) + protected Boolean allowFrontChannelLogout = Boolean.FALSE; + + public Boolean isAllowFrontChannelLogout() { + return allowFrontChannelLogout; + } + + public void setAllowFrontChannelLogout(Boolean allowFrontChannelLogout) { + this.allowFrontChannelLogout = allowFrontChannelLogout; + } + } + + @Override + public String getProviderId() { + return PKCEEnforcerExecutorFactory.PROVIDER_ID; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + switch (context.getEvent()) { + case REGISTER: + case UPDATE: + ClientCRUDContext updateContext = (ClientCRUDContext)context; + ClientRepresentation client = updateContext.getProposedClientRepresentation(); + OIDCAdvancedConfigWrapper clientWrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(client); + + if (!configuration.isAllowFrontChannelLogout() + && (Optional.ofNullable(client.isFrontchannelLogout()).orElse(false) || StringUtil.isNotBlank(clientWrapper.getFrontChannelLogoutUrl()))) { + throwFrontChannelLogoutNotAllowed(); + } + + return; + case LOGOUT_REQUEST: + HttpRequest request = session.getContext().getContextObject(HttpRequest.class); + + if (HttpMethod.GET.equalsIgnoreCase(request.getHttpMethod()) && !configuration.isAllowFrontChannelLogout()) { + throwFrontChannelLogoutNotAllowed(); + } + + return; + default: + return; + } + } + + private void throwFrontChannelLogoutNotAllowed() throws ClientPolicyException { + throw new ClientPolicyException(Errors.INVALID_REGISTRATION, "Front-channel logout is not allowed for this client"); + } +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureLogoutExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureLogoutExecutorFactory.java new file mode 100644 index 0000000000..4859638a6b --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureLogoutExecutorFactory.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.services.clientpolicy.executor; + +import java.util.Collections; +import java.util.List; +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class SecureLogoutExecutorFactory implements ClientPolicyExecutorProviderFactory { + + public static final String PROVIDER_ID = "secure-logout"; + + public static final String ALLOW_FRONT_CHANNEL_LOGOUT = "allow-front-channel-logout"; + + private static final ProviderConfigProperty ALLOW_FRONT_CHANNEL_LOGOUT_PROPERTY = new ProviderConfigProperty( + ALLOW_FRONT_CHANNEL_LOGOUT, "Allow Front-Channel Logout", "If On, then front-channel logout should be allowed. Otherwise, clients should favor other logout mechanisms such as back-channel logout.", ProviderConfigProperty.BOOLEAN_TYPE, false); + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session) { + return new SecureLogoutExecutor(session); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "Enforces certain constraints on how clients should support logout."; + } + + @Override + public List getConfigProperties() { + return Collections.singletonList(ALLOW_FRONT_CHANNEL_LOGOUT_PROPERTY); + } + +} diff --git a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java index f8a713bd2a..9c83ccb14c 100755 --- a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java @@ -22,7 +22,6 @@ import org.keycloak.authentication.ClientAuthenticator; import org.keycloak.authentication.ClientAuthenticatorFactory; import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator; -import org.keycloak.crypto.ClientSignatureVerifierProvider; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKParser; @@ -66,7 +65,6 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.keycloak.models.CibaConfig.CIBA_POLL_MODE; import static org.keycloak.models.OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED; import static org.keycloak.models.CibaConfig.OIDC_CIBA_GRANT_ENABLED; @@ -218,6 +216,8 @@ public class DescriptionConverter { client.setAttributes(attr); } + configWrapper.setFrontChannelLogoutUrl(Optional.ofNullable(clientOIDC.getFrontChannelLogoutUri()).orElse(null)); + return client; } @@ -396,6 +396,8 @@ public class DescriptionConverter { response.setSectorIdentifierUri(sectorIdentifierUri); } + response.setFrontChannelLogoutUri(config.getFrontChannelLogoutUrl()); + return response; } diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 8a1794f19e..52ebefd1e1 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -70,8 +70,11 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.representations.AccessToken; +import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ServicesLogger; import org.keycloak.services.Urls; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.LogoutRequestContext; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.IdentityBrokerService; import org.keycloak.services.resources.LoginActionsService; @@ -493,6 +496,12 @@ public class AuthenticationManager { return null; } + try { + session.clientPolicy().triggerOnEvent(new LogoutRequestContext()); + } catch (ClientPolicyException cpe) { + throw new ErrorResponseException(cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus()); + } + try { setClientLogoutAction(logoutAuthSession, client.getId(), AuthenticationSessionModel.Action.LOGGING_OUT); diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory index eb7c1c3137..3d92a09420 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory @@ -12,4 +12,5 @@ org.keycloak.services.clientpolicy.executor.ConsentRequiredExecutorFactory org.keycloak.services.clientpolicy.executor.FullScopeDisabledExecutorFactory org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSessionEnforceExecutorFactory org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSignedAuthenticationRequestExecutorFactory -org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutorFactory \ No newline at end of file +org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutorFactory +org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java index af2afbf448..77406746e7 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java @@ -59,6 +59,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { private KeycloakSession session; private final BlockingQueue adminLogoutActions; + private final BlockingQueue frontChannelLogoutTokens; private final BlockingQueue backChannelLogoutTokens; private final BlockingQueue adminPushNotBeforeActions; private final BlockingQueue adminTestAvailabilityAction; @@ -72,6 +73,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { public TestApplicationResourceProvider(KeycloakSession session, BlockingQueue adminLogoutActions, BlockingQueue backChannelLogoutTokens, + BlockingQueue frontChannelLogoutTokens, BlockingQueue adminPushNotBeforeActions, BlockingQueue adminTestAvailabilityAction, TestApplicationResourceProviderFactory.OIDCClientData oidcClientData, @@ -80,6 +82,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { this.session = session; this.adminLogoutActions = adminLogoutActions; this.backChannelLogoutTokens = backChannelLogoutTokens; + this.frontChannelLogoutTokens = frontChannelLogoutTokens; this.adminPushNotBeforeActions = adminPushNotBeforeActions; this.adminTestAvailabilityAction = adminTestAvailabilityAction; this.oidcClientData = oidcClientData; @@ -101,6 +104,15 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { backChannelLogoutTokens.add(new JWSInput(request.getDecodedFormParameters().getFirst(OAuth2Constants.LOGOUT_TOKEN)).readJsonContent(LogoutToken.class)); } + @GET + @Path("/admin/frontchannelLogout") + public void frontchannelLogout(@QueryParam("sid") String sid, @QueryParam("iss") String issuer) { + LogoutToken token = new LogoutToken(); + token.setSid(sid); + token.issuer(issuer); + frontChannelLogoutTokens.add(token); + } + @POST @Consumes(MediaType.TEXT_PLAIN_UTF_8) @Path("/admin/k_push_not_before") @@ -129,6 +141,13 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { return backChannelLogoutTokens.poll(20, TimeUnit.SECONDS); } + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/poll-frontchannel-logout") + public LogoutToken getFrontChannelLogoutAction() throws InterruptedException { + return frontChannelLogoutTokens.poll(20, TimeUnit.SECONDS); + } + @GET @Produces(MediaType.APPLICATION_JSON) @Path("/poll-admin-not-before") diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java index 3dc6bac7ec..071541522b 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java @@ -47,6 +47,7 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv private BlockingQueue adminLogoutActions = new LinkedBlockingDeque<>(); private BlockingQueue backChannelLogoutTokens = new LinkedBlockingDeque<>(); + private BlockingQueue frontChannelLogoutTokens = new LinkedBlockingDeque<>(); private BlockingQueue pushNotBeforeActions = new LinkedBlockingDeque<>(); private BlockingQueue testAvailabilityActions = new LinkedBlockingDeque<>(); @@ -57,7 +58,7 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv @Override public RealmResourceProvider create(KeycloakSession session) { TestApplicationResourceProvider provider = new TestApplicationResourceProvider(session, adminLogoutActions, - backChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData, authenticationChannelRequests, cibaClientNotifications); + backChannelLogoutTokens, frontChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData, authenticationChannelRequests, cibaClientNotifications); ResteasyProviderFactory.getInstance().injectProperties(provider); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java index 247a114a5c..90450d0f87 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java @@ -45,6 +45,11 @@ public interface TestApplicationResource { @Path("/poll-backchannel-logout") LogoutToken getBackChannelLogoutToken(); + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/poll-frontchannel-logout") + LogoutToken getFrontChannelLogoutToken(); + @GET @Produces(MediaType.APPLICATION_JSON) @Path("/poll-admin-not-before") diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesTest.java index 97fef8f24b..0c303ec538 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesTest.java @@ -55,9 +55,11 @@ import org.keycloak.representations.AccessToken; import org.keycloak.representations.AuthorizationResponseToken; import org.keycloak.representations.IDToken; import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.oidc.OIDCClientRepresentation; @@ -79,6 +81,7 @@ import org.keycloak.services.clientpolicy.executor.HolderOfKeyEnforcerExecutorFa import org.keycloak.services.clientpolicy.executor.PKCEEnforcerExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureClientAuthenticatorExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureClientUrisExecutorFactory; +import org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutor; import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutorFactory; @@ -119,6 +122,7 @@ import java.util.Optional; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; @@ -144,6 +148,7 @@ import static org.keycloak.testsuite.util.ClientPoliciesUtil.createSecureSigning import static org.keycloak.testsuite.util.ClientPoliciesUtil.createTestRaiseExeptionConditionConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createFullScopeDisabledExecutorConfig; +import javax.ws.rs.BadRequestException; /** * @author Takashi Norimatsu @@ -2579,6 +2584,88 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest { assertEquals("Exception thrown intentionally", tokenResponse.getErrorDescription()); } + @Test + public void testSecureLogoutExecutor() throws Exception { + // register profiles + String json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Logout Test") + .addExecutor(SecureLogoutExecutorFactory.PROVIDER_ID, null) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesBuilder()).addPolicy( + (new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Logout Policy", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + String clientId = generateSuffixedName(CLIENT_NAME); + String clientSecret = "secret"; + try { + createClientByAdmin(clientId, (ClientRepresentation clientRep) -> { + clientRep.setSecret(clientSecret); + clientRep.setStandardFlowEnabled(Boolean.TRUE); + clientRep.setImplicitFlowEnabled(Boolean.TRUE); + clientRep.setPublicClient(Boolean.FALSE); + clientRep.setFrontchannelLogout(true); + }); + } catch (ClientPolicyException cpe) { + assertEquals("Front-channel logout is not allowed for this client", cpe.getErrorDetail()); + } + + String cid = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> { + clientRep.setSecret(clientSecret); + clientRep.setStandardFlowEnabled(Boolean.TRUE); + clientRep.setImplicitFlowEnabled(Boolean.TRUE); + clientRep.setPublicClient(Boolean.FALSE); + }); + + ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(cid); + ClientRepresentation clientRep = clientResource.toRepresentation(); + + clientRep.setFrontchannelLogout(true); + + try { + clientResource.update(clientRep); + } catch (BadRequestException bre) { + assertEquals("Front-channel logout is not allowed for this client", bre.getResponse().readEntity(OAuth2ErrorRepresentation.class).getErrorDescription()); + } + + ClientPolicyExecutorConfigurationRepresentation config = new ClientPolicyExecutorConfigurationRepresentation(); + + config.setConfigAsMap(SecureLogoutExecutorFactory.ALLOW_FRONT_CHANNEL_LOGOUT, Boolean.TRUE.booleanValue()); + + json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Logout Test") + .addExecutor(SecureLogoutExecutorFactory.PROVIDER_ID, config) + .toRepresentation() + ).toString(); + updateProfiles(json); + + OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setFrontChannelLogoutUrl(oauth.getRedirectUri()); + clientResource.update(clientRep); + + config.setConfigAsMap(SecureLogoutExecutorFactory.ALLOW_FRONT_CHANNEL_LOGOUT, Boolean.FALSE.toString()); + + json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Logout Test") + .addExecutor(SecureLogoutExecutorFactory.PROVIDER_ID, config) + .toRepresentation() + ).toString(); + updateProfiles(json); + + successfulLogin(clientId, clientSecret); + + oauth.openLogout(); + + assertTrue(driver.getPageSource().contains("Front-channel logout is not allowed for this client")); + } + private void openVerificationPage(String verificationUri) { driver.navigate().to(verificationUri); } @@ -2753,6 +2840,12 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest { } private void successfulLoginAndLogout(String clientId, String clientSecret) { + OAuthClient.AccessTokenResponse res = successfulLogin(clientId, clientSecret); + oauth.doLogout(res.getRefreshToken(), clientSecret); + events.expectLogout(res.getSessionState()).client(clientId).clearDetails().assertEvent(); + } + + private OAuthClient.AccessTokenResponse successfulLogin(String clientId, String clientSecret) { oauth.clientId(clientId); oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); @@ -2764,8 +2857,7 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest { assertEquals(200, res.getStatusCode()); events.expectCodeToToken(codeId, sessionId).client(clientId).assertEvent(); - oauth.doLogout(res.getRefreshToken(), clientSecret); - events.expectLogout(sessionId).client(clientId).clearDetails().assertEvent(); + return res; } private void successfulLoginAndLogoutWithPKCE(String clientId, String clientSecret, String userName, String userPassword) throws Exception { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java index 108a348cc0..f9181a319c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java @@ -89,6 +89,7 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { client.setClientName("RegistrationAccessTokenTest"); client.setClientUri("http://root"); client.setRedirectUris(Collections.singletonList("http://redirect")); + client.setFrontChannelLogoutUri("http://frontchannel"); return client; } @@ -157,6 +158,7 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { assertEquals(Arrays.asList(OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.REFRESH_TOKEN), response.getGrantTypes()); assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, response.getTokenEndpointAuthMethod()); Assert.assertNull(response.getUserinfoSignedResponseAlg()); + assertEquals("http://frontchannel", response.getFrontChannelLogoutUri()); } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LogoutTest.java index 86b549ede8..618c05452c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LogoutTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LogoutTest.java @@ -22,10 +22,16 @@ import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientsResource; import org.keycloak.common.Profile; import org.keycloak.events.Details; import org.keycloak.events.Errors; +import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.Constants; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.LogoutToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; @@ -348,4 +354,64 @@ public class LogoutTest extends AbstractTestRealmKeycloakTest { } } + @Test + public void testFrontChannelLogoutWithPostLogoutRedirectUri() throws Exception { + ClientsResource clients = adminClient.realm(oauth.getRealm()).clients(); + ClientRepresentation rep = clients.findByClientId(oauth.getClientId()).get(0); + rep.setFrontchannelLogout(true); + rep.getAttributes().put(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI, oauth.APP_ROOT + "/admin/frontchannelLogout"); + clients.get(rep.getId()).update(rep); + try { + oauth.clientSessionState("client-session"); + oauth.doLogin("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + String idTokenString = tokenResponse.getIdToken(); + String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString) + .postLogoutRedirectUri(oauth.APP_AUTH_ROOT).build(); + driver.navigate().to(logoutUrl); + LogoutToken logoutToken = testingClient.testApp().getFrontChannelLogoutToken(); + Assert.assertNotNull(logoutToken); + + IDToken idToken = new JWSInput(idTokenString).readJsonContent(IDToken.class); + + Assert.assertEquals(logoutToken.getIssuer(), idToken.getIssuer()); + Assert.assertEquals(logoutToken.getSid(), idToken.getSessionId()); + } finally { + rep.setFrontchannelLogout(false); + rep.getAttributes().put(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI, ""); + clients.get(rep.getId()).update(rep); + } + } + + @Test + public void testFrontChannelLogout() throws Exception { + ClientsResource clients = adminClient.realm(oauth.getRealm()).clients(); + ClientRepresentation rep = clients.findByClientId(oauth.getClientId()).get(0); + rep.setName("My Testing App"); + rep.setFrontchannelLogout(true); + rep.getAttributes().put(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI, oauth.APP_ROOT + "/admin/frontchannelLogout"); + clients.get(rep.getId()).update(rep); + try { + oauth.clientSessionState("client-session"); + oauth.doLogin("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + String idTokenString = tokenResponse.getIdToken(); + String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString).build(); + driver.navigate().to(logoutUrl); + LogoutToken logoutToken = testingClient.testApp().getFrontChannelLogoutToken(); + Assert.assertNotNull(logoutToken); + IDToken idToken = new JWSInput(idTokenString).readJsonContent(IDToken.class); + Assert.assertEquals(logoutToken.getIssuer(), idToken.getIssuer()); + Assert.assertEquals(logoutToken.getSid(), idToken.getSessionId()); + assertTrue(driver.getTitle().equals("Logging out")); + assertTrue(driver.getPageSource().contains("You are logging out from following apps")); + assertTrue(driver.getPageSource().contains("My Testing App")); + } finally { + rep.setFrontchannelLogout(false); + rep.getAttributes().put(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI, ""); + clients.get(rep.getId()).update(rep); + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java index 810f662af5..d9ff7da8f8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java @@ -62,6 +62,7 @@ import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; /** @@ -205,6 +206,10 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest { assertEquals(oauth.getParEndpointUrl(), oidcConfig.getPushedAuthorizationRequestEndpoint()); assertEquals(Boolean.FALSE, oidcConfig.getRequirePushedAuthorizationRequests()); + // frontchannel logout + assertTrue(oidcConfig.getFrontChannelLogoutSessionSupported()); + assertTrue(oidcConfig.getFrontChannelLogoutSupported()); + } finally { client.close(); } diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 3eb3304106..050175f10b 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -360,6 +360,9 @@ force-post-binding=Force POST Binding force-post-binding.tooltip=Always use POST binding for responses. front-channel-logout=Front Channel Logout front-channel-logout.tooltip=When true, logout requires a browser redirect to client. When false, server performs a background invocation for logout. +front-channel-logout-url=Front-Channel Logout URL +front-channel-logout-url.tooltip=URL that will cause the client to log itself out when a logout request is sent to this realm (via end_session_endpoint). If not provided, it defaults to the base url. + force-name-id-format=Force Name ID Format force-name-id-format.tooltip=Ignore requested NameID subject format and use admin console configured one. name-id-format=Name ID Format diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js index ff9dfce604..659620874c 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js @@ -1777,6 +1777,9 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro } else { $scope.clientEdit.attributes["request.uris"] = null; } + if (!$scope.clientEdit.frontchannelLogout) { + $scope.clientEdit.attributes["frontchannel.logout.url"] = null; + } delete $scope.clientEdit.requestUris; if ($scope.samlArtifactBinding == true) { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index 2164f0b8e9..f4233ce364 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -271,13 +271,20 @@ {{:: 'force-post-binding.tooltip' | translate}} -
+
{{:: 'front-channel-logout.tooltip' | translate}}
+
+ +
+ +
+ {{:: 'front-channel-logout-url.tooltip' | translate}} +
diff --git a/themes/src/main/resources/theme/base/login/frontchannel-logout.ftl b/themes/src/main/resources/theme/base/login/frontchannel-logout.ftl new file mode 100644 index 0000000000..3de7d2500e --- /dev/null +++ b/themes/src/main/resources/theme/base/login/frontchannel-logout.ftl @@ -0,0 +1,30 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + + ${msg("frontchannel-logout.title")} + <#elseif section = "form"> +

${msg("frontchannel-logout.message")}

+
    + <#list logout.clients as client> +
  • + ${client.name} + +
  • + +
+ <#if logout.logoutRedirectUri?has_content> + + ${msg("doContinue")} + + + diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 76da1b08d4..8991242b91 100755 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -439,3 +439,6 @@ accountUnusable=Any subsequent use of the application will not be possible with userDeletedSuccessfully=User deleted successfully access-denied=Access denied + +frontchannel-logout.title=Logging out +frontchannel-logout.message=You are logging out from following apps