Skip to content

Commit

Permalink
OIDC RP-Initiated logout endpoint (keycloak#10887)
Browse files Browse the repository at this point in the history
* OIDC RP-Initiated logout endpoint
Closes keycloak#10885

Co-Authored-By: Marek Posolda <mposolda@gmail.com>

* Review feedback

Co-authored-by: Douglas Palmer <dpalmer@redhat.com>
  • Loading branch information
mposolda and douglaspalmer authored Mar 30, 2022
1 parent da5db5a commit 22a16ee
Show file tree
Hide file tree
Showing 104 changed files with 2,256 additions and 842 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public PublicKey getPublicKey(String kid, KeycloakDeployment deployment) {
sendRequest(deployment);
lastRequestTime = currentTime;
} else {
log.debug("Won't send request to realm jwks url. Last request time was " + lastRequestTime);
log.debugf("Won't send request to realm jwks url. Last request time was %d. Current time is %d.", lastRequestTime, currentTime);
}

return lookupCachedKey(publicKeyCacheTtl, currentTime, kid);
Expand All @@ -76,6 +76,7 @@ public void reset(KeycloakDeployment deployment) {
synchronized (this) {
sendRequest(deployment);
lastRequestTime = Time.currentTime();
log.debugf("Reset time offset to %d.", lastRequestTime);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ private void logoutDesktop() throws IOException, URISyntaxException, Interrupted

// pass the id_token_hint so that sessions is invalidated for this particular session
String logoutUrl = deployment.getLogoutUrl().clone()
.queryParam(OAuth2Constants.REDIRECT_URI, redirectUri)
.queryParam(OAuth2Constants.POST_LOGOUT_REDIRECT_URI, redirectUri)
.queryParam("id_token_hint", idTokenString)
.build().toString();

Expand Down
3 changes: 2 additions & 1 deletion adapters/oidc/js/src/keycloak.js
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,8 @@ function Keycloak (config) {

kc.createLogoutUrl = function(options) {
var url = kc.endpoints.logout()
+ '?redirect_uri=' + encodeURIComponent(adapter.redirectUri(options, false));
+ '?post_logout_redirect_uri=' + encodeURIComponent(adapter.redirectUri(options, false))
+ '&id_token_hint=' + encodeURIComponent(kc.idToken);

return url;
}
Expand Down
4 changes: 4 additions & 0 deletions core/src/main/java/org/keycloak/OAuth2Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ public interface OAuth2Constants {

String REDIRECT_URI = "redirect_uri";

String POST_LOGOUT_REDIRECT_URI = "post_logout_redirect_uri";

String ID_TOKEN_HINT = "id_token_hint";

String DISPLAY = "display";

String SCOPE = "scope";
Expand Down
2 changes: 1 addition & 1 deletion examples/kerberos/src/main/webapp/index.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<%
String logoutUri = KeycloakUriBuilder.fromUri("/auth").path(ServiceUrlConstants.TOKEN_SERVICE_LOGOUT_PATH)
.queryParam("redirect_uri", "/kerberos-portal").build("kerberos-demo").toString();
.build("kerberos-demo").toString();
%>
<b>Details about user from LDAP</b> | <a href="<%=logoutUri%>">Logout</a><br />
<hr />
Expand Down
2 changes: 1 addition & 1 deletion examples/ldap/src/main/webapp/index.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

<%
String logoutUri = KeycloakUriBuilder.fromUri("/auth").path(ServiceUrlConstants.TOKEN_SERVICE_LOGOUT_PATH)
.queryParam("redirect_uri", "/ldap-portal").build("ldap-demo").toString();
.build("ldap-demo").toString();
KeycloakSecurityContext securityContext = (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
IDToken idToken = securityContext.getIdToken();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ public interface AccountProvider extends Provider {

AccountProvider setStateChecker(String stateChecker);

AccountProvider setIdTokenHint(String idTokenHint);

AccountProvider setFeatures(boolean social, boolean events, boolean passwordUpdateSupported, boolean authorizationSupported);

AccountProvider setAttribute(String key, String value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ public enum LoginFormsPages {
LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM,
LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE, UPDATE_USER_PROFILE, IDP_REVIEW_USER_PROFILE,
LOGIN_RECOVERY_AUTHN_CODES_INPUT, LOGIN_RECOVERY_AUTHN_CODES_CONFIG,
FRONTCHANNEL_LOGOUT;
FRONTCHANNEL_LOGOUT, LOGOUT_CONFIRM;

}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ public interface LoginFormsProvider extends Provider {

Response createFrontChannelLogoutPage();

Response createLogoutConfirmPage();

LoginFormsProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession);

LoginFormsProvider setClientSessionCode(String accessCode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,15 @@ enum Error {

Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession);
Response frontchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession);
Response finishLogout(UserSessionModel userSession);

/**
* This method is called when browser logout is going to be finished. It is not triggered during backchannel logout
*
* @param userSession user session, which was logged out
* @param logoutSession authentication session, which was used during logout to track the logout state
* @return response to be sent to the client
*/
Response finishBrowserLogout(UserSessionModel userSession, AuthenticationSessionModel logoutSession);

/**
* @param userSession
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public class FreeMarkerAccountProvider implements AccountProvider {
protected String[] referrer;
protected List<Event> events;
protected String stateChecker;
protected String idTokenHint;
protected List<UserSessionModel> sessions;
protected boolean identityProviderEnabled;
protected boolean eventsEnabled;
Expand Down Expand Up @@ -151,7 +152,7 @@ public Response createResponse(AccountPages page) {
attributes.put("realm", new RealmBean(realm));
}

attributes.put("url", new UrlBean(realm, theme, baseUri, baseQueryUri, uriInfo.getRequestUri(), stateChecker));
attributes.put("url", new UrlBean(realm, theme, baseUri, baseQueryUri, uriInfo.getRequestUri(), idTokenHint));

if (realm.isInternationalizationEnabled()) {
UriBuilder b = UriBuilder.fromUri(baseQueryUri).path(uriInfo.getPath());
Expand Down Expand Up @@ -369,6 +370,12 @@ public AccountProvider setStateChecker(String stateChecker) {
return this;
}

@Override
public AccountProvider setIdTokenHint(String idTokenHint) {
this.idTokenHint = idTokenHint;
return this;
}

@Override
public AccountProvider setFeatures(boolean identityProviderEnabled, boolean eventsEnabled, boolean passwordUpdateSupported, boolean authorizationSupported) {
this.identityProviderEnabled = identityProviderEnabled;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ public class UrlBean {
private URI baseURI;
private URI baseQueryURI;
private URI currentURI;
private String idTokenHint;

public UrlBean(RealmModel realm, Theme theme, URI baseURI, URI baseQueryURI, URI currentURI, String stateChecker) {
public UrlBean(RealmModel realm, Theme theme, URI baseURI, URI baseQueryURI, URI currentURI, String idTokenHint) {
this.realm = realm.getName();
this.theme = theme;
this.baseURI = baseURI;
this.baseQueryURI = baseQueryURI;
this.currentURI = currentURI;
this.idTokenHint = idTokenHint;
}

public String getApplicationsUrl() {
Expand Down Expand Up @@ -71,7 +73,7 @@ public String getSessionsUrl() {
}

public String getLogoutUrl() {
return Urls.accountLogout(baseQueryURI, currentURI, realm).toString();
return Urls.accountLogout(baseQueryURI, currentURI, realm, idTokenHint).toString();
}

public String getResourceUrl() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
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.LogoutConfirmBean;
import org.keycloak.forms.login.freemarker.model.OAuthGrantBean;
import org.keycloak.forms.login.freemarker.model.ProfileBean;
import org.keycloak.forms.login.freemarker.model.RealmBean;
Expand Down Expand Up @@ -286,6 +287,9 @@ protected Response createResponse(LoginFormsPages page) {
case FRONTCHANNEL_LOGOUT:
attributes.put("logout", new FrontChannelLogoutBean(session));
break;
case LOGOUT_CONFIRM:
attributes.put("logoutConfirm", new LogoutConfirmBean(accessCode, authenticationSession));
break;
}

return processTemplate(theme, Templates.getTemplate(page), locale);
Expand Down Expand Up @@ -681,6 +685,11 @@ public Response createFrontChannelLogoutPage() {
return createResponse(LoginFormsPages.FRONTCHANNEL_LOGOUT);
}

@Override
public Response createLogoutConfirmPage() {
return createResponse(LoginFormsPages.LOGOUT_CONFIRM);
}

protected void setMessage(MessageType type, String message, Object... parameters) {
messageType = type;
messages = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ public static String getTemplate(LoginFormsPages page) {
return "idp-review-user-profile.ftl";
case FRONTCHANNEL_LOGOUT:
return "frontchannel-logout.ftl";
case LOGOUT_CONFIRM:
return "logout-confirm.ftl";
default:
throw new IllegalArgumentException();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2022 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.forms.login.freemarker.model;

import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.sessions.AuthenticationSessionModel;

/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class LogoutConfirmBean {

private final String code;
private final AuthenticationSessionModel logoutSession;

public LogoutConfirmBean(String code, AuthenticationSessionModel logoutSession) {
this.code = code;
this.logoutSession = logoutSession;
}

public String getCode() {
return code;
}

public boolean isSkipLink() {
return logoutSession == null || logoutSession.getClient().equals(SystemClientUtil.getSystemClient(logoutSession.getRealm()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ public String getFirstBrokerLoginUrl() {
return Urls.firstBrokerLoginProcessor(baseURI, realm).toString();
}

public String getLogoutConfirmAction() {
return Urls.logoutConfirm(baseURI, realm).toString();
}

public String getResourcesUrl() {
return Urls.themeRoot(baseURI).toString() + "/" + theme.getType().toString().toLowerCase() +"/" + theme.getName();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ public Response frontchannelLogout(final UserSessionModel userSession, final Aut
}

@Override
public Response finishLogout(final UserSessionModel userSession) {
public Response finishBrowserLogout(final UserSessionModel userSession, AuthenticationSessionModel logoutSession) {
return errorResponse(userSession, "finishLogout");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
import org.keycloak.protocol.oidc.utils.LogoutUtil;
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
Expand All @@ -52,6 +54,7 @@
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.services.managers.ResourceAdminManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.TokenUtil;

Expand All @@ -73,12 +76,12 @@ public class OIDCLoginProtocol implements LoginProtocol {

public static final String LOGIN_PROTOCOL = "openid-connect";
public static final String STATE_PARAM = "state";
public static final String LOGOUT_STATE_PARAM = "OIDC_LOGOUT_STATE_PARAM";
public static final String SCOPE_PARAM = "scope";
public static final String CODE_PARAM = "code";
public static final String RESPONSE_TYPE_PARAM = "response_type";
public static final String GRANT_TYPE_PARAM = "grant_type";
public static final String REDIRECT_URI_PARAM = "redirect_uri";
public static final String POST_LOGOUT_REDIRECT_URI_PARAM = "post_logout_redirect_uri";
public static final String CLIENT_ID_PARAM = "client_id";
public static final String NONCE_PARAM = "nonce";
public static final String MAX_AGE_PARAM = OAuth2Constants.MAX_AGE;
Expand All @@ -91,7 +94,11 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String ACR_PARAM = "acr_values";
public static final String ID_TOKEN_HINT = "id_token_hint";

public static final String LOGOUT_STATE_PARAM = "OIDC_LOGOUT_STATE_PARAM";
public static final String LOGOUT_REDIRECT_URI = "OIDC_LOGOUT_REDIRECT_URI";
public static final String LOGOUT_VALIDATED_ID_TOKEN_SESSION_STATE = "OIDC_LOGOUT_VALIDATED_ID_TOKEN_SESSION_STATE";
public static final String LOGOUT_VALIDATED_ID_TOKEN_ISSUED_AT = "OIDC_LOGOUT_VALIDATED_ID_TOKEN_ISSUED_AT";

public static final String ISSUER = "iss";

public static final String RESPONSE_MODE_PARAM = "response_mode";
Expand Down Expand Up @@ -350,28 +357,21 @@ public Response frontchannelLogout(UserSessionModel userSession, AuthenticatedCl
}

@Override
public Response finishLogout(UserSessionModel userSession) {
String redirectUri = userSession.getNote(OIDCLoginProtocol.LOGOUT_REDIRECT_URI);
String state = userSession.getNote(OIDCLoginProtocol.LOGOUT_STATE_PARAM);
public Response finishBrowserLogout(UserSessionModel userSession, AuthenticationSessionModel logoutSession) {
event.event(EventType.LOGOUT);

String redirectUri = logoutSession.getAuthNote(OIDCLoginProtocol.LOGOUT_REDIRECT_URI);
if (redirectUri != null) {
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)
uriBuilder.queryParam(STATE_PARAM, state);
return Response.status(302).location(uriBuilder.build()).build();
} else {
// TODO Empty content with ok makes no sense. Should it display a page? Or use noContent?
session.getProvider(SecurityHeadersProvider.class).options().allowEmptyContentType();
return Response.ok().build();
String finalRedirectUri = redirectUri == null ? null : LogoutUtil.getRedirectUriWithAttachedState(redirectUri, logoutSession).toString();
return frontChannelLogoutHandler.renderLogoutPage(finalRedirectUri);
}

return LogoutUtil.sendResponseAfterLogoutFinished(session, logoutSession);
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package org.keycloak.protocol.oidc;

import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.Profile;
import org.keycloak.common.constants.KerberosConstants;
Expand Down Expand Up @@ -102,6 +103,17 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
public static final String OFFLINE_ACCESS_SCOPE_CONSENT_TEXT = Constants.OFFLINE_ACCESS_SCOPE_CONSENT_TEXT;
public static final String ROLES_SCOPE_CONSENT_TEXT = "${rolesScopeConsentText}";

public static final String CONFIG_LEGACY_LOGOUT_REDIRECT_URI = "legacy-logout-redirect-uri";

private OIDCProviderConfig providerConfig;

@Override
public void init(Config.Scope config) {
this.providerConfig = new OIDCProviderConfig(config);
if (providerConfig.isLegacyLogoutRedirectUri()) {
logger.warnf("Deprecated switch '%s' is enabled. Please try to disable it and update your clients to use OpenID Connect compliant way for RP-initiated logout.", CONFIG_LEGACY_LOGOUT_REDIRECT_URI);
}
}

@Override
public LoginProtocol create(KeycloakSession session) {
Expand Down Expand Up @@ -379,7 +391,7 @@ protected void addDefaults(ClientModel client) {

@Override
public Object createProtocolEndpoint(RealmModel realm, EventBuilder event) {
return new OIDCLoginProtocolService(realm, event);
return new OIDCLoginProtocolService(realm, event, providerConfig);
}

@Override
Expand Down
Loading

0 comments on commit 22a16ee

Please sign in to comment.