[KEYCLOAK-17653] - OIDC Frontchannel logout support

This commit is contained in:
R Yamada 2021-10-06 19:02:58 -03:00 committed by Pedro Igor
parent 97ee8832a3
commit 891c8e1a12
29 changed files with 646 additions and 15 deletions

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -98,6 +98,8 @@ public interface LoginFormsProvider extends Provider {
Response createSamlPostForm();
Response createFrontChannelLogoutPage();
LoginFormsProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession);
LoginFormsProvider setClientSessionCode(String accessCode);

View file

@ -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<>();

View file

@ -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();
}

View file

@ -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();
}
}

View file

@ -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;
}
}
}

View file

@ -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);

View file

@ -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() {
}

View file

@ -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)

View file

@ -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;

View file

@ -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");
}
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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

View file

@ -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")

View file

@ -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);

View file

@ -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")

View file

@ -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 {

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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();
}

View file

@ -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

View file

@ -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) {

View file

@ -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">

View file

@ -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>

View file

@ -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