diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 2e13005d2e..580291a0e9 100644 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -50,6 +50,8 @@ public interface OAuth2Constants { String REFRESH_TOKEN = "refresh_token"; + String LOGOUT_TOKEN = "logout_token"; + String AUTHORIZATION_CODE = "authorization_code"; diff --git a/core/src/main/java/org/keycloak/TokenCategory.java b/core/src/main/java/org/keycloak/TokenCategory.java index 99c1cad03a..fb83321ca4 100644 --- a/core/src/main/java/org/keycloak/TokenCategory.java +++ b/core/src/main/java/org/keycloak/TokenCategory.java @@ -21,5 +21,6 @@ public enum TokenCategory { ACCESS, ID, ADMIN, - USERINFO + USERINFO, + LOGOUT } diff --git a/core/src/main/java/org/keycloak/jose/jws/JWSHeader.java b/core/src/main/java/org/keycloak/jose/jws/JWSHeader.java index 70ea783d7b..afb2a7eded 100755 --- a/core/src/main/java/org/keycloak/jose/jws/JWSHeader.java +++ b/core/src/main/java/org/keycloak/jose/jws/JWSHeader.java @@ -52,6 +52,12 @@ public class JWSHeader implements Serializable { this.contentType = contentType; } + public JWSHeader(Algorithm algorithm, String type, String contentType, String keyId) { + this.algorithm = algorithm; + this.type = type; + this.keyId = keyId; + } + public Algorithm getAlgorithm() { return algorithm; } diff --git a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java index 3b2cc9c11a..175d348ecb 100755 --- a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java +++ b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java @@ -127,6 +127,12 @@ public class OIDCConfigurationRepresentation { @JsonProperty("revocation_endpoint_auth_signing_alg_values_supported") private List revocationEndpointAuthSigningAlgValuesSupported; + @JsonProperty("backchannel_logout_supported") + private Boolean backchannelLogoutSupported; + + @JsonProperty("backchannel_logout_session_supported") + private Boolean backchannelLogoutSessionSupported; + protected Map otherClaims = new HashMap(); public String getIssuer() { @@ -380,6 +386,22 @@ public class OIDCConfigurationRepresentation { this.revocationEndpointAuthSigningAlgValuesSupported = revocationEndpointAuthSigningAlgValuesSupported; } + public Boolean getBackchannelLogoutSupported() { + return backchannelLogoutSupported; + } + + public Boolean getBackchannelLogoutSessionSupported() { + return backchannelLogoutSessionSupported; + } + + public void setBackchannelLogoutSessionSupported(Boolean backchannelLogoutSessionSupported) { + this.backchannelLogoutSessionSupported = backchannelLogoutSessionSupported; + } + + public void setBackchannelLogoutSupported(Boolean backchannelLogoutSupported) { + this.backchannelLogoutSupported = backchannelLogoutSupported; + } + @JsonAnyGetter public Map getOtherClaims() { return otherClaims; @@ -389,5 +411,4 @@ public class OIDCConfigurationRepresentation { public void setOtherClaims(String name, Object value) { otherClaims.put(name, value); } - } diff --git a/core/src/main/java/org/keycloak/representations/LogoutToken.java b/core/src/main/java/org/keycloak/representations/LogoutToken.java new file mode 100644 index 0000000000..cc665d43f8 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/LogoutToken.java @@ -0,0 +1,44 @@ +package org.keycloak.representations; + +import org.keycloak.TokenCategory; +import org.keycloak.util.TokenUtil; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.HashMap; +import java.util.Map; + +public class LogoutToken extends JsonWebToken { + + @JsonProperty("sid") + protected String sid; + + @JsonProperty("events") + protected Map events = new HashMap<>(); + + public Map getEvents() { + return events; + } + + public void putEvents(String name, Object value) { + events.put(name, value); + } + + public String getSid() { + return sid; + } + + public LogoutToken setSid(String sid) { + this.sid = sid; + return this; + } + + public LogoutToken() { + type(TokenUtil.TOKEN_TYPE_LOGOUT); + } + + @Override + public TokenCategory getCategory() { + return TokenCategory.LOGOUT; + } +} diff --git a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java index 9b81228502..6d81e142fb 100644 --- a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java @@ -119,6 +119,10 @@ public class OIDCClientRepresentation { private String registration_access_token; + private String backchannel_logout_uri; + + private Boolean backchannel_logout_session_required; + public List getRedirectUris() { return redirect_uris; } @@ -449,6 +453,22 @@ public class OIDCClientRepresentation { this.tls_client_certificate_bound_access_tokens = tls_client_certificate_bound_access_tokens; } + public String getBackchannelLogoutUri() { + return backchannel_logout_uri; + } + + public void setBackchannelLogoutUri(String backchannel_logout_uri) { + this.backchannel_logout_uri = backchannel_logout_uri; + } + + public Boolean getBackchannelLogoutSessionRequired() { + return backchannel_logout_session_required; + } + + public void setBackchannelLogoutSessionRequired(Boolean backchannel_logout_session_required) { + this.backchannel_logout_session_required = backchannel_logout_session_required; + } + public String getTlsClientAuthSubjectDn() { return tls_client_auth_subject_dn; } diff --git a/core/src/main/java/org/keycloak/util/TokenUtil.java b/core/src/main/java/org/keycloak/util/TokenUtil.java index d2bae90098..96edcfdb16 100644 --- a/core/src/main/java/org/keycloak/util/TokenUtil.java +++ b/core/src/main/java/org/keycloak/util/TokenUtil.java @@ -48,6 +48,9 @@ public class TokenUtil { public static final String TOKEN_TYPE_OFFLINE = "Offline"; + public static final String TOKEN_TYPE_LOGOUT = "Logout"; + + public static final String TOKEN_BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; public static String attachOIDCScope(String scopeParam) { if (scopeParam == null || scopeParam.isEmpty()) { diff --git a/server-spi-private/src/main/java/org/keycloak/events/Errors.java b/server-spi-private/src/main/java/org/keycloak/events/Errors.java index 163da04c72..4653afdad4 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Errors.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Errors.java @@ -93,5 +93,6 @@ public interface Errors { String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider"; String ILLEGAL_ORIGIN = "illegal_origin"; String DISPLAY_UNSUPPORTED = "display_unsupported"; + String LOGOUT_FAILED = "logout_failed"; } diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocol.java b/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocol.java index 849859ec4b..ca977f801b 100755 --- a/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocol.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocol.java @@ -80,7 +80,7 @@ public interface LoginProtocol extends Provider { Response sendError(AuthenticationSessionModel authSession, Error error); - void backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession); + Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession); Response frontchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession); Response finishLogout(UserSessionModel userSession); diff --git a/server-spi/src/main/java/org/keycloak/models/ClientModel.java b/server-spi/src/main/java/org/keycloak/models/ClientModel.java index c470275d09..a7a6112608 100755 --- a/server-spi/src/main/java/org/keycloak/models/ClientModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientModel.java @@ -103,7 +103,6 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot void setBaseUrl(String url); - boolean isBearerOnly(); void setBearerOnly(boolean only); diff --git a/server-spi/src/main/java/org/keycloak/models/TokenManager.java b/server-spi/src/main/java/org/keycloak/models/TokenManager.java index eb0bc7398e..7d99675f71 100644 --- a/server-spi/src/main/java/org/keycloak/models/TokenManager.java +++ b/server-spi/src/main/java/org/keycloak/models/TokenManager.java @@ -18,6 +18,7 @@ package org.keycloak.models; import org.keycloak.Token; import org.keycloak.TokenCategory; +import org.keycloak.representations.LogoutToken; public interface TokenManager { @@ -46,4 +47,6 @@ public interface TokenManager { String encodeAndEncrypt(Token token); String cekManagementAlgorithm(TokenCategory category); String encryptAlgorithm(TokenCategory category); + + LogoutToken initLogoutToken(ClientModel client, UserModel user, AuthenticatedClientSessionModel clientSessionModel); } diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index a2eb07c14a..fcba176717 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -530,7 +530,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider params) { if (!supportsExternalExchange()) return false; - String requestedIssuer = params.getFirst(OAuth2Constants.SUBJECT_ISSUER); + String requestedIssuer = params == null ? null : params.getFirst(OAuth2Constants.SUBJECT_ISSUER); if (requestedIssuer == null) requestedIssuer = issuer; if (requestedIssuer.equals(getConfig().getAlias())) return true; diff --git a/services/src/main/java/org/keycloak/jose/jws/DefaultTokenManager.java b/services/src/main/java/org/keycloak/jose/jws/DefaultTokenManager.java index e99d4868c1..d2c548e071 100644 --- a/services/src/main/java/org/keycloak/jose/jws/DefaultTokenManager.java +++ b/services/src/main/java/org/keycloak/jose/jws/DefaultTokenManager.java @@ -16,9 +16,6 @@ */ package org.keycloak.jose.jws; -import java.io.UnsupportedEncodingException; -import java.security.Key; - import org.jboss.logging.Logger; import org.keycloak.Token; import org.keycloak.TokenCategory; @@ -35,13 +32,23 @@ import org.keycloak.jose.jwe.alg.JWEAlgorithmProvider; import org.keycloak.jose.jwe.enc.JWEEncryptionProvider; import org.keycloak.jose.jwk.JWK; import org.keycloak.keys.loader.PublicKeyStorageManager; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.TokenManager; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.LogoutToken; +import org.keycloak.util.JsonSerialization; import org.keycloak.util.TokenUtil; +import java.io.UnsupportedEncodingException; +import java.security.Key; + public class DefaultTokenManager implements TokenManager { private static final Logger logger = Logger.getLogger(DefaultTokenManager.class); @@ -129,6 +136,7 @@ public class DefaultTokenManager implements TokenManager { case ACCESS: return getSignatureAlgorithm(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG); case ID: + case LOGOUT: return getSignatureAlgorithm(OIDCConfigAttributes.ID_TOKEN_SIGNED_RESPONSE_ALG); case USERINFO: return getSignatureAlgorithm(OIDCConfigAttributes.USER_INFO_RESPONSE_SIGNATURE_ALG); @@ -202,6 +210,7 @@ public class DefaultTokenManager implements TokenManager { if (category == null) return null; switch (category) { case ID: + case LOGOUT: return getCekManagementAlgorithm(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ALG); default: return null; @@ -222,6 +231,7 @@ public class DefaultTokenManager implements TokenManager { if (category == null) return null; switch (category) { case ID: + case LOGOUT: return getEncryptAlgorithm(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ENC); default: return null; @@ -236,4 +246,21 @@ public class DefaultTokenManager implements TokenManager { } return null; } + + public LogoutToken initLogoutToken(ClientModel client, UserModel user, + AuthenticatedClientSessionModel clientSession) { + LogoutToken token = new LogoutToken(); + token.id(KeycloakModelUtils.generateId()); + token.issuedNow(); + token.issuer(clientSession.getNote(OIDCLoginProtocol.ISSUER)); + token.putEvents(TokenUtil.TOKEN_BACKCHANNEL_LOGOUT_EVENT, JsonSerialization.createObjectNode()); + token.addAudience(client.getClientId()); + + if (OIDCAdvancedConfigWrapper.fromClientModel(client).isBackchannelLogoutSessionRequired()){ + token.setSid(clientSession.getUserSession().getId()); + } + token.setSubject(user.getId()); + + return token; + } } diff --git a/services/src/main/java/org/keycloak/protocol/docker/DockerAuthV2Protocol.java b/services/src/main/java/org/keycloak/protocol/docker/DockerAuthV2Protocol.java index 0207f525ca..1b035fbec0 100644 --- a/services/src/main/java/org/keycloak/protocol/docker/DockerAuthV2Protocol.java +++ b/services/src/main/java/org/keycloak/protocol/docker/DockerAuthV2Protocol.java @@ -156,9 +156,8 @@ public class DockerAuthV2Protocol implements LoginProtocol { } @Override - public void backchannelLogout(final UserSessionModel userSession, final AuthenticatedClientSessionModel clientSession) { - errorResponse(userSession, "backchannelLogout"); - + public Response backchannelLogout(final UserSessionModel userSession, final AuthenticatedClientSessionModel clientSession) { + return errorResponse(userSession, "backchannelLogout"); } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oidc/BackchannelLogoutResponse.java b/services/src/main/java/org/keycloak/protocol/oidc/BackchannelLogoutResponse.java new file mode 100644 index 0000000000..2710f60495 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/BackchannelLogoutResponse.java @@ -0,0 +1,49 @@ +package org.keycloak.protocol.oidc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class BackchannelLogoutResponse { + + private boolean localLogoutSucceeded; + private List clientResponses = new ArrayList<>(); + + public List getClientResponses() { + return clientResponses; + } + + public void addClientResponses(DownStreamBackchannelLogoutResponse clientResponse) { + this.clientResponses.add(clientResponse); + } + + public boolean getLocalLogoutSucceeded() { + return localLogoutSucceeded; + } + + public void setLocalLogoutSucceeded(boolean localLogoutSucceeded) { + this.localLogoutSucceeded = localLogoutSucceeded; + } + + public static class DownStreamBackchannelLogoutResponse { + protected boolean withBackchannelLogoutUrl; + protected Integer responseCode; + + public boolean isWithBackchannelLogoutUrl() { + return withBackchannelLogoutUrl; + } + + public void setWithBackchannelLogoutUrl(boolean withBackchannelLogoutUrl) { + this.withBackchannelLogoutUrl = withBackchannelLogoutUrl; + } + + public Optional getResponseCode() { + return Optional.ofNullable(responseCode); + } + + public void setResponseCode(Integer responseCode) { + this.responseCode = responseCode; + } + } +} + diff --git a/services/src/main/java/org/keycloak/protocol/oidc/LogoutTokenValidationCode.java b/services/src/main/java/org/keycloak/protocol/oidc/LogoutTokenValidationCode.java new file mode 100644 index 0000000000..a247ea4838 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/LogoutTokenValidationCode.java @@ -0,0 +1,24 @@ +package org.keycloak.protocol.oidc; + +public enum LogoutTokenValidationCode { + + VALIDATION_SUCCESS(""), + DECODE_TOKEN_FAILED("The decode of the logoutToken failed"), + COULD_NOT_FIND_IDP("No Identity Provider has been found"), + TOKEN_VERIFICATION_WITH_IDP_FAILED("LogoutToken verification with identity provider failed"), + MISSING_SID_OR_SUBJECT("Missing sid or sub claim"), + BACKCHANNEL_LOGOUT_EVENT_MISSING("The LogoutToken event claim is not as expected"), + NONCE_CLAIM_IN_TOKEN("The LogoutToken contains a nonce claim which is not allowed"), + MISSING_IAT_CLAIM("The LogoutToken doesn't contain an iat claim"), + LOGOUT_TOKEN_ID_MISSING("The logoutToken jti is missing"); + + private String errorMessage; + + LogoutTokenValidationCode(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java index 78572be1e0..f7b1bbfaeb 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -166,6 +166,24 @@ public class OIDCAdvancedConfigWrapper { setAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, algName); } + public String getBackchannelLogoutUrl() { + return getAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL); + } + + public void setBackchannelLogoutUrl(String backchannelLogoutUrl) { + setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, backchannelLogoutUrl); + } + + public boolean isBackchannelLogoutSessionRequired() { + String backchannelLogoutSessionRequired = getAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_SESSION_REQUIRED); + return Boolean.parseBoolean(backchannelLogoutSessionRequired); + } + + public void setBackchannelLogoutSessionRequired(boolean backchannelLogoutSessionRequired) { + String val = String.valueOf(backchannelLogoutSessionRequired); + setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_SESSION_REQUIRED, val); + } + private String getAttribute(String attrKey) { if (clientModel != null) { return clientModel.getAttribute(attrKey); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java index aa2b695eec..31ccf5e055 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -52,6 +52,10 @@ public final class OIDCConfigAttributes { public static final String TOKEN_ENDPOINT_AUTH_SIGNING_ALG = "token.endpoint.auth.signing.alg"; + public static final String BACKCHANNEL_LOGOUT_URL = "backchannel.logout.url"; + + public static final String BACKCHANNEL_LOGOUT_SESSION_REQUIRED = "backchannel.logout.session.required"; + private OIDCConfigAttributes() { } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index 092744471e..9ce31ed4bd 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java @@ -29,7 +29,6 @@ import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.headers.SecurityHeadersProvider; import org.keycloak.models.AuthenticatedClientSessionModel; -import org.keycloak.models.BrowserSecurityHeaders; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.Constants; @@ -50,7 +49,6 @@ import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.services.managers.ResourceAdminManager; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.TokenUtil; -import org.keycloak.utils.MediaType; import java.io.IOException; import java.net.URI; @@ -312,9 +310,13 @@ public class OIDCLoginProtocol implements LoginProtocol { } @Override - public void backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { + public Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { ClientModel client = clientSession.getClient(); - new ResourceAdminManager(session).logoutClientSession(realm, client, clientSession); + if (OIDCAdvancedConfigWrapper.fromClientModel(clientSession.getClient()).getBackchannelLogoutUrl() != null) { + return new ResourceAdminManager(session).logoutClientSessionWithBackchannelLogoutUrl(client, clientSession); + } else { + return new ResourceAdminManager(session).logoutClientSession(realm, client, clientSession); + } } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java index efa4caa227..48658d8efa 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java @@ -391,6 +391,10 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory { if (rep.isImplicitFlowEnabled() == null) newClient.setImplicitFlowEnabled(false); if (rep.isPublicClient() == null) newClient.setPublicClient(true); if (rep.isFrontchannelLogout() == null) newClient.setFrontchannelLogout(false); + if (OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).getBackchannelLogoutUrl() == null){ + OIDCAdvancedConfigWrapper oidcAdvancedConfigWrapper = OIDCAdvancedConfigWrapper.fromClientModel(newClient); + oidcAdvancedConfigWrapper.setBackchannelLogoutSessionRequired(true); + } } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java index 58751436ff..b59c7d30b0 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java @@ -17,6 +17,7 @@ package org.keycloak.protocol.oidc; +import org.jboss.logging.Logger; import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.ResteasyProviderFactory; @@ -46,6 +47,9 @@ import org.keycloak.services.resources.Cors; import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.util.CacheControlUtil; +import java.util.LinkedList; +import java.util.List; + import javax.ws.rs.GET; import javax.ws.rs.NotFoundException; import javax.ws.rs.OPTIONS; @@ -59,8 +63,6 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriInfo; -import java.util.LinkedList; -import java.util.List; /** * Resource class for the oauth/openid connect token service @@ -70,6 +72,8 @@ import java.util.List; */ public class OIDCLoginProtocolService { + private static final Logger logger = Logger.getLogger(OIDCLoginProtocolService.class); + private RealmModel realm; private TokenManager tokenManager; private EventBuilder event; @@ -240,6 +244,8 @@ public class OIDCLoginProtocolService { return endpoint; } + /* old deprecated logout endpoint needs to be removed in the future + * https://issues.redhat.com/browse/KEYCLOAK-2940 */ @Path("logout") public Object logout() { LogoutEndpoint endpoint = new LogoutEndpoint(tokenManager, realm, event); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index 66168507e6..aa5f09ec6a 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -143,6 +143,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider { config.setRevocationEndpointAuthSigningAlgValuesSupported(getSupportedClientSigningAlgorithms(false)); } + config.setBackchannelLogoutSupported(true); + config.setBackchannelLogoutSessionSupported(true); + return config; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 976a1422aa..fcbe3ae23a 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -21,8 +21,13 @@ import org.jboss.logging.Logger; import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; +import org.keycloak.Token; import org.keycloak.TokenCategory; import org.keycloak.TokenVerifier; +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.provider.IdentityBrokerException; +import org.keycloak.broker.provider.IdentityProvider; +import org.keycloak.broker.provider.IdentityProviderFactory; import org.keycloak.cluster.ClusterProvider; import org.keycloak.common.ClientConnection; import org.keycloak.common.VerificationException; @@ -32,6 +37,7 @@ import org.keycloak.crypto.SignatureProvider; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; +import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; import org.keycloak.jose.jws.crypto.HashUtils; import org.keycloak.migration.migrators.MigrationUtils; @@ -39,6 +45,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; @@ -54,33 +61,43 @@ import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper; import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper; +import org.keycloak.protocol.oidc.utils.OAuth2Code; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.LogoutToken; import org.keycloak.representations.RefreshToken; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.UserSessionCrossDCManager; import org.keycloak.services.managers.UserSessionManager; +import org.keycloak.services.resources.IdentityBrokerService; import org.keycloak.services.util.DefaultClientSessionContext; import org.keycloak.services.util.MtlsHoKTokenUtil; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.TokenUtil; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriInfo; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; -import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +import static org.keycloak.representations.IDToken.NONCE; + /** * Stateless object that creates tokens and manages oauth access codes * @@ -1070,4 +1087,103 @@ public class TokenManager { return new NotBeforeCheck(session.users().getNotBeforeOfUser(realmModel, userModel)); } } -} + + public LogoutTokenValidationCode verifyLogoutToken(KeycloakSession session, RealmModel realm, String encodedLogoutToken) { + Optional logoutTokenOptional = toLogoutToken(encodedLogoutToken); + if (!logoutTokenOptional.isPresent()) { + return LogoutTokenValidationCode.DECODE_TOKEN_FAILED; + } + + LogoutToken logoutToken = logoutTokenOptional.get(); + List identityProviders = getOIDCIdentityProviders(realm, session); + if (identityProviders.isEmpty()) { + return LogoutTokenValidationCode.COULD_NOT_FIND_IDP; + } + + List validOidcIdentityProviders = validateLogoutTokenAgainstIdpProvider(identityProviders, encodedLogoutToken, logoutToken); + if (validOidcIdentityProviders.isEmpty()) { + return LogoutTokenValidationCode.TOKEN_VERIFICATION_WITH_IDP_FAILED; + } + + if (logoutToken.getSubject() == null && logoutToken.getSid() == null) { + return LogoutTokenValidationCode.MISSING_SID_OR_SUBJECT; + } + + if (!checkLogoutTokenForEvents(logoutToken)) { + return LogoutTokenValidationCode.BACKCHANNEL_LOGOUT_EVENT_MISSING; + } + + if (logoutToken.getOtherClaims().get(NONCE) != null) { + return LogoutTokenValidationCode.NONCE_CLAIM_IN_TOKEN; + } + + if (logoutToken.getId() == null) { + return LogoutTokenValidationCode.LOGOUT_TOKEN_ID_MISSING; + } + + if (logoutToken.getIat() == null) { + return LogoutTokenValidationCode.MISSING_IAT_CLAIM; + } + + return LogoutTokenValidationCode.VALIDATION_SUCCESS; + } + + public Optional toLogoutToken(String encodedLogoutToken) { + try { + JWSInput jws = new JWSInput(encodedLogoutToken); + return Optional.of(jws.readJsonContent(LogoutToken.class)); + } catch (JWSInputException e) { + return Optional.empty(); + } + } + + + public List getValidOIDCIdentityProvidersForBackchannelLogout(RealmModel realm, KeycloakSession session, String encodedLogoutToken, LogoutToken logoutToken) { + List identityProviders = getOIDCIdentityProviders(realm, session); + return validateLogoutTokenAgainstIdpProvider(identityProviders, encodedLogoutToken, logoutToken); + } + + + public List validateLogoutTokenAgainstIdpProvider(List oidcIdps, String encodedLogoutToken, LogoutToken logoutToken) { + List validIdps = new ArrayList<>(); + for (OIDCIdentityProvider oidcIdp : oidcIdps) { + if (oidcIdp.getConfig().getIssuer() != null) { + if (oidcIdp.isIssuer(logoutToken.getIssuer(), null)) { + try { + oidcIdp.validateToken(encodedLogoutToken); + validIdps.add(oidcIdp); + } catch (IdentityBrokerException e) { + logger.debugf("LogoutToken verification with identity provider failed", e.getMessage()); + } + } + } + } + return validIdps; + } + + private List getOIDCIdentityProviders(RealmModel realm, KeycloakSession session) { + List availableProviders = new ArrayList<>(); + try { + for (IdentityProviderModel idpModel : realm.getIdentityProviders()) { + IdentityProviderFactory factory = IdentityBrokerService.getIdentityProviderFactory(session, idpModel); + IdentityProvider identityProvider = factory.create(session, idpModel); + if (identityProvider instanceof OIDCIdentityProvider) { + availableProviders.add(((OIDCIdentityProvider) identityProvider)); + } + } + } catch (IdentityBrokerException e) { + logger.warnf("LogoutToken verification with identity provider failed", e.getMessage()); + } + return availableProviders; + } + + private boolean checkLogoutTokenForEvents(LogoutToken logoutToken) { + for (String eventKey : logoutToken.getEvents().keySet()) { + if (TokenUtil.TOKEN_BACKCHANNEL_LOGOUT_EVENT.equals(eventKey)) { + return true; + } + } + return false; + } + +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java index c94d2dba18..b3f515fbc5 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java @@ -34,11 +34,14 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.BackchannelLogoutResponse; +import org.keycloak.protocol.oidc.LogoutTokenValidationCode; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.representations.IDToken; +import org.keycloak.representations.LogoutToken; import org.keycloak.representations.RefreshToken; import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorResponseException; @@ -52,16 +55,15 @@ import org.keycloak.services.resources.Cors; import org.keycloak.services.util.MtlsHoKTokenUtil; import org.keycloak.util.TokenUtil; -import javax.ws.rs.Consumes; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.QueryParam; +import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; +import java.util.List; +import java.util.stream.Collectors; /** * @author Stian Thorgersen @@ -235,6 +237,145 @@ public class LogoutEndpoint { return Cors.add(request, Response.noContent()).auth().allowedOrigins(session, client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build(); } + /** + * Backchannel logout endpoint implementation for Keycloak, which tries to logout the user from all sessions via + * POST with a valid LogoutToken. + * + * Logout a session via a non-browser invocation. Will be implemented as a backchannel logout based on the + * specification + * https://openid.net/specs/openid-connect-backchannel-1_0.html + * + * @return + */ + @Path("/backchannel-logout") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response backchannelLogout() { + MultivaluedMap form = request.getDecodedFormParameters(); + event.event(EventType.LOGOUT); + + String encodedLogoutToken = form.getFirst(OAuth2Constants.LOGOUT_TOKEN); + if (encodedLogoutToken == null) { + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "No logout token", + Response.Status.BAD_REQUEST); + } + + LogoutTokenValidationCode validationCode = tokenManager.verifyLogoutToken(session, realm, encodedLogoutToken); + if (!validationCode.equals(LogoutTokenValidationCode.VALIDATION_SUCCESS)) { + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, validationCode.getErrorMessage(), + Response.Status.BAD_REQUEST); + } + + LogoutToken logoutToken = tokenManager.toLogoutToken(encodedLogoutToken).get(); + + List identityProviderAliases = tokenManager.getValidOIDCIdentityProvidersForBackchannelLogout(realm, + session, encodedLogoutToken, logoutToken).stream() + .map(idp -> idp.getConfig().getAlias()) + .collect(Collectors.toList()); + + BackchannelLogoutResponse backchannelLogoutResponse; + + if (logoutToken.getSid() != null) { + backchannelLogoutResponse = backchannelLogoutWithSessionId(logoutToken.getSid(), identityProviderAliases); + } else { + backchannelLogoutResponse = + backchannelLogoutFederatedUserId(logoutToken.getSubject(), identityProviderAliases); + } + + if (!backchannelLogoutResponse.getLocalLogoutSucceeded()) { + event.error(Errors.LOGOUT_FAILED); + throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR, + "There was an error in the local logout", + Response.Status.NOT_IMPLEMENTED); + } + + session.getProvider(SecurityHeadersProvider.class).options().allowEmptyContentType(); + + if (oneOrMoreDownstreamLogoutsFailed(backchannelLogoutResponse)) { + return Cors.add(request) + .auth() + .builder(Response.status(Response.Status.GATEWAY_TIMEOUT) + .type(MediaType.APPLICATION_JSON_TYPE)) + .build(); + } + + return Cors.add(request) + .auth() + .builder(Response.ok() + .type(MediaType.APPLICATION_JSON_TYPE)) + .build(); + } + + private BackchannelLogoutResponse backchannelLogoutWithSessionId(String sessionId, + List identityProviderAliases) { + BackchannelLogoutResponse backchannelLogoutResponse = new BackchannelLogoutResponse(); + backchannelLogoutResponse.setLocalLogoutSucceeded(true); + for (String identityProviderAlias : identityProviderAliases) { + UserSessionModel userSession = session.sessions().getUserSessionByBrokerSessionId(realm, + identityProviderAlias + "." + sessionId); + + if (userSession != null) { + backchannelLogoutResponse = logoutUserSession(userSession); + } + } + + return backchannelLogoutResponse; + } + + private BackchannelLogoutResponse backchannelLogoutFederatedUserId(String federatedUserId, + List identityProviderAliases) { + BackchannelLogoutResponse backchannelLogoutResponse = new BackchannelLogoutResponse(); + backchannelLogoutResponse.setLocalLogoutSucceeded(true); + for (String identityProviderAlias : identityProviderAliases) { + List userSessions = session.sessions().getUserSessionByBrokerUserId(realm, + identityProviderAlias + "." + federatedUserId); + + for (UserSessionModel userSession : userSessions) { + BackchannelLogoutResponse userBackchannelLogoutResponse; + userBackchannelLogoutResponse = logoutUserSession(userSession); + backchannelLogoutResponse.setLocalLogoutSucceeded(backchannelLogoutResponse.getLocalLogoutSucceeded() + && userBackchannelLogoutResponse.getLocalLogoutSucceeded()); + userBackchannelLogoutResponse.getClientResponses() + .forEach(backchannelLogoutResponse::addClientResponses); + } + } + + return backchannelLogoutResponse; + } + + private BackchannelLogoutResponse logoutUserSession(UserSessionModel userSession) { + BackchannelLogoutResponse backchannelLogoutResponse = + AuthenticationManager.backchannelLogout(session, realm, userSession, + session.getContext().getUri(), + clientConnection, headers, false); + + if (backchannelLogoutResponse.getLocalLogoutSucceeded()) { + event.user(userSession.getUser()) + .session(userSession) + .success(); + } + + return backchannelLogoutResponse; + } + + private boolean oneOrMoreDownstreamLogoutsFailed(BackchannelLogoutResponse backchannelLogoutResponse) { + BackchannelLogoutResponse filteredBackchannelLogoutResponse = new BackchannelLogoutResponse(); + for (BackchannelLogoutResponse.DownStreamBackchannelLogoutResponse response : backchannelLogoutResponse + .getClientResponses()) { + if (response.isWithBackchannelLogoutUrl()) { + filteredBackchannelLogoutResponse.addClientResponses(response); + } + } + + return backchannelLogoutResponse.getClientResponses().stream() + .filter(BackchannelLogoutResponse.DownStreamBackchannelLogoutResponse::isWithBackchannelLogoutUrl) + .anyMatch(clientResponse -> !(clientResponse.getResponseCode().isPresent() && + (clientResponse.getResponseCode().get() == Response.Status.OK.getStatusCode() || + clientResponse.getResponseCode().get() == Response.Status.NO_CONTENT.getStatusCode()))); + } + private void logout(UserSessionModel userSession, boolean offline) { AuthenticationManager.backchannelLogout(session, realm, userSession, session.getContext().getUri(), clientConnection, headers, true, offline); event.user(userSession.getUser()).session(userSession).success(); diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java index 7ad4f877bd..0640c35592 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java @@ -671,13 +671,14 @@ public class SamlProtocol implements LoginProtocol { } @Override - public void backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { + public Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { ClientModel client = clientSession.getClient(); SamlClient samlClient = new SamlClient(client); String logoutUrl = getLogoutServiceUrl(session, client, SAML_POST_BINDING); if (logoutUrl == null) { - logger.warnf("Can't do backchannel logout. No SingleLogoutService POST Binding registered for client: %s", client.getClientId()); - return; + logger.warnf("Can't do backchannel logout. No SingleLogoutService POST Binding registered for client: %s", + client.getClientId()); + return Response.serverError().build(); } String logoutRequestString = null; @@ -688,7 +689,7 @@ public class SamlProtocol implements LoginProtocol { logoutRequestString = binding.postBinding(SAML2Request.convert(logoutRequest)).encoded(); } catch (Exception e) { logger.warn("failed to send saml logout", e); - return; + return Response.serverError().build(); } HttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient(); @@ -724,10 +725,11 @@ public class SamlProtocol implements LoginProtocol { } } catch (IOException e) { logger.warn("failed to send saml logout", e); + return Response.serverError().build(); } break; } - + return Response.ok().build(); } protected LogoutRequestType createLogoutRequest(String logoutUrl, AuthenticatedClientSessionModel clientSession, ClientModel client, NodeGenerator... extensions) throws ConfigurationException { diff --git a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java index c92ef3fea2..0fc6120fce 100644 --- a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java @@ -139,6 +139,14 @@ public class DescriptionConverter { configWrapper.setTokenEndpointAuthSigningAlg(clientOIDC.getTokenEndpointAuthSigningAlg()); + configWrapper.setBackchannelLogoutUrl(clientOIDC.getBackchannelLogoutUri()); + + if (clientOIDC.getBackchannelLogoutSessionRequired() == null) { + configWrapper.setBackchannelLogoutSessionRequired(true); + } else { + configWrapper.setBackchannelLogoutSessionRequired(clientOIDC.getBackchannelLogoutSessionRequired()); + } + return client; } @@ -234,6 +242,8 @@ public class DescriptionConverter { if (config.getTokenEndpointAuthSigningAlg() != null) { response.setTokenEndpointAuthSigningAlg(config.getTokenEndpointAuthSigningAlg()); } + response.setBackchannelLogoutUri(config.getBackchannelLogoutUrl()); + response.setBackchannelLogoutSessionRequired(config.isBackchannelLogoutSessionRequired()); List foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client); SubjectType subjectType = foundPairwiseMappers.isEmpty() ? SubjectType.PUBLIC : SubjectType.PAIRWISE; diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 29030386ba..78610141c8 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -64,6 +64,8 @@ import org.keycloak.models.utils.SessionTimeoutHelper; import org.keycloak.models.utils.SystemClientUtil; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol.Error; +import org.keycloak.protocol.oidc.BackchannelLogoutResponse; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.representations.AccessToken; import org.keycloak.services.ServicesLogger; @@ -168,11 +170,11 @@ public class AuthenticationManager { } } - public static void expireUserSessionCookie(KeycloakSession session, UserSessionModel userSession, RealmModel realm, UriInfo uriInfo, HttpHeaders headers, ClientConnection connection) { + public static boolean expireUserSessionCookie(KeycloakSession session, UserSessionModel userSession, RealmModel realm, UriInfo uriInfo, HttpHeaders headers, ClientConnection connection) { try { // check to see if any identity cookie is set with the same session and expire it if necessary Cookie cookie = CookieHelper.getCookie(headers.getCookies(), KEYCLOAK_IDENTITY_COOKIE); - if (cookie == null) return; + if (cookie == null) return true; String tokenString = cookie.getValue(); TokenVerifier verifier = TokenVerifier.create(tokenString, AccessToken.class) @@ -189,9 +191,11 @@ public class AuthenticationManager { AccessToken token = verifier.verify().getToken(); UserSessionModel cookieSession = session.sessions().getUserSession(realm, token.getSessionState()); - if (cookieSession == null || !cookieSession.getId().equals(userSession.getId())) return; + if (cookieSession == null || !cookieSession.getId().equals(userSession.getId())) return true; expireIdentityCookie(realm, uriInfo, connection); + return true; } catch (Exception e) { + return false; } } @@ -208,11 +212,11 @@ public class AuthenticationManager { ); } - public static void backchannelLogout(KeycloakSession session, RealmModel realm, - UserSessionModel userSession, UriInfo uriInfo, - ClientConnection connection, HttpHeaders headers, - boolean logoutBroker) { - backchannelLogout(session, realm, userSession, uriInfo, connection, headers, logoutBroker, false); + public static BackchannelLogoutResponse backchannelLogout(KeycloakSession session, RealmModel realm, + UserSessionModel userSession, UriInfo uriInfo, + ClientConnection connection, HttpHeaders headers, + boolean logoutBroker) { + return backchannelLogout(session, realm, userSession, uriInfo, connection, headers, logoutBroker, false); } /** @@ -225,27 +229,40 @@ public class AuthenticationManager { * @param headers * @param logoutBroker * @param offlineSession + * + * @return BackchannelLogoutResponse with logout information */ - public static void backchannelLogout(KeycloakSession session, RealmModel realm, - UserSessionModel userSession, UriInfo uriInfo, - ClientConnection connection, HttpHeaders headers, - boolean logoutBroker, - boolean offlineSession) { - if (userSession == null) return; + public static BackchannelLogoutResponse backchannelLogout(KeycloakSession session, RealmModel realm, + UserSessionModel userSession, UriInfo uriInfo, + ClientConnection connection, HttpHeaders headers, + boolean logoutBroker, + boolean offlineSession) { + BackchannelLogoutResponse backchannelLogoutResponse = new BackchannelLogoutResponse(); + + if (userSession == null) { + backchannelLogoutResponse.setLocalLogoutSucceeded(true); + return backchannelLogoutResponse; + } UserModel user = userSession.getUser(); if (userSession.getState() != UserSessionModel.State.LOGGING_OUT) { userSession.setState(UserSessionModel.State.LOGGING_OUT); } - logger.debugv("Logging out: {0} ({1}) offline: {2}", user.getUsername(), userSession.getId(), userSession.isOffline()); - expireUserSessionCookie(session, userSession, realm, uriInfo, headers, connection); + logger.debugv("Logging out: {0} ({1}) offline: {2}", user.getUsername(), userSession.getId(), + userSession.isOffline()); + boolean expireUserSessionCookieSucceeded = + expireUserSessionCookie(session, userSession, realm, uriInfo, headers, connection); final AuthenticationSessionManager asm = new AuthenticationSessionManager(session); - AuthenticationSessionModel logoutAuthSession = createOrJoinLogoutSession(session, realm, asm, userSession, false); + AuthenticationSessionModel logoutAuthSession = + createOrJoinLogoutSession(session, realm, asm, userSession, false); + boolean userSessionOnlyHasLoggedOutClients = false; try { - backchannelLogoutAll(session, realm, userSession, logoutAuthSession, uriInfo, headers, logoutBroker); - checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession); + backchannelLogoutResponse = backchannelLogoutAll(session, realm, userSession, logoutAuthSession, uriInfo, + headers, logoutBroker); + userSessionOnlyHasLoggedOutClients = + checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession); } finally { RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession(); rootAuthSession.removeAuthenticationSessionByTabId(logoutAuthSession.getTabId()); @@ -264,6 +281,9 @@ public class AuthenticationManager { } else { session.sessions().removeUserSession(realm, userSession); } + backchannelLogoutResponse + .setLocalLogoutSucceeded(expireUserSessionCookieSucceeded && userSessionOnlyHasLoggedOutClients); + return backchannelLogoutResponse; } private static AuthenticationSessionModel createOrJoinLogoutSession(KeycloakSession session, RealmModel realm, final AuthenticationSessionManager asm, UserSessionModel userSession, boolean browserCookie) { @@ -307,12 +327,29 @@ public class AuthenticationManager { return logoutAuthSession; } - private static void backchannelLogoutAll(KeycloakSession session, RealmModel realm, - UserSessionModel userSession, AuthenticationSessionModel logoutAuthSession, UriInfo uriInfo, - HttpHeaders headers, boolean logoutBroker) { - userSession.getAuthenticatedClientSessions().values().forEach( - clientSession -> backchannelLogoutClientSession(session, realm, clientSession, logoutAuthSession, uriInfo, headers) - ); + private static BackchannelLogoutResponse backchannelLogoutAll(KeycloakSession session, RealmModel realm, + UserSessionModel userSession, AuthenticationSessionModel logoutAuthSession, UriInfo uriInfo, + HttpHeaders headers, boolean logoutBroker) { + BackchannelLogoutResponse backchannelLogoutResponse = new BackchannelLogoutResponse(); + + for (AuthenticatedClientSessionModel clientSession : userSession.getAuthenticatedClientSessions().values()) { + Response clientSessionLogoutResponse = + backchannelLogoutClientSession(session, realm, clientSession, logoutAuthSession, uriInfo, headers); + + String backchannelLogoutUrl = + OIDCAdvancedConfigWrapper.fromClientModel(clientSession.getClient()).getBackchannelLogoutUrl(); + + BackchannelLogoutResponse.DownStreamBackchannelLogoutResponse downStreamBackchannelLogoutResponse = + new BackchannelLogoutResponse.DownStreamBackchannelLogoutResponse(); + downStreamBackchannelLogoutResponse.setWithBackchannelLogoutUrl(backchannelLogoutUrl != null); + + if (clientSessionLogoutResponse != null) { + downStreamBackchannelLogoutResponse.setResponseCode(clientSessionLogoutResponse.getStatus()); + } else { + downStreamBackchannelLogoutResponse.setResponseCode(null); + } + backchannelLogoutResponse.addClientResponses(downStreamBackchannelLogoutResponse); + } if (logoutBroker) { String brokerId = userSession.getNote(Details.IDENTITY_PROVIDER); if (brokerId != null) { @@ -321,9 +358,12 @@ public class AuthenticationManager { identityProvider.backchannelLogout(session, userSession, uriInfo, realm); } catch (Exception e) { logger.warn("Exception at broker backchannel logout for broker " + brokerId, e); + backchannelLogoutResponse.setLocalLogoutSucceeded(false); } } } + + return backchannelLogoutResponse; } /** @@ -365,53 +405,58 @@ public class AuthenticationManager { /** * Logs out the given client session and records the result into {@code logoutAuthSession} if set. + * * @param session * @param realm * @param clientSession * @param logoutAuthSession auth session used for recording result of logout. May be {@code null} * @param uriInfo * @param headers - * @return {@code true} if the client was or is already being logged out, {@code false} if logout failed or it is not known how to log it out. + * @return {@code http status OK} if the client was or is already being logged out, {@code null} if it is + * not known how to log it out and no request is made, otherwise the response of the logout request. */ - private static boolean backchannelLogoutClientSession(KeycloakSession session, RealmModel realm, - AuthenticatedClientSessionModel clientSession, AuthenticationSessionModel logoutAuthSession, - UriInfo uriInfo, HttpHeaders headers) { + private static Response backchannelLogoutClientSession(KeycloakSession session, RealmModel realm, + AuthenticatedClientSessionModel clientSession, AuthenticationSessionModel logoutAuthSession, + UriInfo uriInfo, HttpHeaders headers) { UserSessionModel userSession = clientSession.getUserSession(); ClientModel client = clientSession.getClient(); - if (client.isFrontchannelLogout() || AuthenticationSessionModel.Action.LOGGED_OUT.name().equals(clientSession.getAction())) { - return false; + if (client.isFrontchannelLogout() + || AuthenticationSessionModel.Action.LOGGED_OUT.name().equals(clientSession.getAction())) { + return null; } final AuthenticationSessionModel.Action logoutState = getClientLogoutAction(logoutAuthSession, client.getId()); - if (logoutState == AuthenticationSessionModel.Action.LOGGED_OUT || logoutState == AuthenticationSessionModel.Action.LOGGING_OUT) { - return true; + if (logoutState == AuthenticationSessionModel.Action.LOGGED_OUT + || logoutState == AuthenticationSessionModel.Action.LOGGING_OUT) { + return Response.ok().build(); } if (!client.isEnabled()) { - return false; + return null; } try { setClientLogoutAction(logoutAuthSession, client.getId(), AuthenticationSessionModel.Action.LOGGING_OUT); String authMethod = clientSession.getProtocol(); - if (authMethod == null) return true; // must be a keycloak service like account + if (authMethod == null) return Response.ok().build(); // must be a keycloak service like account logger.debugv("backchannel logout to: {0}", client.getClientId()); LoginProtocol protocol = session.getProvider(LoginProtocol.class, authMethod); protocol.setRealm(realm) .setHttpHeaders(headers) .setUriInfo(uriInfo); - protocol.backchannelLogout(userSession, clientSession); + + Response clientSessionLogout = protocol.backchannelLogout(userSession, clientSession); setClientLogoutAction(logoutAuthSession, client.getId(), AuthenticationSessionModel.Action.LOGGED_OUT); - return true; + return clientSessionLogout; } catch (Exception ex) { ServicesLogger.LOGGER.failedToLogoutClient(ex); - return false; + return Response.serverError().build(); } } diff --git a/services/src/main/java/org/keycloak/services/managers/ResourceAdminManager.java b/services/src/main/java/org/keycloak/services/managers/ResourceAdminManager.java index bffe20ee32..f1fb256050 100755 --- a/services/src/main/java/org/keycloak/services/managers/ResourceAdminManager.java +++ b/services/src/main/java/org/keycloak/services/managers/ResourceAdminManager.java @@ -16,7 +16,15 @@ */ package org.keycloak.services.managers; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.client.HttpClient; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; import org.keycloak.TokenIdGenerator; import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.common.util.MultivaluedHashMap; @@ -26,19 +34,21 @@ import org.keycloak.connections.httpclient.HttpClientProvider; import org.keycloak.constants.AdapterConstants; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; -import org.keycloak.models.TokenManager; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.LoginProtocol; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.LogoutToken; import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.adapters.action.LogoutAction; import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.services.ServicesLogger; import org.keycloak.services.util.ResolveRelative; +import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import java.io.IOException; import java.net.URI; @@ -139,11 +149,11 @@ public class ResourceAdminManager { } - public boolean logoutClientSession(RealmModel realm, ClientModel resource, AuthenticatedClientSessionModel clientSession) { + public Response logoutClientSession(RealmModel realm, ClientModel resource, AuthenticatedClientSessionModel clientSession) { return logoutClientSessions(realm, resource, Arrays.asList(clientSession)); } - protected boolean logoutClientSessions(RealmModel realm, ClientModel resource, List clientSessions) { + protected Response logoutClientSessions(RealmModel realm, ClientModel resource, List clientSessions) { String managementUrl = getManagementUrl(session, resource); if (managementUrl != null) { @@ -164,20 +174,19 @@ public class ResourceAdminManager { if (adapterSessionIds == null || adapterSessionIds.isEmpty()) { logger.debugv("Can't logout {0}: no logged adapter sessions", resource.getClientId()); - return false; + return null; } if (managementUrl.contains(CLIENT_SESSION_HOST_PROPERTY)) { - boolean allPassed = true; // Send logout separately to each host (needed for single-sign-out in cluster for non-distributable apps - KEYCLOAK-748) for (Map.Entry> entry : adapterSessionIds.entrySet()) { String host = entry.getKey(); List sessionIds = entry.getValue(); String currentHostMgmtUrl = managementUrl.replace(CLIENT_SESSION_HOST_PROPERTY, host); - allPassed = sendLogoutRequest(realm, resource, sessionIds, userSessions, 0, currentHostMgmtUrl) && allPassed; + sendLogoutRequest(realm, resource, sessionIds, userSessions, 0, currentHostMgmtUrl); } + return Response.ok().build(); - return allPassed; } else { // Send single logout request List allSessionIds = new ArrayList(); @@ -189,7 +198,68 @@ public class ResourceAdminManager { } } else { logger.debugv("Can't logout {0}: no management url", resource.getClientId()); - return false; + return null; + } + } + + public Response logoutClientSessionWithBackchannelLogoutUrl(ClientModel resource, + AuthenticatedClientSessionModel clientSession) { + String backchannelLogoutUrl = getBackchannelLogoutUrl(session, resource); + // Send logout separately to each host (needed for single-sign-out in cluster for non-distributable apps - + // KEYCLOAK-748) + if (backchannelLogoutUrl.contains(CLIENT_SESSION_HOST_PROPERTY)) { + String host = clientSession.getNote(AdapterConstants.CLIENT_SESSION_HOST); + String currentHostMgmtUrl = backchannelLogoutUrl.replace(CLIENT_SESSION_HOST_PROPERTY, host); + return sendBackChannelLogoutRequestToClientUri(resource, clientSession, currentHostMgmtUrl); + } else { + return sendBackChannelLogoutRequestToClientUri(resource, clientSession, backchannelLogoutUrl); + } + } + + public static String getBackchannelLogoutUrl(KeycloakSession session, ClientModel client) { + String backchannelLogoutUrl = OIDCAdvancedConfigWrapper.fromClientModel(client).getBackchannelLogoutUrl(); + if (backchannelLogoutUrl == null || backchannelLogoutUrl.equals("")) { + return null; + } + + String absoluteURI = ResolveRelative.resolveRelativeUri(session, client.getRootUrl(), backchannelLogoutUrl); + // this is for resolving URI like "http://${jboss.host.name}:8080/..." in order to send request to same machine + // and avoid request to LB in cluster environment + return StringPropertyReplacer.replaceProperties(absoluteURI); + } + + protected Response sendBackChannelLogoutRequestToClientUri(ClientModel resource, + AuthenticatedClientSessionModel clientSessionModel, String managementUrl) { + UserModel user = clientSessionModel.getUserSession().getUser(); + + LogoutToken logoutToken = session.tokens().initLogoutToken(resource, user, clientSessionModel); + String token = session.tokens().encode(logoutToken); + if (logger.isDebugEnabled()) + logger.debugv("logout resource {0} url: {1} sessionIds: ", resource.getClientId(), managementUrl); + HttpPost post = null; + try { + post = new HttpPost(managementUrl); + List parameters = new LinkedList<>(); + if (logoutToken != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.LOGOUT_TOKEN, token)); + } + HttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient(); + UrlEncodedFormEntity formEntity; + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + post.setEntity(formEntity); + HttpResponse response = httpClient.execute(post); + int status = response.getStatusLine().getStatusCode(); + EntityUtils.consumeQuietly(response.getEntity()); + boolean success = status == 204 || status == 200; + logger.debugf("logout success for %s: %s", managementUrl, success); + return Response.status(status).build(); + } catch (IOException e) { + ServicesLogger.LOGGER.logoutFailed(e, resource.getClientId()); + return Response.serverError().build(); + } finally { + if (post != null) { + post.releaseConnection(); + } } } @@ -231,7 +301,7 @@ public class ResourceAdminManager { // Propagate this to all hosts GlobalRequestResult result = new GlobalRequestResult(); for (String mgmtUrl : mgmtUrls) { - if (sendLogoutRequest(realm, resource, null, null, notBefore, mgmtUrl)) { + if (sendLogoutRequest(realm, resource, null, null, notBefore, mgmtUrl) != null) { result.addSuccessRequest(mgmtUrl); } else { result.addFailedRequest(mgmtUrl); @@ -240,7 +310,7 @@ public class ResourceAdminManager { return result; } - protected boolean sendLogoutRequest(RealmModel realm, ClientModel resource, List adapterSessionIds, List userSessions, int notBefore, String managementUrl) { + protected Response sendLogoutRequest(RealmModel realm, ClientModel resource, List adapterSessionIds, List userSessions, int notBefore, String managementUrl) { LogoutAction adminAction = new LogoutAction(TokenIdGenerator.generateId(), Time.currentTime() + 30, resource.getClientId(), adapterSessionIds, notBefore, userSessions); String token = session.tokens().encode(adminAction); if (logger.isDebugEnabled()) logger.debugv("logout resource {0} url: {1} sessionIds: " + adapterSessionIds, resource.getClientId(), managementUrl); @@ -249,10 +319,10 @@ public class ResourceAdminManager { int status = session.getProvider(HttpClientProvider.class).postText(target.toString(), token); boolean success = status == 204 || status == 200; logger.debugf("logout success for %s: %s", managementUrl, success); - return success; + return Response.ok().build(); } catch (IOException e) { ServicesLogger.LOGGER.logoutFailed(e, resource.getClientId()); - return false; + return Response.serverError().build(); } } diff --git a/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java b/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java index 554c7a62ce..396a5c4efb 100644 --- a/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java +++ b/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java @@ -17,6 +17,7 @@ package org.keycloak.validation; import org.keycloak.models.ClientModel; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.services.util.ResolveRelative; import java.net.MalformedURLException; @@ -47,8 +48,13 @@ public class DefaultClientValidationProvider implements ClientValidationProvider String resolvedRootUrl = ResolveRelative.resolveRootUrl(authServerUrl, authServerUrl, client.getRootUrl()); String resolvedBaseUrl = ResolveRelative.resolveRelativeUri(authServerUrl, authServerUrl, resolvedRootUrl, client.getBaseUrl()); + String backchannelLogoutUrl = OIDCAdvancedConfigWrapper.fromClientModel(client).getBackchannelLogoutUrl(); + String resolvedBackchannelLogoutUrl = + ResolveRelative.resolveRelativeUri(authServerUrl, authServerUrl, resolvedRootUrl, backchannelLogoutUrl); + validateRootUrl(resolvedRootUrl); validateBaseUrl(resolvedBaseUrl); + validateBackchannelLogoutUrl(resolvedBackchannelLogoutUrl); } private void validateRootUrl(String rootUrl) throws ValidationException { @@ -63,6 +69,12 @@ public class DefaultClientValidationProvider implements ClientValidationProvider } } + private void validateBackchannelLogoutUrl(String backchannelLogoutUrl) throws ValidationException { + if (backchannelLogoutUrl != null && !backchannelLogoutUrl.isEmpty()) { + basicHttpUrlCheck("backchannelLogoutUrl", backchannelLogoutUrl); + } + } + private void basicHttpUrlCheck(String field, String url) throws ValidationException { boolean valid = true; try { diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 0237883715..3b844f41c1 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -17,7 +17,12 @@ package org.keycloak.testsuite.util; -import com.google.common.base.Charsets; +import static org.keycloak.testsuite.admin.Users.getPasswordOf; +import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; +import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts; +import static org.keycloak.testsuite.util.UIUtils.clickLink; +import static org.keycloak.testsuite.util.WaitUtils.waitUntilElement; + import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.ByteArrayOutputStream; import org.apache.http.Header; @@ -70,9 +75,8 @@ import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.Form; -import javax.ws.rs.core.UriBuilder; +import com.google.common.base.Charsets; + import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URI; @@ -88,10 +92,9 @@ import java.util.List; import java.util.Map; import java.util.function.Supplier; -import static org.keycloak.testsuite.admin.Users.getPasswordOf; -import static org.keycloak.testsuite.util.UIUtils.clickLink; -import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; -import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.UriBuilder; /** * @author Stian Thorgersen @@ -200,6 +203,16 @@ public class OAuthClient { } } + public class BackchannelLogoutUrlBuilder { + private final String backchannelLogoutPath = "/backchannel-logout"; + + private final UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl)).path(backchannelLogoutPath); + + public String build() { + return b.build(realm).toString(); + } + } + public void init(WebDriver driver) { this.driver = driver; @@ -251,6 +264,30 @@ public class OAuthClient { return new AuthorizationEndpointResponse(this); } + public void updateAccountInformation(String username, String email) { + WaitUtils.waitForPageToLoad(); + updateAccountInformation(username, email, "First", "Last"); + } + + public void linkUsers(String username, String password) { + WaitUtils.waitForPageToLoad(); + WebElement linkAccountButton = driver.findElement(By.id("linkAccount")); + waitUntilElement(linkAccountButton).is().clickable(); + linkAccountButton.click(); + + WaitUtils.waitForPageToLoad(); + WebElement usernameInput = driver.findElement(By.id("username")); + usernameInput.clear(); + usernameInput.sendKeys(username); + WebElement passwordInput = driver.findElement(By.id("password")); + passwordInput.clear(); + passwordInput.sendKeys(password); + + WebElement loginButton = driver.findElement(By.id("kc-login")); + waitUntilElement(loginButton).is().clickable(); + loginButton.click(); + } + public AuthorizationEndpointResponse doLogin(UserRepresentation user) { return doLogin(user.getUsername(), getPasswordOf(user)); @@ -283,6 +320,29 @@ public class OAuthClient { } } + private void updateAccountInformation(String username, String email, String firstName, String lastName) { + + WebElement usernameInput = driver.findElement(By.id("username")); + usernameInput.clear(); + usernameInput.sendKeys(username); + + WebElement emailInput = driver.findElement(By.id("email")); + emailInput.clear(); + emailInput.sendKeys(email); + + WebElement firstNameInput = driver.findElement(By.id("firstName")); + firstNameInput.clear(); + firstNameInput.sendKeys(firstName); + + WebElement lastNameInput = driver.findElement(By.id("lastName")); + lastNameInput.clear(); + lastNameInput.sendKeys(lastName); + + WebElement submitButton = driver.findElement(By.cssSelector("input[type=\"submit\"]")); + waitUntilElement(submitButton).is().clickable(); + submitButton.click(); + } + public void doLoginGrant(String username, String password) { openLoginForm(); fillLoginForm(username, password); @@ -663,6 +723,32 @@ public class OAuthClient { return client.execute(post); } + public CloseableHttpResponse doBackchannelLogout(String logoutTokon) { + try (CloseableHttpClient client = HttpClientBuilder.create().build()) { + return doBackchannelLogout(logoutTokon, client); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + + public CloseableHttpResponse doBackchannelLogout(String logoutToken, CloseableHttpClient client) throws IOException { + HttpPost post = new HttpPost(getBackchannelLogoutUrl().build()); + List parameters = new LinkedList<>(); + if (logoutToken != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.LOGOUT_TOKEN, logoutToken)); + } + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return client.execute(post); + } + public CloseableHttpResponse doTokenRevoke(String token, String tokenTypeHint, String clientSecret) { try (CloseableHttpClient client = HttpClientBuilder.create().build()) { return doTokenRevoke(token, tokenTypeHint, clientSecret, client); @@ -985,6 +1071,10 @@ public class OAuthClient { return new LogoutUrlBuilder(); } + public BackchannelLogoutUrlBuilder getBackchannelLogoutUrl() { + return new BackchannelLogoutUrlBuilder(); + } + public String getTokenRevocationUrl() { UriBuilder b = OIDCLoginProtocolService.tokenRevocationUrl(UriBuilder.fromUri(baseUrl)); return b.build(realm).toString(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientTest.java index 7f5642d63a..00bedfa3e2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/ClientTest.java @@ -27,6 +27,7 @@ import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; import org.keycloak.models.AccountRoles; import org.keycloak.models.Constants; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.representations.adapters.action.GlobalRequestResult; @@ -124,6 +125,10 @@ public class ClientTest extends AbstractAdminTest { rep.setRootUrl(""); rep.setBaseUrl("/valid"); createClientExpectingSuccessfulClientCreation(rep); + + rep.setBaseUrl(null); + OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).setBackchannelLogoutUrl("invalid"); + createClientExpectingValidationError(rep, "Invalid URL in backchannelLogoutUrl"); } @Test @@ -152,6 +157,10 @@ public class ClientTest extends AbstractAdminTest { rep.setRootUrl(""); rep.setBaseUrl("/valid"); updateClientExpectingSuccessfulClientUpdate(rep, "", "/valid"); + + rep.setBaseUrl(null); + OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).setBackchannelLogoutUrl("invalid"); + updateClientExpectingValidationError(rep, "Invalid URL in backchannelLogoutUrl"); } private void createClientExpectingValidationError(ClientRepresentation rep, String expectedError) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractNestedBrokerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractNestedBrokerTest.java new file mode 100644 index 0000000000..6c42d0841b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractNestedBrokerTest.java @@ -0,0 +1,48 @@ +package org.keycloak.testsuite.broker; + +import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot; +import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage; + +import org.junit.After; +import org.junit.Before; + +public abstract class AbstractNestedBrokerTest extends AbstractBaseBrokerTest { + + protected NestedBrokerConfiguration nbc = getNestedBrokerConfiguration(); + + protected abstract NestedBrokerConfiguration getNestedBrokerConfiguration(); + + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return getNestedBrokerConfiguration(); + } + + @Before + public void createSubConsumerRealm() { + importRealm(nbc.createSubConsumerRealm()); + } + + @After + public void removeSubConsumerRealm() { + adminClient.realm(nbc.subConsumerRealmName()).remove(); + } + + /** Logs in subconsumer realm via consumer IDP via provider IDP and updates account information */ + protected void logInAsUserInNestedIDPForFirstTime() { + driver.navigate().to(getAccountUrl(getConsumerRoot(), nbc.subConsumerRealmName())); + waitForPage(driver, "log in to", true); + log.debug("Clicking social " + nbc.getSubConsumerIDPDisplayName()); + loginPage.clickSocial(nbc.getSubConsumerIDPDisplayName()); + waitForPage(driver, "log in to", true); + log.debug("Clicking social " + nbc.getIDPAlias()); + loginPage.clickSocial(nbc.getIDPAlias()); + waitForPage(driver, "log in to", true); + log.debug("Logging in"); + loginPage.login(nbc.getUserLogin(), nbc.getUserPassword()); + + waitForPage(driver, "update account information", false); + log.debug("Updating info on updateAccount page"); + updateAccountInformationPage.updateAccountInformation(bc.getUserLogin(), bc.getUserEmail(), "Firstname", + "Lastname"); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java index 9064ca30de..f21c4d6381 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java @@ -1,5 +1,6 @@ package org.keycloak.testsuite.broker; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderSyncMode; import org.keycloak.protocol.ProtocolMapperUtils; @@ -40,6 +41,8 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration { RealmRepresentation realm = new RealmRepresentation(); realm.setRealm(REALM_PROV_NAME); realm.setEnabled(true); + realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue")); + realm.setEventsEnabled(true); return realm; } @@ -50,6 +53,8 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration { realm.setRealm(REALM_CONS_NAME); realm.setEnabled(true); realm.setResetPasswordAllowed(true); + realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue")); + realm.setEventsEnabled(true); return realm; } @@ -60,7 +65,6 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration { client.setClientId(getIDPClientIdInProviderRealm()); client.setName(CLIENT_ID); client.setSecret(CLIENT_SECRET); - client.setEnabled(true); client.setRedirectUris(Collections.singletonList(getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint/*")); @@ -190,6 +194,10 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration { config.put("userInfoUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/userinfo"); config.put("defaultScope", "email profile"); config.put("backchannelSupported", "true"); + config.put(OIDCIdentityProviderConfig.JWKS_URL, + getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/certs"); + config.put(OIDCIdentityProviderConfig.USE_JWKS_URL, "true"); + config.put(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true"); } @Override diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/NestedBrokerConfiguration.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/NestedBrokerConfiguration.java new file mode 100644 index 0000000000..4523218cbc --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/NestedBrokerConfiguration.java @@ -0,0 +1,15 @@ +package org.keycloak.testsuite.broker; + +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; + +public interface NestedBrokerConfiguration extends BrokerConfiguration { + + RealmRepresentation createSubConsumerRealm(); + + String subConsumerRealmName(); + + IdentityProviderRepresentation setUpConsumerIdentityProvider(); + + String getSubConsumerIDPDisplayName(); +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcBackchannelLogoutBrokerConfiguration.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcBackchannelLogoutBrokerConfiguration.java new file mode 100644 index 0000000000..343e56bd1d --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcBackchannelLogoutBrokerConfiguration.java @@ -0,0 +1,326 @@ +package org.keycloak.testsuite.broker; + +import static org.keycloak.testsuite.broker.BrokerTestConstants.CLIENT_ID; +import static org.keycloak.testsuite.broker.BrokerTestConstants.CLIENT_SECRET; +import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS; +import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_PROVIDER_ID; +import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_CONS_NAME; +import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_PROV_NAME; +import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_EMAIL; +import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_LOGIN; +import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_PASSWORD; +import static org.keycloak.testsuite.broker.BrokerTestTools.createIdentityProvider; +import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot; + +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IdentityProviderSyncMode; +import org.keycloak.protocol.ProtocolMapperUtils; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.HardcodedClaim; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.protocol.oidc.mappers.UserAttributeMapper; +import org.keycloak.protocol.oidc.mappers.UserPropertyMapper; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class OidcBackchannelLogoutBrokerConfiguration implements NestedBrokerConfiguration { + + public static final OidcBackchannelLogoutBrokerConfiguration INSTANCE = + new OidcBackchannelLogoutBrokerConfiguration(); + + protected static final String ATTRIBUTE_TO_MAP_NAME = "user-attribute"; + protected static final String ATTRIBUTE_TO_MAP_NAME_2 = "user-attribute-2"; + public static final String USER_INFO_CLAIM = "user-claim"; + public static final String HARDOCDED_CLAIM = "test"; + public static final String HARDOCDED_VALUE = "value"; + + public static final String REALM_SUB_CONS_NAME = "subconsumer"; + public static final String CONSUMER_CLIENT_ID = "consumer-brokerapp"; + public static final String CONSUMER_CLIENT_SECRET = "consumer-secret"; + + public static final String SUB_CONSUMER_IDP_OIDC_ALIAS = "consumer-kc-oidc-idp"; + public static final String SUB_CONSUMER_IDP_OIDC_PROVIDER_ID = "keycloak-oidc"; + + @Override + public RealmRepresentation createProviderRealm() { + RealmRepresentation realm = new RealmRepresentation(); + realm.setRealm(REALM_PROV_NAME); + realm.setEnabled(true); + realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue")); + realm.setEventsEnabled(true); + + return realm; + } + + @Override + public RealmRepresentation createConsumerRealm() { + RealmRepresentation realm = new RealmRepresentation(); + realm.setRealm(REALM_CONS_NAME); + realm.setEnabled(true); + realm.setResetPasswordAllowed(true); + realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue")); + realm.setEventsEnabled(true); + + return realm; + } + + @Override + public List createProviderClients() { + ClientRepresentation client = new ClientRepresentation(); + client.setId(CLIENT_ID); + client.setClientId(getIDPClientIdInProviderRealm()); + client.setName(CLIENT_ID); + client.setSecret(CLIENT_SECRET); + client.setEnabled(true); + + OIDCAdvancedConfigWrapper oidcAdvancedConfigWrapper = + OIDCAdvancedConfigWrapper.fromClientRepresentation(client); + oidcAdvancedConfigWrapper.setBackchannelLogoutSessionRequired(true); + oidcAdvancedConfigWrapper.setBackchannelLogoutUrl(getConsumerRoot() + + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/logout/backchannel-logout"); + + client.setRedirectUris(Collections.singletonList(getConsumerRoot() + + "/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint/*")); + + client.setAdminUrl(getConsumerRoot() + + "/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint"); + + ProtocolMapperRepresentation emailMapper = new ProtocolMapperRepresentation(); + emailMapper.setName("email"); + emailMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + emailMapper.setProtocolMapper(UserPropertyMapper.PROVIDER_ID); + + Map emailMapperConfig = emailMapper.getConfig(); + emailMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, "email"); + emailMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "email"); + emailMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE); + emailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + emailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + emailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true"); + + ProtocolMapperRepresentation nestedAttrMapper = new ProtocolMapperRepresentation(); + nestedAttrMapper.setName("attribute - nested claim"); + nestedAttrMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + nestedAttrMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID); + + Map nestedEmailMapperConfig = nestedAttrMapper.getConfig(); + nestedEmailMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, "nested.email"); + nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "nested.email"); + nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE); + nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true"); + + ProtocolMapperRepresentation dottedAttrMapper = new ProtocolMapperRepresentation(); + dottedAttrMapper.setName("attribute - claim with dot in name"); + dottedAttrMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + dottedAttrMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID); + + Map dottedEmailMapperConfig = dottedAttrMapper.getConfig(); + dottedEmailMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, "dotted.email"); + dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "dotted\\.email"); + dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE); + dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true"); + + ProtocolMapperRepresentation userAttrMapper = new ProtocolMapperRepresentation(); + userAttrMapper.setName("attribute - name"); + userAttrMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + userAttrMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID); + + Map userAttrMapperConfig = userAttrMapper.getConfig(); + userAttrMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, ATTRIBUTE_TO_MAP_NAME); + userAttrMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, ATTRIBUTE_TO_MAP_NAME); + userAttrMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE); + userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true"); + userAttrMapperConfig.put(ProtocolMapperUtils.MULTIVALUED, "true"); + + ProtocolMapperRepresentation userAttrMapper2 = new ProtocolMapperRepresentation(); + userAttrMapper2.setName("attribute - name - 2"); + userAttrMapper2.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + userAttrMapper2.setProtocolMapper(UserAttributeMapper.PROVIDER_ID); + + Map userAttrMapperConfig2 = userAttrMapper2.getConfig(); + userAttrMapperConfig2.put(ProtocolMapperUtils.USER_ATTRIBUTE, ATTRIBUTE_TO_MAP_NAME_2); + userAttrMapperConfig2.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, ATTRIBUTE_TO_MAP_NAME_2); + userAttrMapperConfig2.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE); + userAttrMapperConfig2.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + userAttrMapperConfig2.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + userAttrMapperConfig2.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true"); + userAttrMapperConfig2.put(ProtocolMapperUtils.MULTIVALUED, "true"); + + ProtocolMapperRepresentation hardcodedJsonClaim = new ProtocolMapperRepresentation(); + hardcodedJsonClaim.setName("json-mapper"); + hardcodedJsonClaim.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + hardcodedJsonClaim.setProtocolMapper(HardcodedClaim.PROVIDER_ID); + + Map hardcodedJsonClaimMapperConfig = hardcodedJsonClaim.getConfig(); + hardcodedJsonClaimMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, + OidcBackchannelLogoutBrokerConfiguration.USER_INFO_CLAIM); + hardcodedJsonClaimMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, "JSON"); + hardcodedJsonClaimMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + hardcodedJsonClaimMapperConfig.put(HardcodedClaim.CLAIM_VALUE, + "{\"" + HARDOCDED_CLAIM + "\": \"" + HARDOCDED_VALUE + "\"}"); + + client.setProtocolMappers(Arrays.asList(emailMapper, userAttrMapper, userAttrMapper2, nestedAttrMapper, + dottedAttrMapper, hardcodedJsonClaim)); + + return Collections.singletonList(client); + } + + @Override + public List createConsumerClients() { + ClientRepresentation client = new ClientRepresentation(); + client.setId(CONSUMER_CLIENT_ID); + client.setClientId(CONSUMER_CLIENT_ID); + client.setName(CONSUMER_CLIENT_ID); + client.setSecret(CONSUMER_CLIENT_SECRET); + client.setEnabled(true); + client.setDirectAccessGrantsEnabled(true); + + client.setRedirectUris(Collections.singletonList(getConsumerRoot() + + "/auth/realms/" + REALM_SUB_CONS_NAME + "/broker/" + SUB_CONSUMER_IDP_OIDC_ALIAS + "/endpoint/*")); + + client.setBaseUrl(getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/app"); + + OIDCAdvancedConfigWrapper oidcAdvancedConfigWrapper = + OIDCAdvancedConfigWrapper.fromClientRepresentation(client); + oidcAdvancedConfigWrapper.setBackchannelLogoutSessionRequired(true); + oidcAdvancedConfigWrapper.setBackchannelLogoutUrl(getConsumerRoot() + + "/auth/realms/" + REALM_SUB_CONS_NAME + "/protocol/openid-connect/logout/backchannel-logout"); + + return Collections.singletonList(client); + } + + @Override + public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) { + IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID); + + Map config = idp.getConfig(); + applyDefaultConfiguration(config, syncMode); + + return idp; + } + + protected void applyDefaultConfiguration(final Map config, + IdentityProviderSyncMode syncMode) { + config.put(IdentityProviderModel.SYNC_MODE, syncMode.toString()); + config.put("clientId", CLIENT_ID); + config.put("clientSecret", CLIENT_SECRET); + config.put("prompt", "login"); + config.put("issuer", getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME); + config.put("authorizationUrl", + getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/auth"); + config.put("tokenUrl", + getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/token"); + config.put("logoutUrl", + getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/logout"); + config.put("userInfoUrl", + getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/userinfo"); + config.put("defaultScope", "email profile"); + config.put("backchannelSupported", "true"); + config.put(OIDCIdentityProviderConfig.JWKS_URL, + getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/certs"); + config.put(OIDCIdentityProviderConfig.USE_JWKS_URL, "true"); + config.put(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true"); + } + + @Override + public String getUserLogin() { + return USER_LOGIN; + } + + @Override + public String getIDPClientIdInProviderRealm() { + return CLIENT_ID; + } + + @Override + public String getUserPassword() { + return USER_PASSWORD; + } + + @Override + public String getUserEmail() { + return USER_EMAIL; + } + + @Override + public String providerRealmName() { + return REALM_PROV_NAME; + } + + @Override + public String consumerRealmName() { + return REALM_CONS_NAME; + } + + @Override + public String getIDPAlias() { + return IDP_OIDC_ALIAS; + } + + @Override + public RealmRepresentation createSubConsumerRealm() { + RealmRepresentation realm = new RealmRepresentation(); + realm.setRealm(REALM_SUB_CONS_NAME); + realm.setEnabled(true); + realm.setResetPasswordAllowed(true); + realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue")); + realm.setEventsEnabled(true); + + return realm; + } + + @Override + public String subConsumerRealmName() { + return REALM_SUB_CONS_NAME; + } + + @Override + public IdentityProviderRepresentation setUpConsumerIdentityProvider() { + IdentityProviderRepresentation idp = + createIdentityProvider(SUB_CONSUMER_IDP_OIDC_ALIAS, SUB_CONSUMER_IDP_OIDC_PROVIDER_ID); + + Map config = idp.getConfig(); + config.put(IdentityProviderModel.SYNC_MODE, IdentityProviderSyncMode.IMPORT.toString()); + config.put("clientId", CONSUMER_CLIENT_ID); + config.put("clientSecret", CONSUMER_CLIENT_SECRET); + config.put("prompt", "login"); + config.put("issuer", getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME); + config.put("authorizationUrl", + getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/auth"); + config.put("tokenUrl", + getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/token"); + config.put("logoutUrl", + getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/logout"); + config.put("userInfoUrl", + getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/userinfo"); + config.put("defaultScope", "email profile"); + config.put("backchannelSupported", "true"); + config.put(OIDCIdentityProviderConfig.JWKS_URL, + getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/certs"); + config.put(OIDCIdentityProviderConfig.USE_JWKS_URL, "true"); + config.put(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true"); + + return idp; + } + + @Override + public String getSubConsumerIDPDisplayName() { + return SUB_CONSUMER_IDP_OIDC_ALIAS; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/BackchannelLogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/BackchannelLogoutTest.java new file mode 100644 index 0000000000..8c3abd937f --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/BackchannelLogoutTest.java @@ -0,0 +1,694 @@ +package org.keycloak.testsuite.oauth; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; +import static org.keycloak.testsuite.admin.ApiUtil.createUserWithAdminClient; +import static org.keycloak.testsuite.admin.ApiUtil.resetUserPassword; +import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.jboss.arquillian.drone.api.annotation.Drone; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.protocol.oidc.LogoutTokenValidationCode; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.UserSessionRepresentation; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.broker.AbstractNestedBrokerTest; +import org.keycloak.testsuite.broker.NestedBrokerConfiguration; +import org.keycloak.testsuite.broker.OidcBackchannelLogoutBrokerConfiguration; +import org.keycloak.testsuite.util.CredentialBuilder; +import org.keycloak.testsuite.util.LogoutTokenUtil; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.RealmManager; +import org.keycloak.testsuite.util.SecondBrowser; +import org.keycloak.util.JsonSerialization; +import org.openqa.selenium.WebDriver; + +import java.io.IOException; +import java.security.KeyPair; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import javax.ws.rs.core.Response; + +public class BackchannelLogoutTest extends AbstractNestedBrokerTest { + + public static final String ACCOUNT_CLIENT_NAME = "account"; + public static final String BROKER_CLIENT_ID = "brokerapp"; + public static final String USER_PASSWORD_CONSUMER_REALM = "password"; + private static final KeyPair KEY_PAIR = KeyUtils.generateRsaKeyPair(2048); + private String userIdProviderRealm; + private String realmIdConsumerRealm; + private String accountClientIdConsumerRealm; + private String accountClientIdSubConsumerRealm; + private String providerId; + + private RealmManager providerRealmManager; + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Drone + @SecondBrowser + WebDriver driver2; + + @Override + protected NestedBrokerConfiguration getNestedBrokerConfiguration() { + return OidcBackchannelLogoutBrokerConfiguration.INSTANCE; + } + + @Before + public void createProviderRealmUser() { + log.debug("creating user for realm " + nbc.providerRealmName()); + + final UserRepresentation userProviderRealm = new UserRepresentation(); + userProviderRealm.setUsername(nbc.getUserLogin()); + userProviderRealm.setEmail(nbc.getUserEmail()); + userProviderRealm.setEmailVerified(true); + userProviderRealm.setEnabled(true); + + final RealmResource realmResource = adminClient.realm(nbc.providerRealmName()); + userIdProviderRealm = createUserWithAdminClient(realmResource, userProviderRealm); + + resetUserPassword(realmResource.users().get(userIdProviderRealm), nbc.getUserPassword(), false); + } + + @Before + public void addIdentityProviders() { + log.debug("adding identity provider to realm " + nbc.consumerRealmName()); + RealmResource realm = adminClient.realm(nbc.consumerRealmName()); + realm.identityProviders().create(nbc.setUpIdentityProvider()).close(); + + log.debug("adding identity provider to realm " + nbc.subConsumerRealmName()); + realm = adminClient.realm(nbc.subConsumerRealmName()); + realm.identityProviders().create(nbc.setUpConsumerIdentityProvider()).close(); + } + + @Before + public void addClients() { + addClientsToProviderAndConsumer(); + } + + @Before + public void fetchConsumerRealmDetails() { + RealmResource realmResourceConsumerRealm = adminClient.realm(nbc.consumerRealmName()); + realmIdConsumerRealm = realmResourceConsumerRealm.toRepresentation().getId(); + accountClientIdConsumerRealm = + adminClient.realm(nbc.consumerRealmName()).clients().findByClientId(ACCOUNT_CLIENT_NAME).get(0).getId(); + + RealmResource realmResourceSubConsumerRealm = adminClient.realm(nbc.subConsumerRealmName()); + accountClientIdSubConsumerRealm = + adminClient.realm(nbc.subConsumerRealmName()).clients().findByClientId(ACCOUNT_CLIENT_NAME).get(0) + .getId(); + } + + @Before + public void createNewRsaKeyForProviderRealm() { + providerRealmManager = RealmManager.realm(adminClient.realm(nbc.providerRealmName())); + providerId = providerRealmManager.generateNewRsaKey(KEY_PAIR, "rsa-test-2"); + } + + @Test + public void postBackchannelLogoutWithSessionId() throws Exception { + logInAsUserInIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + + String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionIdConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionIdConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionIdProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + + assertConsumerLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm); + assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionIdConsumerRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionIdProviderRealm); + } + + @Test + public void postBackchannelLogoutWithoutSessionId() throws Exception { + logInAsUserInIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + + String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionIdConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionIdConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + + assertConsumerLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm); + assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionIdConsumerRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionIdProviderRealm); + } + + @Test + public void postBackchannelLogoutWithoutLogoutToken() throws Exception { + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(null)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.BAD_REQUEST)); + assertThat(response, Matchers.bodyHC(containsString("No logout token"))); + } + events.expectLogoutError(Errors.INVALID_TOKEN) + .realm(realmIdConsumerRealm) + .assertEvent(); + } + + @Test + public void postBackchannelLogoutWithInvalidLogoutToken() throws Exception { + String logoutTokenMissingContent = + Base64Url.encode(JsonSerialization.writeValueAsBytes(JsonSerialization.createObjectNode())); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenMissingContent)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.BAD_REQUEST)); + assertThat(response, + Matchers.bodyHC(containsString(LogoutTokenValidationCode.DECODE_TOKEN_FAILED.getErrorMessage()))); + } + events.expectLogoutError(Errors.INVALID_TOKEN) + .realm(realmIdConsumerRealm) + .assertEvent(); + } + + @Test + public void postBackchannelLogoutWithSessionIdUserNotLoggedIn() throws Exception { + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, UUID.randomUUID().toString()); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + } + + @Test + public void postBackchannelLogoutWithoutSessionIdUserNotLoggedIn() throws Exception { + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + } + + @Test + public void postBackchannelLogoutWithoutSessionIdUserDoesntExist() throws Exception { + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(UUID.randomUUID().toString()); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + } + + @Test + public void postBackchannelLogoutWithSessionIdMultipleOpenSession() throws Exception { + logInAsUserInIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + + String sessionId1ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionId1ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId1ConsumerRealm); + + OAuthClient oauth2 = new OAuthClient(); + oauth2.init(driver2); + oauth2.realm(nbc.consumerRealmName()) + .clientId(ACCOUNT_CLIENT_NAME) + .redirectUri(getAuthServerRoot() + "realms/" + nbc.consumerRealmName() + "/account") + .doLoginSocial(nbc.getIDPAlias(), nbc.getUserLogin(), nbc.getUserPassword()); + + String sessionId2ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionId2ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId2ConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionId1ProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + + assertConsumerLogoutEvent(sessionId1ConsumerRealm, userIdConsumerRealm); + assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId1ConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId2ConsumerRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionId1ProviderRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionId2ProviderRealm); + } + + @Test + public void postBackchannelLogoutWithoutSessionIdMultipleOpenSession() throws Exception { + logInAsUserInIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + + String sessionId1ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionId1ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId1ConsumerRealm); + + loginWithSecondBrowser(nbc.getIDPAlias()); + + String sessionId2ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionId2ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId2ConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + + List expectedSessionIdsInLogoutEvents = Arrays.asList(sessionId1ConsumerRealm, sessionId2ConsumerRealm); + assertConsumerLogoutEvents(expectedSessionIdsInLogoutEvents, userIdConsumerRealm); + assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId1ConsumerRealm); + assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId2ConsumerRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionId1ProviderRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionId2ProviderRealm); + } + + @Test + public void postBackchannelLogoutWithSessionIdMultipleOpenSessionDifferentIdentityProvider() throws Exception { + + IdentityProviderRepresentation identityProvider2 = addSecondIdentityProviderToConsumerRealm(); + + logInAsUserInIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + adminClient.realm(nbc.consumerRealmName()).users().get(userIdConsumerRealm) + .resetPassword(CredentialBuilder.create().password(USER_PASSWORD_CONSUMER_REALM).build()); + + String sessionId1ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionId1ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId1ConsumerRealm); + + OAuthClient oauth2 = loginWithSecondBrowser(identityProvider2.getDisplayName()); + linkUsers(oauth2); + + String sessionId2ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionId2ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId2ConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionId1ProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + + assertConsumerLogoutEvent(sessionId1ConsumerRealm, userIdConsumerRealm); + assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId1ConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId2ConsumerRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionId1ProviderRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionId2ProviderRealm); + } + + @Test + public void postBackchannelLogoutWithoutSessionIdMultipleOpenSessionDifferentIdentityProvider() throws Exception { + + IdentityProviderRepresentation identityProvider2 = addSecondIdentityProviderToConsumerRealm(); + + logInAsUserInIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + adminClient.realm(nbc.consumerRealmName()).users().get(userIdConsumerRealm) + .resetPassword(CredentialBuilder.create().password(USER_PASSWORD_CONSUMER_REALM).build()); + + String sessionId1ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionId1ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId1ConsumerRealm); + + OAuthClient oauth2 = loginWithSecondBrowser(identityProvider2.getDisplayName()); + linkUsers(oauth2); + + String sessionId2ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionId2ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId2ConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + + List expectedSessionIdsInLogoutEvents = Arrays.asList(sessionId1ConsumerRealm, sessionId2ConsumerRealm); + assertConsumerLogoutEvents(expectedSessionIdsInLogoutEvents, userIdConsumerRealm); + assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId1ConsumerRealm); + assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionId2ConsumerRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionId1ProviderRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionId2ProviderRealm); + } + + @Test + public void postBackchannelLogoutOnDisabledClientReturnsNotImplemented() throws Exception { + logInAsUserInIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + + String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + String sessionIdConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm); + assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm, + sessionIdConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionIdProviderRealm); + + disableClient(nbc.consumerRealmName(), accountClientIdConsumerRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.NOT_IMPLEMENTED)); + assertThat(response, Matchers.bodyHC(containsString("There was an error in the local logout"))); + } + + assertLogoutErrorEvent(nbc.consumerRealmName()); + } + + @Test + public void postBackchannelLogoutNestedBrokering() throws Exception { + String consumerClientId = OidcBackchannelLogoutBrokerConfiguration.CONSUMER_CLIENT_ID; + + logInAsUserInNestedIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + String userIdSubConsumerRealm = getUserIdSubConsumerRealm(); + String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + + String sessionIdConsumerRealm = assertConsumerLoginEvent(userIdConsumerRealm, consumerClientId); + assertActiveSessionInClient(nbc.consumerRealmName(), consumerClientId, userIdConsumerRealm, + sessionIdConsumerRealm); + + String sessionIdSubConsumerRealm = + assertLoginEvent(userIdSubConsumerRealm, ACCOUNT_CLIENT_NAME, nbc.subConsumerRealmName()); + assertActiveSessionInClient(nbc.subConsumerRealmName(), accountClientIdSubConsumerRealm, userIdSubConsumerRealm, + sessionIdSubConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionIdProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK)); + } + + assertConsumerLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm); + assertLogoutEvent(sessionIdSubConsumerRealm, userIdSubConsumerRealm, nbc.subConsumerRealmName()); + + assertNoSessionsInClient(nbc.consumerRealmName(), consumerClientId, userIdConsumerRealm, + sessionIdConsumerRealm); + assertNoSessionsInClient(nbc.subConsumerRealmName(), accountClientIdSubConsumerRealm, userIdSubConsumerRealm, + sessionIdSubConsumerRealm); + assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm, + sessionIdProviderRealm); + } + + @Test + public void postBackchannelLogoutNestedBrokeringDownstreamLogoutOfSubConsumerFails() throws Exception { + String consumerClientId = OidcBackchannelLogoutBrokerConfiguration.CONSUMER_CLIENT_ID; + + logInAsUserInNestedIDPForFirstTime(); + String userIdConsumerRealm = getUserIdConsumerRealm(); + String userIdSubConsumerRealm = getUserIdSubConsumerRealm(); + String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm); + + String sessionIdConsumerRealm = assertConsumerLoginEvent(userIdConsumerRealm, consumerClientId); + assertActiveSessionInClient(nbc.consumerRealmName(), consumerClientId, userIdConsumerRealm, + sessionIdConsumerRealm); + + String sessionIdSubConsumerRealm = + assertLoginEvent(userIdSubConsumerRealm, ACCOUNT_CLIENT_NAME, nbc.subConsumerRealmName()); + assertActiveSessionInClient(nbc.subConsumerRealmName(), accountClientIdSubConsumerRealm, userIdSubConsumerRealm, + sessionIdSubConsumerRealm); + + disableClient(nbc.subConsumerRealmName(), accountClientIdSubConsumerRealm); + + String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionIdProviderRealm); + + oauth.realm(nbc.consumerRealmName()); + try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.GATEWAY_TIMEOUT)); + } + + assertLogoutErrorEvent(nbc.subConsumerRealmName()); + assertConsumerLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm); + + assertNoSessionsInClient(nbc.consumerRealmName(), consumerClientId, userIdConsumerRealm, + sessionIdConsumerRealm); + } + + private String getLogoutTokenEncodedAndSigned(String userId) throws IOException { + return getLogoutTokenEncodedAndSigned(userId, null); + } + + private String getLogoutTokenEncodedAndSigned(String userId, String sessionId) throws IOException { + String keyId = adminClient.realm(nbc.providerRealmName()) + .keys().getKeyMetadata().getKeys().stream() + .filter(key -> providerId.equals(key.getProviderId())) + .findFirst().get() + .getKid(); + + return LogoutTokenUtil.generateSignedLogoutToken(KEY_PAIR.getPrivate(), + keyId, + getConsumerRoot() + "/auth/realms/" + nbc.providerRealmName(), + nbc.getIDPClientIdInProviderRealm(), + userId, + sessionId); + } + + private String assertConsumerLoginEventAccountManagement(String userIdConsumerRealm) { + return assertConsumerLoginEvent(userIdConsumerRealm, ACCOUNT_CLIENT_NAME); + } + + private String assertConsumerLoginEvent(String userIdConsumerRealm, String clientId) { + return assertLoginEvent(userIdConsumerRealm, clientId, nbc.consumerRealmName()); + } + + private String assertLoginEvent(String userId, String clientId, String realmName) { + String sessionId = null; + String realmId = adminClient.realm(realmName).toRepresentation().getId(); + + List eventList = adminClient.realm(realmName).getEvents(); + + Optional loginEventOptional = eventList.stream() + .filter(event -> userId.equals(event.getUserId())) + .filter(event -> event.getType().equals(EventType.LOGIN.name())) + .findAny(); + + if (loginEventOptional.isPresent()) { + EventRepresentation loginEvent = loginEventOptional.get(); + this.events.expectLogin() + .realm(realmId) + .client(clientId) + .user(userId) + .removeDetail(Details.CODE_ID) + .removeDetail(Details.REDIRECT_URI) + .removeDetail(Details.CONSENT) + .assertEvent(loginEvent); + sessionId = loginEvent.getSessionId(); + } else { + fail("No Login event found for user " + userId); + } + + return sessionId; + } + + private String assertProviderLoginEventIdpClient(String userIdProviderRealm) { + return assertLoginEvent(userIdProviderRealm, BROKER_CLIENT_ID, nbc.providerRealmName()); + } + + private void assertConsumerLogoutEvent(String sessionIdConsumerRealm, String userIdConsumerRealm) { + assertLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm, nbc.consumerRealmName()); + } + + private void assertLogoutEvent(String sessionId, String userId, String realmName) { + + String realmId = adminClient.realm(realmName).toRepresentation().getId(); + + List eventList = adminClient.realm(realmName).getEvents(); + + Optional logoutEventOptional = eventList.stream() + .filter(event -> sessionId.equals(event.getSessionId())) + .findAny(); + + if (logoutEventOptional.isPresent()) { + EventRepresentation logoutEvent = logoutEventOptional.get(); + this.events.expectLogout(sessionId) + .realm(realmId) + .user(userId) + .removeDetail(Details.REDIRECT_URI) + .assertEvent(logoutEvent); + } else { + fail("No Logout event found for session " + sessionId); + } + } + + private void assertLogoutErrorEvent(String realmName) { + + String realmId = adminClient.realm(realmName).toRepresentation().getId(); + + List eventList = adminClient.realm(realmName).getEvents(); + + Optional logoutErrorEventOptional = eventList.stream() + .filter(event -> event.getError().equals(Errors.LOGOUT_FAILED)) + .findAny(); + + if (logoutErrorEventOptional.isPresent()) { + EventRepresentation logoutEvent = logoutErrorEventOptional.get(); + this.events.expectLogoutError(Errors.LOGOUT_FAILED) + .realm(realmId) + .assertEvent(logoutEvent); + } else { + fail("No Logout error event found in realm " + realmName); + } + } + + private void assertConsumerLogoutEvents(List sessionIdsConsumerRealm, String userIdConsumerRealm) { + + List consumerRealmEvents = adminClient.realm(nbc.consumerRealmName()).getEvents(); + + for (String sessionId : sessionIdsConsumerRealm) { + Optional logoutEventOptional = consumerRealmEvents.stream() + .filter(event -> sessionId.equals(event.getSessionId())) + .findAny(); + + if (logoutEventOptional.isPresent()) { + EventRepresentation logoutEvent = logoutEventOptional.get(); + this.events.expectLogout(sessionId) + .realm(realmIdConsumerRealm) + .user(userIdConsumerRealm) + .removeDetail(Details.REDIRECT_URI) + .assertEvent(logoutEvent); + } else { + fail("No Logout event found for session " + sessionId); + } + } + } + + private String getUserIdConsumerRealm() { + return getUserId(nbc.consumerRealmName()); + } + + private String getUserIdSubConsumerRealm() { + return getUserId(nbc.subConsumerRealmName()); + } + + private String getUserId(String realmName) { + RealmResource realmResourceConsumerRealm = adminClient.realm(realmName); + return realmResourceConsumerRealm.users().list().get(0).getId(); + } + + private void assertActiveSessionInClient(String realmName, String clientId, String userId, + String sessionId) { + List sessions = getClientSessions(realmName, clientId, userId, sessionId); + assertThat(sessions.size(), is(1)); + } + + private void assertNoSessionsInClient(String realmName, String clientId, String userId, String sessionId) { + List sessions = getClientSessions(realmName, clientId, userId, sessionId); + assertThat(sessions.size(), is(0)); + } + + private List getClientSessions(String realmName, String clientId, String userId, + String sessionId) { + return adminClient.realm(realmName) + .clients() + .get(clientId) + .getUserSessions(0, 5) + .stream() + .filter(s -> s.getUserId().equals(userId) && s.getId().equals(sessionId)) + .collect(Collectors.toList()); + } + + private IdentityProviderRepresentation addSecondIdentityProviderToConsumerRealm() { + log.debug("adding second identity provider to realm " + nbc.consumerRealmName()); + + IdentityProviderRepresentation identityProvider2 = nbc.setUpIdentityProvider(); + identityProvider2.setAlias(identityProvider2.getAlias() + "2"); + identityProvider2.setDisplayName(identityProvider2.getDisplayName() + "2"); + Map config = identityProvider2.getConfig(); + config.put("clientId", BROKER_CLIENT_ID); + adminClient.realm(nbc.consumerRealmName()).identityProviders().create(identityProvider2).close(); + + ClientResource ipdClientResource = adminClient.realm(nbc.providerRealmName()).clients() + .get(nbc.getIDPClientIdInProviderRealm()); + ClientRepresentation clientRepresentation = ipdClientResource.toRepresentation(); + clientRepresentation.getRedirectUris().add(getConsumerRoot() + "/auth/realms/" + nbc.consumerRealmName() + + "/broker/" + identityProvider2.getAlias() + "/endpoint/*"); + ipdClientResource.update(clientRepresentation); + + return identityProvider2; + } + + private void disableClient(String realmName, String clientId) { + ClientResource accountClient = adminClient.realm(realmName) + .clients() + .get(clientId); + ClientRepresentation clientRepresentation = accountClient.toRepresentation(); + clientRepresentation.setEnabled(false); + accountClient.update(clientRepresentation); + } + + private OAuthClient loginWithSecondBrowser(String identityProviderDisplayName) { + OAuthClient oauth2 = new OAuthClient(); + oauth2.init(driver2); + oauth2.realm(nbc.consumerRealmName()) + .clientId(ACCOUNT_CLIENT_NAME) + .redirectUri(getAuthServerRoot() + "realms/" + nbc.consumerRealmName() + "/account") + .doLoginSocial(identityProviderDisplayName, nbc.getUserLogin(), nbc.getUserPassword()); + return oauth2; + } + + private void linkUsers(OAuthClient oauth) { + oauth.updateAccountInformation(nbc.getUserLogin(), nbc.getUserEmail()); + oauth.linkUsers(nbc.getUserLogin(), USER_PASSWORD_CONSUMER_REALM); + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java index 0ea7a99d38..28c73f2508 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java @@ -162,6 +162,9 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest { // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2 Assert.assertTrue(oidcConfig.getTlsClientCertificateBoundAccessTokens()); + Assert.assertTrue(oidcConfig.getBackchannelLogoutSupported()); + Assert.assertTrue(oidcConfig.getBackchannelLogoutSessionSupported()); + // Token Revocation assertEquals(oidcConfig.getRevocationEndpoint(), oauth.getTokenRevocationUrl()); Assert.assertNames(oidcConfig.getRevocationEndpointAuthMethodsSupported(), "client_secret_basic", diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/LogoutTokenUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/LogoutTokenUtil.java new file mode 100644 index 0000000000..dce1172aae --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/LogoutTokenUtil.java @@ -0,0 +1,53 @@ +package org.keycloak.testsuite.util; + +import org.apache.http.entity.ContentType; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.util.Base64Url; +import org.keycloak.crypto.JavaAlgorithm; +import org.keycloak.jose.jws.Algorithm; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.representations.LogoutToken; +import org.keycloak.util.JsonSerialization; +import org.keycloak.util.TokenUtil; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.HashMap; +import java.util.UUID; + +public class LogoutTokenUtil { + + public static String generateSignedLogoutToken(PrivateKey privateKey, String keyId, + String issuer, String clientId, String userId, String sessionId) throws IOException { + JWSHeader jwsHeader = + new JWSHeader(Algorithm.RS256, OAuth2Constants.JWT, ContentType.APPLICATION_JSON.toString(), keyId); + String logoutTokenHeaderEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(jwsHeader)); + + LogoutToken logoutToken = new LogoutToken(); + logoutToken.setSid(sessionId); + logoutToken.putEvents(TokenUtil.TOKEN_BACKCHANNEL_LOGOUT_EVENT, new HashMap<>()); + logoutToken.setSubject(userId); + logoutToken.issuer(issuer); + logoutToken.id(UUID.randomUUID().toString()); + logoutToken.issuedNow(); + logoutToken.audience(clientId); + + String logoutTokenPayloadEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(logoutToken)); + + try { + Signature signature = Signature.getInstance(JavaAlgorithm.RS256); + signature.initSign(privateKey); + String data = logoutTokenHeaderEncoded + "." + logoutTokenPayloadEncoded; + byte[] dataByteArray = data.getBytes(); + signature.update(dataByteArray); + byte[] signatureByteArray = signature.sign(); + return data + "." + Base64Url.encode(signatureByteArray); + } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { + return null; + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmManager.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmManager.java index 7a4d79ad4d..9d70b075a6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmManager.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmManager.java @@ -1,14 +1,28 @@ package org.keycloak.testsuite.util; +import org.keycloak.admin.client.resource.ComponentResource; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.common.util.Base64; import org.keycloak.common.util.CertificateUtils; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.PemUtils; +import org.keycloak.crypto.KeyType; +import org.keycloak.keys.Attributes; +import org.keycloak.keys.ImportedRsaKeyProviderFactory; +import org.keycloak.keys.KeyProvider; +import org.keycloak.representations.idm.ComponentRepresentation; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; import java.security.cert.X509Certificate; +import java.util.List; +import java.util.stream.Collectors; + +import javax.ws.rs.core.Response; /** * @author Bruno Oliveira. @@ -92,6 +106,48 @@ public class RealmManager { realm.update(rep); } + public String generateNewRsaKey(KeyPair keyPair, String name) { + RealmRepresentation rep = realm.toRepresentation(); + + Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate(keyPair, "test"); + String certificatePem = PemUtils.encodeCertificate(certificate); + + ComponentRepresentation keyProviderRepresentation = new ComponentRepresentation(); + keyProviderRepresentation.setName(name); + keyProviderRepresentation.setParentId(rep.getId()); + keyProviderRepresentation.setProviderId(ImportedRsaKeyProviderFactory.ID); + keyProviderRepresentation.setProviderType(KeyProvider.class.getName()); + + MultivaluedHashMap config = new MultivaluedHashMap<>(); + config.putSingle(Attributes.PRIVATE_KEY_KEY, PemUtils.encodeKey(keyPair.getPrivate())); + config.putSingle(Attributes.CERTIFICATE_KEY, certificatePem); + config.putSingle(Attributes.PRIORITY_KEY, "100"); + keyProviderRepresentation.setConfig(config); + + Response response = realm.components().add(keyProviderRepresentation); + String providerId = ApiUtil.getCreatedId(response); + response.close(); + + deactivateOtherRsaKeys(providerId); + + return providerId; + } + + private void deactivateOtherRsaKeys(String providerId) { + List otherRsaKeyProviderIds = realm.keys() + .getKeyMetadata().getKeys().stream() + .filter(key -> KeyType.RSA.equals(key.getType()) && !providerId.equals(key.getProviderId())) + .map(key -> key.getProviderId()) + .collect(Collectors.toList()); + + for (String otherRsaKeyProviderId : otherRsaKeyProviderIds) { + ComponentResource componentResource = realm.components().component(otherRsaKeyProviderId); + ComponentRepresentation componentRepresentation = componentResource.toRepresentation(); + componentRepresentation.getConfig().putSingle(Attributes.ACTIVE_KEY, "false"); + componentResource.update(componentRepresentation); + } + } + public void ssoSessionMaxLifespan(int ssoSessionMaxLifespan) { RealmRepresentation rep = realm.toRepresentation(); rep.setSsoSessionMaxLifespan(ssoSessionMaxLifespan); diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 8a116273cb..216efed1a1 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -353,6 +353,10 @@ idp-sso-relay-state=IDP Initiated SSO Relay State idp-sso-relay-state.tooltip=Relay state you want to send with SAML request when you want to do IDP Initiated SSO. web-origins=Web Origins web-origins.tooltip=Allowed CORS origins. To permit all origins of Valid Redirect URIs, add '+'. This does not include the '*' wildcard though. To permit all origins, explicitly add '*'. +backchannel-logout-url=Backchannel Logout URL +backchannel-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 omitted, no logout request will be sent to the client is this case. +backchannel-logout-session-required=Backchannel Logout Session Required +backchannel-logout-session-required.tooltip=Specifying whether a sid (session ID) Claim is included in the Logout Token when the Backchannel Logout URL is used. fine-oidc-endpoint-conf=Fine Grain OpenID Connect Configuration fine-oidc-endpoint-conf.tooltip=Expand this section to configure advanced settings of this client related to OpenID Connect protocol access-token-signed-response-alg=Access Token Signature Algorithm diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js index b06ee78de1..ce1d1d74ee 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js @@ -1290,6 +1290,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.displayOnConsentScreen = false; } } + + if ($scope.client.attributes["backchannel.logout.session.required"]) { + if ($scope.client.attributes["backchannel.logout.session.required"] == "true") { + $scope.backchannelLogoutSessionRequired = true; + } else { + $scope.backchannelLogoutSessionRequired = false; + } + } } if (!$scope.create) { @@ -1618,6 +1626,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.clientEdit.attributes["display.on.consent.screen"] = "false"; } + if ($scope.backchannelLogoutSessionRequired == true) { + $scope.clientEdit.attributes["backchannel.logout.session.required"] = "true"; + } else { + $scope.clientEdit.attributes["backchannel.logout.session.required"] = "false"; + } + $scope.clientEdit.protocol = $scope.protocol; $scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm; $scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat; diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index c2130d2a18..eb7d50a696 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -359,6 +359,20 @@ {{:: 'web-origins.tooltip' | translate}} +
+ +
+ +
+ {{:: 'backchannel-logout-url.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'backchannel-logout-session-required.tooltip' | translate}} +
{{:: 'fine-saml-endpoint-conf' | translate}} {{:: 'fine-saml-endpoint-conf.tooltip' | translate}}