[KEYCLOAK-17653] - OIDC Frontchannel logout support
This commit is contained in:
parent
97ee8832a3
commit
891c8e1a12
29 changed files with 646 additions and 15 deletions
|
@ -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<String> authorizationEncryptionEncValuesSupported) {
|
||||
this.authorizationEncryptionEncValuesSupported = authorizationEncryptionEncValuesSupported;
|
||||
}
|
||||
|
||||
public Boolean getFrontChannelLogoutSessionSupported() {
|
||||
return frontChannelLogoutSessionSupported;
|
||||
}
|
||||
|
||||
public Boolean getFrontChannelLogoutSupported() {
|
||||
return frontChannelLogoutSupported;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -142,6 +142,8 @@ public class OIDCClientRepresentation {
|
|||
// PAR request
|
||||
private Boolean require_pushed_authorization_requests;
|
||||
|
||||
private String frontchannel_logout_uri;
|
||||
|
||||
public List<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
@ -98,6 +98,8 @@ public interface LoginFormsProvider extends Provider {
|
|||
|
||||
Response createSamlPostForm();
|
||||
|
||||
Response createFrontChannelLogoutPage();
|
||||
|
||||
LoginFormsProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession);
|
||||
|
||||
LoginFormsProvider setClientSessionCode(String accessCode);
|
||||
|
|
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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<>();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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<FrontChannelLogoutHandler.ClientInfo> getClients() {
|
||||
return logoutInfo.getClients();
|
||||
}
|
||||
|
||||
}
|
|
@ -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<ClientInfo> 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<ClientInfo> 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<SecureLogoutExecutor.Configuration> {
|
||||
|
||||
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<Configuration> 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");
|
||||
}
|
||||
}
|
|
@ -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<ProviderConfigProperty> getConfigProperties() {
|
||||
return Collections.singletonList(ALLOW_FRONT_CHANNEL_LOGOUT_PROPERTY);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -13,3 +13,4 @@ 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
|
||||
org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory
|
|
@ -59,6 +59,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
|
|||
private KeycloakSession session;
|
||||
|
||||
private final BlockingQueue<LogoutAction> adminLogoutActions;
|
||||
private final BlockingQueue<LogoutToken> frontChannelLogoutTokens;
|
||||
private final BlockingQueue<LogoutToken> backChannelLogoutTokens;
|
||||
private final BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions;
|
||||
private final BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction;
|
||||
|
@ -72,6 +73,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
|
|||
|
||||
public TestApplicationResourceProvider(KeycloakSession session, BlockingQueue<LogoutAction> adminLogoutActions,
|
||||
BlockingQueue<LogoutToken> backChannelLogoutTokens,
|
||||
BlockingQueue<LogoutToken> frontChannelLogoutTokens,
|
||||
BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions,
|
||||
BlockingQueue<TestAvailabilityAction> 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")
|
||||
|
|
|
@ -47,6 +47,7 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv
|
|||
|
||||
private BlockingQueue<LogoutAction> adminLogoutActions = new LinkedBlockingDeque<>();
|
||||
private BlockingQueue<LogoutToken> backChannelLogoutTokens = new LinkedBlockingDeque<>();
|
||||
private BlockingQueue<LogoutToken> frontChannelLogoutTokens = new LinkedBlockingDeque<>();
|
||||
private BlockingQueue<PushNotBeforeAction> pushNotBeforeActions = new LinkedBlockingDeque<>();
|
||||
private BlockingQueue<TestAvailabilityAction> 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);
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -271,13 +271,20 @@
|
|||
</div>
|
||||
<kc-tooltip>{{:: 'force-post-binding.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
<div class="form-group clearfix block" data-ng-show="protocol == 'saml'">
|
||||
<div class="form-group clearfix block" data-ng-show="protocol == 'saml' || protocol == 'openid-connect'">
|
||||
<label class="col-md-2 control-label" for="frontchannelLogout">{{:: 'front-channel-logout' | translate}}</label>
|
||||
<div class="col-sm-6">
|
||||
<input ng-model="clientEdit.frontchannelLogout" name="frontchannelLogout" id="frontchannelLogout" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
|
||||
</div>
|
||||
<kc-tooltip>{{:: 'front-channel-logout.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
<div class="form-group" data-ng-show="protocol == 'openid-connect' && clientEdit.frontchannelLogout">
|
||||
<label class="col-md-2 control-label" for="frontchannelLogoutUrl">{{:: 'front-channel-logout-url' | translate}}</label>
|
||||
<div class="col-sm-6">
|
||||
<input class="form-control" type="text" name="frontchannelLogoutUrl" id="frontchannelLogoutUrl" data-ng-model="clientEdit.attributes['frontchannel.logout.url']">
|
||||
</div>
|
||||
<kc-tooltip>{{:: 'front-channel-logout-url.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
<div class="form-group clearfix block" data-ng-show="protocol == 'saml'">
|
||||
<label class="col-md-2 control-label" for="samlForceNameIdFormat">{{:: 'force-name-id-format' | translate}}</label>
|
||||
<div class="col-sm-6">
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout; section>
|
||||
<#if section = "header">
|
||||
<script>
|
||||
document.title = "${msg("frontchannel-logout.title")}";
|
||||
</script>
|
||||
${msg("frontchannel-logout.title")}
|
||||
<#elseif section = "form">
|
||||
<p>${msg("frontchannel-logout.message")}</p>
|
||||
<ul>
|
||||
<#list logout.clients as client>
|
||||
<li>
|
||||
${client.name}
|
||||
<iframe src="${client.frontChannelLogoutUrl}" style="display:none;"></iframe>
|
||||
</li>
|
||||
</#list>
|
||||
</ul>
|
||||
<#if logout.logoutRedirectUri?has_content>
|
||||
<script>
|
||||
function readystatechange(event) {
|
||||
if (document.readyState=='complete') {
|
||||
window.location.replace('${logout.logoutRedirectUri}');
|
||||
}
|
||||
}
|
||||
document.addEventListener('readystatechange', readystatechange);
|
||||
</script>
|
||||
<a id="continue" class="btn btn-primary" href="${logout.logoutRedirectUri}">${msg("doContinue")}</a>
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue