KEYCLOAK-2940 - draft - Backchannel Logout (#7272)

* KEYCLOAK-2940 Backchannel Logout

Co-authored-by: Benjamin Weimer <external.Benjamin.Weimer@bosch-si.com>
Co-authored-by: David Hellwig <hed4be@bosch.com>
This commit is contained in:
David Hellwig 2020-08-12 14:07:58 +02:00 committed by GitHub
parent 4ff34c1be9
commit ddc2c25951
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 2057 additions and 91 deletions

View file

@ -50,6 +50,8 @@ public interface OAuth2Constants {
String REFRESH_TOKEN = "refresh_token"; String REFRESH_TOKEN = "refresh_token";
String LOGOUT_TOKEN = "logout_token";
String AUTHORIZATION_CODE = "authorization_code"; String AUTHORIZATION_CODE = "authorization_code";

View file

@ -21,5 +21,6 @@ public enum TokenCategory {
ACCESS, ACCESS,
ID, ID,
ADMIN, ADMIN,
USERINFO USERINFO,
LOGOUT
} }

View file

@ -52,6 +52,12 @@ public class JWSHeader implements Serializable {
this.contentType = contentType; 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() { public Algorithm getAlgorithm() {
return algorithm; return algorithm;
} }

View file

@ -127,6 +127,12 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("revocation_endpoint_auth_signing_alg_values_supported") @JsonProperty("revocation_endpoint_auth_signing_alg_values_supported")
private List<String> revocationEndpointAuthSigningAlgValuesSupported; private List<String> revocationEndpointAuthSigningAlgValuesSupported;
@JsonProperty("backchannel_logout_supported")
private Boolean backchannelLogoutSupported;
@JsonProperty("backchannel_logout_session_supported")
private Boolean backchannelLogoutSessionSupported;
protected Map<String, Object> otherClaims = new HashMap<String, Object>(); protected Map<String, Object> otherClaims = new HashMap<String, Object>();
public String getIssuer() { public String getIssuer() {
@ -380,6 +386,22 @@ public class OIDCConfigurationRepresentation {
this.revocationEndpointAuthSigningAlgValuesSupported = revocationEndpointAuthSigningAlgValuesSupported; 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 @JsonAnyGetter
public Map<String, Object> getOtherClaims() { public Map<String, Object> getOtherClaims() {
return otherClaims; return otherClaims;
@ -389,5 +411,4 @@ public class OIDCConfigurationRepresentation {
public void setOtherClaims(String name, Object value) { public void setOtherClaims(String name, Object value) {
otherClaims.put(name, value); otherClaims.put(name, value);
} }
} }

View file

@ -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<String, Object> events = new HashMap<>();
public Map<String, Object> 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;
}
}

View file

@ -119,6 +119,10 @@ public class OIDCClientRepresentation {
private String registration_access_token; private String registration_access_token;
private String backchannel_logout_uri;
private Boolean backchannel_logout_session_required;
public List<String> getRedirectUris() { public List<String> getRedirectUris() {
return redirect_uris; return redirect_uris;
} }
@ -449,6 +453,22 @@ public class OIDCClientRepresentation {
this.tls_client_certificate_bound_access_tokens = tls_client_certificate_bound_access_tokens; 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() { public String getTlsClientAuthSubjectDn() {
return tls_client_auth_subject_dn; return tls_client_auth_subject_dn;
} }

View file

@ -48,6 +48,9 @@ public class TokenUtil {
public static final String TOKEN_TYPE_OFFLINE = "Offline"; 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) { public static String attachOIDCScope(String scopeParam) {
if (scopeParam == null || scopeParam.isEmpty()) { if (scopeParam == null || scopeParam.isEmpty()) {

View file

@ -93,5 +93,6 @@ public interface Errors {
String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider"; String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider";
String ILLEGAL_ORIGIN = "illegal_origin"; String ILLEGAL_ORIGIN = "illegal_origin";
String DISPLAY_UNSUPPORTED = "display_unsupported"; String DISPLAY_UNSUPPORTED = "display_unsupported";
String LOGOUT_FAILED = "logout_failed";
} }

View file

@ -80,7 +80,7 @@ public interface LoginProtocol extends Provider {
Response sendError(AuthenticationSessionModel authSession, Error error); 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 frontchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession);
Response finishLogout(UserSessionModel userSession); Response finishLogout(UserSessionModel userSession);

View file

@ -103,7 +103,6 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
void setBaseUrl(String url); void setBaseUrl(String url);
boolean isBearerOnly(); boolean isBearerOnly();
void setBearerOnly(boolean only); void setBearerOnly(boolean only);

View file

@ -18,6 +18,7 @@ package org.keycloak.models;
import org.keycloak.Token; import org.keycloak.Token;
import org.keycloak.TokenCategory; import org.keycloak.TokenCategory;
import org.keycloak.representations.LogoutToken;
public interface TokenManager { public interface TokenManager {
@ -46,4 +47,6 @@ public interface TokenManager {
String encodeAndEncrypt(Token token); String encodeAndEncrypt(Token token);
String cekManagementAlgorithm(TokenCategory category); String cekManagementAlgorithm(TokenCategory category);
String encryptAlgorithm(TokenCategory category); String encryptAlgorithm(TokenCategory category);
LogoutToken initLogoutToken(ClientModel client, UserModel user, AuthenticatedClientSessionModel clientSessionModel);
} }

View file

@ -530,7 +530,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
} }
} }
protected JsonWebToken validateToken(String encodedToken) { public JsonWebToken validateToken(String encodedToken) {
boolean ignoreAudience = false; boolean ignoreAudience = false;
return validateToken(encodedToken, ignoreAudience); return validateToken(encodedToken, ignoreAudience);
@ -602,7 +602,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
@Override @Override
public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) { public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) {
if (!supportsExternalExchange()) return false; 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 == null) requestedIssuer = issuer;
if (requestedIssuer.equals(getConfig().getAlias())) return true; if (requestedIssuer.equals(getConfig().getAlias())) return true;

View file

@ -16,9 +16,6 @@
*/ */
package org.keycloak.jose.jws; package org.keycloak.jose.jws;
import java.io.UnsupportedEncodingException;
import java.security.Key;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.Token; import org.keycloak.Token;
import org.keycloak.TokenCategory; 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.jwe.enc.JWEEncryptionProvider;
import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWK;
import org.keycloak.keys.loader.PublicKeyStorageManager; import org.keycloak.keys.loader.PublicKeyStorageManager;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.TokenManager; 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.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.LogoutToken;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil; import org.keycloak.util.TokenUtil;
import java.io.UnsupportedEncodingException;
import java.security.Key;
public class DefaultTokenManager implements TokenManager { public class DefaultTokenManager implements TokenManager {
private static final Logger logger = Logger.getLogger(DefaultTokenManager.class); private static final Logger logger = Logger.getLogger(DefaultTokenManager.class);
@ -129,6 +136,7 @@ public class DefaultTokenManager implements TokenManager {
case ACCESS: case ACCESS:
return getSignatureAlgorithm(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG); return getSignatureAlgorithm(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG);
case ID: case ID:
case LOGOUT:
return getSignatureAlgorithm(OIDCConfigAttributes.ID_TOKEN_SIGNED_RESPONSE_ALG); return getSignatureAlgorithm(OIDCConfigAttributes.ID_TOKEN_SIGNED_RESPONSE_ALG);
case USERINFO: case USERINFO:
return getSignatureAlgorithm(OIDCConfigAttributes.USER_INFO_RESPONSE_SIGNATURE_ALG); return getSignatureAlgorithm(OIDCConfigAttributes.USER_INFO_RESPONSE_SIGNATURE_ALG);
@ -202,6 +210,7 @@ public class DefaultTokenManager implements TokenManager {
if (category == null) return null; if (category == null) return null;
switch (category) { switch (category) {
case ID: case ID:
case LOGOUT:
return getCekManagementAlgorithm(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ALG); return getCekManagementAlgorithm(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ALG);
default: default:
return null; return null;
@ -222,6 +231,7 @@ public class DefaultTokenManager implements TokenManager {
if (category == null) return null; if (category == null) return null;
switch (category) { switch (category) {
case ID: case ID:
case LOGOUT:
return getEncryptAlgorithm(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ENC); return getEncryptAlgorithm(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ENC);
default: default:
return null; return null;
@ -236,4 +246,21 @@ public class DefaultTokenManager implements TokenManager {
} }
return null; 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;
}
} }

View file

@ -156,9 +156,8 @@ public class DockerAuthV2Protocol implements LoginProtocol {
} }
@Override @Override
public void backchannelLogout(final UserSessionModel userSession, final AuthenticatedClientSessionModel clientSession) { public Response backchannelLogout(final UserSessionModel userSession, final AuthenticatedClientSessionModel clientSession) {
errorResponse(userSession, "backchannelLogout"); return errorResponse(userSession, "backchannelLogout");
} }
@Override @Override

View file

@ -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<DownStreamBackchannelLogoutResponse> clientResponses = new ArrayList<>();
public List<DownStreamBackchannelLogoutResponse> 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<Integer> getResponseCode() {
return Optional.ofNullable(responseCode);
}
public void setResponseCode(Integer responseCode) {
this.responseCode = responseCode;
}
}
}

View file

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

View file

@ -166,6 +166,24 @@ public class OIDCAdvancedConfigWrapper {
setAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, algName); 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) { private String getAttribute(String attrKey) {
if (clientModel != null) { if (clientModel != null) {
return clientModel.getAttribute(attrKey); return clientModel.getAttribute(attrKey);

View file

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

View file

@ -29,7 +29,6 @@ import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.headers.SecurityHeadersProvider; import org.keycloak.headers.SecurityHeadersProvider;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.BrowserSecurityHeaders;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext; import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants; 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.services.managers.ResourceAdminManager;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.TokenUtil; import org.keycloak.util.TokenUtil;
import org.keycloak.utils.MediaType;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
@ -312,9 +310,13 @@ public class OIDCLoginProtocol implements LoginProtocol {
} }
@Override @Override
public void backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { public Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
ClientModel client = clientSession.getClient(); 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 @Override

View file

@ -391,6 +391,10 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
if (rep.isImplicitFlowEnabled() == null) newClient.setImplicitFlowEnabled(false); if (rep.isImplicitFlowEnabled() == null) newClient.setImplicitFlowEnabled(false);
if (rep.isPublicClient() == null) newClient.setPublicClient(true); if (rep.isPublicClient() == null) newClient.setPublicClient(true);
if (rep.isFrontchannelLogout() == null) newClient.setFrontchannelLogout(false); if (rep.isFrontchannelLogout() == null) newClient.setFrontchannelLogout(false);
if (OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).getBackchannelLogoutUrl() == null){
OIDCAdvancedConfigWrapper oidcAdvancedConfigWrapper = OIDCAdvancedConfigWrapper.fromClientModel(newClient);
oidcAdvancedConfigWrapper.setBackchannelLogoutSessionRequired(true);
}
} }
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.protocol.oidc; package org.keycloak.protocol.oidc;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.ResteasyProviderFactory; 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.resources.RealmsResource;
import org.keycloak.services.util.CacheControlUtil; import org.keycloak.services.util.CacheControlUtil;
import java.util.LinkedList;
import java.util.List;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException; import javax.ws.rs.NotFoundException;
import javax.ws.rs.OPTIONS; 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.Response;
import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo; import javax.ws.rs.core.UriInfo;
import java.util.LinkedList;
import java.util.List;
/** /**
* Resource class for the oauth/openid connect token service * Resource class for the oauth/openid connect token service
@ -70,6 +72,8 @@ import java.util.List;
*/ */
public class OIDCLoginProtocolService { public class OIDCLoginProtocolService {
private static final Logger logger = Logger.getLogger(OIDCLoginProtocolService.class);
private RealmModel realm; private RealmModel realm;
private TokenManager tokenManager; private TokenManager tokenManager;
private EventBuilder event; private EventBuilder event;
@ -240,6 +244,8 @@ public class OIDCLoginProtocolService {
return endpoint; return endpoint;
} }
/* old deprecated logout endpoint needs to be removed in the future
* https://issues.redhat.com/browse/KEYCLOAK-2940 */
@Path("logout") @Path("logout")
public Object logout() { public Object logout() {
LogoutEndpoint endpoint = new LogoutEndpoint(tokenManager, realm, event); LogoutEndpoint endpoint = new LogoutEndpoint(tokenManager, realm, event);

View file

@ -143,6 +143,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
config.setRevocationEndpointAuthSigningAlgValuesSupported(getSupportedClientSigningAlgorithms(false)); config.setRevocationEndpointAuthSigningAlgValuesSupported(getSupportedClientSigningAlgorithms(false));
} }
config.setBackchannelLogoutSupported(true);
config.setBackchannelLogoutSessionSupported(true);
return config; return config;
} }

View file

@ -21,8 +21,13 @@ import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
import org.keycloak.Token;
import org.keycloak.TokenCategory; import org.keycloak.TokenCategory;
import org.keycloak.TokenVerifier; 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.cluster.ClusterProvider;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.VerificationException; import org.keycloak.common.VerificationException;
@ -32,6 +37,7 @@ import org.keycloak.crypto.SignatureProvider;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException; import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.jose.jws.crypto.HashUtils; import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.migration.migrators.MigrationUtils; import org.keycloak.migration.migrators.MigrationUtils;
@ -39,6 +45,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext; import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel; 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.OIDCAccessTokenMapper;
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper; import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper; import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.IDToken; import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.RefreshToken; import org.keycloak.representations.RefreshToken;
import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.managers.UserSessionCrossDCManager; import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.services.util.DefaultClientSessionContext; import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.services.util.MtlsHoKTokenUtil; import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.TokenUtil; import org.keycloak.util.TokenUtil;
import javax.ws.rs.core.HttpHeaders; import java.util.ArrayList;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; 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 * 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)); return new NotBeforeCheck(session.users().getNotBeforeOfUser(realmModel, userModel));
} }
} }
public LogoutTokenValidationCode verifyLogoutToken(KeycloakSession session, RealmModel realm, String encodedLogoutToken) {
Optional<LogoutToken> logoutTokenOptional = toLogoutToken(encodedLogoutToken);
if (!logoutTokenOptional.isPresent()) {
return LogoutTokenValidationCode.DECODE_TOKEN_FAILED;
}
LogoutToken logoutToken = logoutTokenOptional.get();
List<OIDCIdentityProvider> identityProviders = getOIDCIdentityProviders(realm, session);
if (identityProviders.isEmpty()) {
return LogoutTokenValidationCode.COULD_NOT_FIND_IDP;
}
List<OIDCIdentityProvider> 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<LogoutToken> toLogoutToken(String encodedLogoutToken) {
try {
JWSInput jws = new JWSInput(encodedLogoutToken);
return Optional.of(jws.readJsonContent(LogoutToken.class));
} catch (JWSInputException e) {
return Optional.empty();
}
}
public List<OIDCIdentityProvider> getValidOIDCIdentityProvidersForBackchannelLogout(RealmModel realm, KeycloakSession session, String encodedLogoutToken, LogoutToken logoutToken) {
List<OIDCIdentityProvider> identityProviders = getOIDCIdentityProviders(realm, session);
return validateLogoutTokenAgainstIdpProvider(identityProviders, encodedLogoutToken, logoutToken);
}
public List<OIDCIdentityProvider> validateLogoutTokenAgainstIdpProvider(List<OIDCIdentityProvider> oidcIdps, String encodedLogoutToken, LogoutToken logoutToken) {
List<OIDCIdentityProvider> 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<OIDCIdentityProvider> getOIDCIdentityProviders(RealmModel realm, KeycloakSession session) {
List<OIDCIdentityProvider> 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;
}
} }

View file

@ -34,11 +34,14 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel; 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.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.representations.IDToken; import org.keycloak.representations.IDToken;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.RefreshToken; import org.keycloak.representations.RefreshToken;
import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ErrorResponseException;
@ -52,16 +55,15 @@ import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.MtlsHoKTokenUtil; import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.util.TokenUtil; import org.keycloak.util.TokenUtil;
import javax.ws.rs.Consumes; import javax.ws.rs.*;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context; import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriBuilder;
import java.util.List;
import java.util.stream.Collectors;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -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(); 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<String, String> 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<String> 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<String> 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<String> identityProviderAliases) {
BackchannelLogoutResponse backchannelLogoutResponse = new BackchannelLogoutResponse();
backchannelLogoutResponse.setLocalLogoutSucceeded(true);
for (String identityProviderAlias : identityProviderAliases) {
List<UserSessionModel> 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) { private void logout(UserSessionModel userSession, boolean offline) {
AuthenticationManager.backchannelLogout(session, realm, userSession, session.getContext().getUri(), clientConnection, headers, true, offline); AuthenticationManager.backchannelLogout(session, realm, userSession, session.getContext().getUri(), clientConnection, headers, true, offline);
event.user(userSession.getUser()).session(userSession).success(); event.user(userSession.getUser()).session(userSession).success();

View file

@ -671,13 +671,14 @@ public class SamlProtocol implements LoginProtocol {
} }
@Override @Override
public void backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { public Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
ClientModel client = clientSession.getClient(); ClientModel client = clientSession.getClient();
SamlClient samlClient = new SamlClient(client); SamlClient samlClient = new SamlClient(client);
String logoutUrl = getLogoutServiceUrl(session, client, SAML_POST_BINDING); String logoutUrl = getLogoutServiceUrl(session, client, SAML_POST_BINDING);
if (logoutUrl == null) { if (logoutUrl == null) {
logger.warnf("Can't do backchannel logout. No SingleLogoutService POST Binding registered for client: %s", client.getClientId()); logger.warnf("Can't do backchannel logout. No SingleLogoutService POST Binding registered for client: %s",
return; client.getClientId());
return Response.serverError().build();
} }
String logoutRequestString = null; String logoutRequestString = null;
@ -688,7 +689,7 @@ public class SamlProtocol implements LoginProtocol {
logoutRequestString = binding.postBinding(SAML2Request.convert(logoutRequest)).encoded(); logoutRequestString = binding.postBinding(SAML2Request.convert(logoutRequest)).encoded();
} catch (Exception e) { } catch (Exception e) {
logger.warn("failed to send saml logout", e); logger.warn("failed to send saml logout", e);
return; return Response.serverError().build();
} }
HttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient(); HttpClient httpClient = session.getProvider(HttpClientProvider.class).getHttpClient();
@ -724,10 +725,11 @@ public class SamlProtocol implements LoginProtocol {
} }
} catch (IOException e) { } catch (IOException e) {
logger.warn("failed to send saml logout", e); logger.warn("failed to send saml logout", e);
return Response.serverError().build();
} }
break; break;
} }
return Response.ok().build();
} }
protected LogoutRequestType createLogoutRequest(String logoutUrl, AuthenticatedClientSessionModel clientSession, ClientModel client, NodeGenerator... extensions) throws ConfigurationException { protected LogoutRequestType createLogoutRequest(String logoutUrl, AuthenticatedClientSessionModel clientSession, ClientModel client, NodeGenerator... extensions) throws ConfigurationException {

View file

@ -139,6 +139,14 @@ public class DescriptionConverter {
configWrapper.setTokenEndpointAuthSigningAlg(clientOIDC.getTokenEndpointAuthSigningAlg()); configWrapper.setTokenEndpointAuthSigningAlg(clientOIDC.getTokenEndpointAuthSigningAlg());
configWrapper.setBackchannelLogoutUrl(clientOIDC.getBackchannelLogoutUri());
if (clientOIDC.getBackchannelLogoutSessionRequired() == null) {
configWrapper.setBackchannelLogoutSessionRequired(true);
} else {
configWrapper.setBackchannelLogoutSessionRequired(clientOIDC.getBackchannelLogoutSessionRequired());
}
return client; return client;
} }
@ -234,6 +242,8 @@ public class DescriptionConverter {
if (config.getTokenEndpointAuthSigningAlg() != null) { if (config.getTokenEndpointAuthSigningAlg() != null) {
response.setTokenEndpointAuthSigningAlg(config.getTokenEndpointAuthSigningAlg()); response.setTokenEndpointAuthSigningAlg(config.getTokenEndpointAuthSigningAlg());
} }
response.setBackchannelLogoutUri(config.getBackchannelLogoutUrl());
response.setBackchannelLogoutSessionRequired(config.isBackchannelLogoutSessionRequired());
List<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client); List<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client);
SubjectType subjectType = foundPairwiseMappers.isEmpty() ? SubjectType.PUBLIC : SubjectType.PAIRWISE; SubjectType subjectType = foundPairwiseMappers.isEmpty() ? SubjectType.PUBLIC : SubjectType.PAIRWISE;

View file

@ -64,6 +64,8 @@ import org.keycloak.models.utils.SessionTimeoutHelper;
import org.keycloak.models.utils.SystemClientUtil; import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocol.Error; 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.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.services.ServicesLogger; 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 { try {
// check to see if any identity cookie is set with the same session and expire it if necessary // 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); Cookie cookie = CookieHelper.getCookie(headers.getCookies(), KEYCLOAK_IDENTITY_COOKIE);
if (cookie == null) return; if (cookie == null) return true;
String tokenString = cookie.getValue(); String tokenString = cookie.getValue();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString, AccessToken.class) TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString, AccessToken.class)
@ -189,9 +191,11 @@ public class AuthenticationManager {
AccessToken token = verifier.verify().getToken(); AccessToken token = verifier.verify().getToken();
UserSessionModel cookieSession = session.sessions().getUserSession(realm, token.getSessionState()); 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); expireIdentityCookie(realm, uriInfo, connection);
return true;
} catch (Exception e) { } catch (Exception e) {
return false;
} }
} }
@ -208,11 +212,11 @@ public class AuthenticationManager {
); );
} }
public static void backchannelLogout(KeycloakSession session, RealmModel realm, public static BackchannelLogoutResponse backchannelLogout(KeycloakSession session, RealmModel realm,
UserSessionModel userSession, UriInfo uriInfo, UserSessionModel userSession, UriInfo uriInfo,
ClientConnection connection, HttpHeaders headers, ClientConnection connection, HttpHeaders headers,
boolean logoutBroker) { boolean logoutBroker) {
backchannelLogout(session, realm, userSession, uriInfo, connection, headers, logoutBroker, false); return backchannelLogout(session, realm, userSession, uriInfo, connection, headers, logoutBroker, false);
} }
/** /**
@ -225,26 +229,39 @@ public class AuthenticationManager {
* @param headers * @param headers
* @param logoutBroker * @param logoutBroker
* @param offlineSession * @param offlineSession
*
* @return BackchannelLogoutResponse with logout information
*/ */
public static void backchannelLogout(KeycloakSession session, RealmModel realm, public static BackchannelLogoutResponse backchannelLogout(KeycloakSession session, RealmModel realm,
UserSessionModel userSession, UriInfo uriInfo, UserSessionModel userSession, UriInfo uriInfo,
ClientConnection connection, HttpHeaders headers, ClientConnection connection, HttpHeaders headers,
boolean logoutBroker, boolean logoutBroker,
boolean offlineSession) { boolean offlineSession) {
if (userSession == null) return; BackchannelLogoutResponse backchannelLogoutResponse = new BackchannelLogoutResponse();
if (userSession == null) {
backchannelLogoutResponse.setLocalLogoutSucceeded(true);
return backchannelLogoutResponse;
}
UserModel user = userSession.getUser(); UserModel user = userSession.getUser();
if (userSession.getState() != UserSessionModel.State.LOGGING_OUT) { if (userSession.getState() != UserSessionModel.State.LOGGING_OUT) {
userSession.setState(UserSessionModel.State.LOGGING_OUT); userSession.setState(UserSessionModel.State.LOGGING_OUT);
} }
logger.debugv("Logging out: {0} ({1}) offline: {2}", user.getUsername(), userSession.getId(), userSession.isOffline()); logger.debugv("Logging out: {0} ({1}) offline: {2}", user.getUsername(), userSession.getId(),
userSession.isOffline());
boolean expireUserSessionCookieSucceeded =
expireUserSessionCookie(session, userSession, realm, uriInfo, headers, connection); expireUserSessionCookie(session, userSession, realm, uriInfo, headers, connection);
final AuthenticationSessionManager asm = new AuthenticationSessionManager(session); 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 { try {
backchannelLogoutAll(session, realm, userSession, logoutAuthSession, uriInfo, headers, logoutBroker); backchannelLogoutResponse = backchannelLogoutAll(session, realm, userSession, logoutAuthSession, uriInfo,
headers, logoutBroker);
userSessionOnlyHasLoggedOutClients =
checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession); checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession);
} finally { } finally {
RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession(); RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession();
@ -264,6 +281,9 @@ public class AuthenticationManager {
} else { } else {
session.sessions().removeUserSession(realm, userSession); 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) { private static AuthenticationSessionModel createOrJoinLogoutSession(KeycloakSession session, RealmModel realm, final AuthenticationSessionManager asm, UserSessionModel userSession, boolean browserCookie) {
@ -307,12 +327,29 @@ public class AuthenticationManager {
return logoutAuthSession; return logoutAuthSession;
} }
private static void backchannelLogoutAll(KeycloakSession session, RealmModel realm, private static BackchannelLogoutResponse backchannelLogoutAll(KeycloakSession session, RealmModel realm,
UserSessionModel userSession, AuthenticationSessionModel logoutAuthSession, UriInfo uriInfo, UserSessionModel userSession, AuthenticationSessionModel logoutAuthSession, UriInfo uriInfo,
HttpHeaders headers, boolean logoutBroker) { HttpHeaders headers, boolean logoutBroker) {
userSession.getAuthenticatedClientSessions().values().forEach( BackchannelLogoutResponse backchannelLogoutResponse = new BackchannelLogoutResponse();
clientSession -> backchannelLogoutClientSession(session, realm, clientSession, logoutAuthSession, uriInfo, headers)
); 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) { if (logoutBroker) {
String brokerId = userSession.getNote(Details.IDENTITY_PROVIDER); String brokerId = userSession.getNote(Details.IDENTITY_PROVIDER);
if (brokerId != null) { if (brokerId != null) {
@ -321,9 +358,12 @@ public class AuthenticationManager {
identityProvider.backchannelLogout(session, userSession, uriInfo, realm); identityProvider.backchannelLogout(session, userSession, uriInfo, realm);
} catch (Exception e) { } catch (Exception e) {
logger.warn("Exception at broker backchannel logout for broker " + brokerId, 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. * Logs out the given client session and records the result into {@code logoutAuthSession} if set.
*
* @param session * @param session
* @param realm * @param realm
* @param clientSession * @param clientSession
* @param logoutAuthSession auth session used for recording result of logout. May be {@code null} * @param logoutAuthSession auth session used for recording result of logout. May be {@code null}
* @param uriInfo * @param uriInfo
* @param headers * @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, private static Response backchannelLogoutClientSession(KeycloakSession session, RealmModel realm,
AuthenticatedClientSessionModel clientSession, AuthenticationSessionModel logoutAuthSession, AuthenticatedClientSessionModel clientSession, AuthenticationSessionModel logoutAuthSession,
UriInfo uriInfo, HttpHeaders headers) { UriInfo uriInfo, HttpHeaders headers) {
UserSessionModel userSession = clientSession.getUserSession(); UserSessionModel userSession = clientSession.getUserSession();
ClientModel client = clientSession.getClient(); ClientModel client = clientSession.getClient();
if (client.isFrontchannelLogout() || AuthenticationSessionModel.Action.LOGGED_OUT.name().equals(clientSession.getAction())) { if (client.isFrontchannelLogout()
return false; || AuthenticationSessionModel.Action.LOGGED_OUT.name().equals(clientSession.getAction())) {
return null;
} }
final AuthenticationSessionModel.Action logoutState = getClientLogoutAction(logoutAuthSession, client.getId()); final AuthenticationSessionModel.Action logoutState = getClientLogoutAction(logoutAuthSession, client.getId());
if (logoutState == AuthenticationSessionModel.Action.LOGGED_OUT || logoutState == AuthenticationSessionModel.Action.LOGGING_OUT) { if (logoutState == AuthenticationSessionModel.Action.LOGGED_OUT
return true; || logoutState == AuthenticationSessionModel.Action.LOGGING_OUT) {
return Response.ok().build();
} }
if (!client.isEnabled()) { if (!client.isEnabled()) {
return false; return null;
} }
try { try {
setClientLogoutAction(logoutAuthSession, client.getId(), AuthenticationSessionModel.Action.LOGGING_OUT); setClientLogoutAction(logoutAuthSession, client.getId(), AuthenticationSessionModel.Action.LOGGING_OUT);
String authMethod = clientSession.getProtocol(); 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()); logger.debugv("backchannel logout to: {0}", client.getClientId());
LoginProtocol protocol = session.getProvider(LoginProtocol.class, authMethod); LoginProtocol protocol = session.getProvider(LoginProtocol.class, authMethod);
protocol.setRealm(realm) protocol.setRealm(realm)
.setHttpHeaders(headers) .setHttpHeaders(headers)
.setUriInfo(uriInfo); .setUriInfo(uriInfo);
protocol.backchannelLogout(userSession, clientSession);
Response clientSessionLogout = protocol.backchannelLogout(userSession, clientSession);
setClientLogoutAction(logoutAuthSession, client.getId(), AuthenticationSessionModel.Action.LOGGED_OUT); setClientLogoutAction(logoutAuthSession, client.getId(), AuthenticationSessionModel.Action.LOGGED_OUT);
return true; return clientSessionLogout;
} catch (Exception ex) { } catch (Exception ex) {
ServicesLogger.LOGGER.failedToLogoutClient(ex); ServicesLogger.LOGGER.failedToLogoutClient(ex);
return false; return Response.serverError().build();
} }
} }

View file

@ -16,7 +16,15 @@
*/ */
package org.keycloak.services.managers; 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.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenIdGenerator; import org.keycloak.TokenIdGenerator;
import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.MultivaluedHashMap;
@ -26,19 +34,21 @@ import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.constants.AdapterConstants; import org.keycloak.constants.AdapterConstants;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.TokenManager;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.adapters.action.GlobalRequestResult;
import org.keycloak.representations.adapters.action.LogoutAction; import org.keycloak.representations.adapters.action.LogoutAction;
import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.representations.adapters.action.TestAvailabilityAction;
import org.keycloak.services.ServicesLogger; import org.keycloak.services.ServicesLogger;
import org.keycloak.services.util.ResolveRelative; import org.keycloak.services.util.ResolveRelative;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriBuilder;
import java.io.IOException; import java.io.IOException;
import java.net.URI; 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)); return logoutClientSessions(realm, resource, Arrays.asList(clientSession));
} }
protected boolean logoutClientSessions(RealmModel realm, ClientModel resource, List<AuthenticatedClientSessionModel> clientSessions) { protected Response logoutClientSessions(RealmModel realm, ClientModel resource, List<AuthenticatedClientSessionModel> clientSessions) {
String managementUrl = getManagementUrl(session, resource); String managementUrl = getManagementUrl(session, resource);
if (managementUrl != null) { if (managementUrl != null) {
@ -164,20 +174,19 @@ public class ResourceAdminManager {
if (adapterSessionIds == null || adapterSessionIds.isEmpty()) { if (adapterSessionIds == null || adapterSessionIds.isEmpty()) {
logger.debugv("Can't logout {0}: no logged adapter sessions", resource.getClientId()); logger.debugv("Can't logout {0}: no logged adapter sessions", resource.getClientId());
return false; return null;
} }
if (managementUrl.contains(CLIENT_SESSION_HOST_PROPERTY)) { 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) // Send logout separately to each host (needed for single-sign-out in cluster for non-distributable apps - KEYCLOAK-748)
for (Map.Entry<String, List<String>> entry : adapterSessionIds.entrySet()) { for (Map.Entry<String, List<String>> entry : adapterSessionIds.entrySet()) {
String host = entry.getKey(); String host = entry.getKey();
List<String> sessionIds = entry.getValue(); List<String> sessionIds = entry.getValue();
String currentHostMgmtUrl = managementUrl.replace(CLIENT_SESSION_HOST_PROPERTY, host); 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 { } else {
// Send single logout request // Send single logout request
List<String> allSessionIds = new ArrayList<String>(); List<String> allSessionIds = new ArrayList<String>();
@ -189,7 +198,68 @@ public class ResourceAdminManager {
} }
} else { } else {
logger.debugv("Can't logout {0}: no management url", resource.getClientId()); 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<NameValuePair> 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 // Propagate this to all hosts
GlobalRequestResult result = new GlobalRequestResult(); GlobalRequestResult result = new GlobalRequestResult();
for (String mgmtUrl : mgmtUrls) { for (String mgmtUrl : mgmtUrls) {
if (sendLogoutRequest(realm, resource, null, null, notBefore, mgmtUrl)) { if (sendLogoutRequest(realm, resource, null, null, notBefore, mgmtUrl) != null) {
result.addSuccessRequest(mgmtUrl); result.addSuccessRequest(mgmtUrl);
} else { } else {
result.addFailedRequest(mgmtUrl); result.addFailedRequest(mgmtUrl);
@ -240,7 +310,7 @@ public class ResourceAdminManager {
return result; return result;
} }
protected boolean sendLogoutRequest(RealmModel realm, ClientModel resource, List<String> adapterSessionIds, List<String> userSessions, int notBefore, String managementUrl) { protected Response sendLogoutRequest(RealmModel realm, ClientModel resource, List<String> adapterSessionIds, List<String> userSessions, int notBefore, String managementUrl) {
LogoutAction adminAction = new LogoutAction(TokenIdGenerator.generateId(), Time.currentTime() + 30, resource.getClientId(), adapterSessionIds, notBefore, userSessions); LogoutAction adminAction = new LogoutAction(TokenIdGenerator.generateId(), Time.currentTime() + 30, resource.getClientId(), adapterSessionIds, notBefore, userSessions);
String token = session.tokens().encode(adminAction); String token = session.tokens().encode(adminAction);
if (logger.isDebugEnabled()) logger.debugv("logout resource {0} url: {1} sessionIds: " + adapterSessionIds, resource.getClientId(), managementUrl); 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); int status = session.getProvider(HttpClientProvider.class).postText(target.toString(), token);
boolean success = status == 204 || status == 200; boolean success = status == 204 || status == 200;
logger.debugf("logout success for %s: %s", managementUrl, success); logger.debugf("logout success for %s: %s", managementUrl, success);
return success; return Response.ok().build();
} catch (IOException e) { } catch (IOException e) {
ServicesLogger.LOGGER.logoutFailed(e, resource.getClientId()); ServicesLogger.LOGGER.logoutFailed(e, resource.getClientId());
return false; return Response.serverError().build();
} }
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.validation; package org.keycloak.validation;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.services.util.ResolveRelative; import org.keycloak.services.util.ResolveRelative;
import java.net.MalformedURLException; import java.net.MalformedURLException;
@ -47,8 +48,13 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
String resolvedRootUrl = ResolveRelative.resolveRootUrl(authServerUrl, authServerUrl, client.getRootUrl()); String resolvedRootUrl = ResolveRelative.resolveRootUrl(authServerUrl, authServerUrl, client.getRootUrl());
String resolvedBaseUrl = ResolveRelative.resolveRelativeUri(authServerUrl, authServerUrl, resolvedRootUrl, client.getBaseUrl()); 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); validateRootUrl(resolvedRootUrl);
validateBaseUrl(resolvedBaseUrl); validateBaseUrl(resolvedBaseUrl);
validateBackchannelLogoutUrl(resolvedBackchannelLogoutUrl);
} }
private void validateRootUrl(String rootUrl) throws ValidationException { 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 { private void basicHttpUrlCheck(String field, String url) throws ValidationException {
boolean valid = true; boolean valid = true;
try { try {

View file

@ -17,7 +17,12 @@
package org.keycloak.testsuite.util; 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.IOUtils;
import org.apache.commons.io.output.ByteArrayOutputStream; import org.apache.commons.io.output.ByteArrayOutputStream;
import org.apache.http.Header; import org.apache.http.Header;
@ -70,9 +75,8 @@ import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import javax.ws.rs.client.Entity; import com.google.common.base.Charsets;
import javax.ws.rs.core.Form;
import javax.ws.rs.core.UriBuilder;
import java.io.IOException; import java.io.IOException;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.net.URI; import java.net.URI;
@ -88,10 +92,9 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier; import java.util.function.Supplier;
import static org.keycloak.testsuite.admin.Users.getPasswordOf; import javax.ws.rs.client.Entity;
import static org.keycloak.testsuite.util.UIUtils.clickLink; import javax.ws.rs.core.Form;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; import javax.ws.rs.core.UriBuilder;
import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -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) { public void init(WebDriver driver) {
this.driver = driver; this.driver = driver;
@ -251,6 +264,30 @@ public class OAuthClient {
return new AuthorizationEndpointResponse(this); 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) { public AuthorizationEndpointResponse doLogin(UserRepresentation user) {
return doLogin(user.getUsername(), getPasswordOf(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) { public void doLoginGrant(String username, String password) {
openLoginForm(); openLoginForm();
fillLoginForm(username, password); fillLoginForm(username, password);
@ -663,6 +723,32 @@ public class OAuthClient {
return client.execute(post); 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<NameValuePair> 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) { public CloseableHttpResponse doTokenRevoke(String token, String tokenTypeHint, String clientSecret) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) { try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
return doTokenRevoke(token, tokenTypeHint, clientSecret, client); return doTokenRevoke(token, tokenTypeHint, clientSecret, client);
@ -985,6 +1071,10 @@ public class OAuthClient {
return new LogoutUrlBuilder(); return new LogoutUrlBuilder();
} }
public BackchannelLogoutUrlBuilder getBackchannelLogoutUrl() {
return new BackchannelLogoutUrlBuilder();
}
public String getTokenRevocationUrl() { public String getTokenRevocationUrl() {
UriBuilder b = OIDCLoginProtocolService.tokenRevocationUrl(UriBuilder.fromUri(baseUrl)); UriBuilder b = OIDCLoginProtocolService.tokenRevocationUrl(UriBuilder.fromUri(baseUrl));
return b.build(realm).toString(); return b.build(realm).toString();

View file

@ -27,6 +27,7 @@ import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType; import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.AccountRoles; import org.keycloak.models.AccountRoles;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.adapters.action.GlobalRequestResult;
@ -124,6 +125,10 @@ public class ClientTest extends AbstractAdminTest {
rep.setRootUrl(""); rep.setRootUrl("");
rep.setBaseUrl("/valid"); rep.setBaseUrl("/valid");
createClientExpectingSuccessfulClientCreation(rep); createClientExpectingSuccessfulClientCreation(rep);
rep.setBaseUrl(null);
OIDCAdvancedConfigWrapper.fromClientRepresentation(rep).setBackchannelLogoutUrl("invalid");
createClientExpectingValidationError(rep, "Invalid URL in backchannelLogoutUrl");
} }
@Test @Test
@ -152,6 +157,10 @@ public class ClientTest extends AbstractAdminTest {
rep.setRootUrl(""); rep.setRootUrl("");
rep.setBaseUrl("/valid"); rep.setBaseUrl("/valid");
updateClientExpectingSuccessfulClientUpdate(rep, "", "/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) { private void createClientExpectingValidationError(ClientRepresentation rep, String expectedError) {

View file

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

View file

@ -1,5 +1,6 @@
package org.keycloak.testsuite.broker; package org.keycloak.testsuite.broker;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderSyncMode; import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.ProtocolMapperUtils;
@ -40,6 +41,8 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration {
RealmRepresentation realm = new RealmRepresentation(); RealmRepresentation realm = new RealmRepresentation();
realm.setRealm(REALM_PROV_NAME); realm.setRealm(REALM_PROV_NAME);
realm.setEnabled(true); realm.setEnabled(true);
realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue"));
realm.setEventsEnabled(true);
return realm; return realm;
} }
@ -50,6 +53,8 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration {
realm.setRealm(REALM_CONS_NAME); realm.setRealm(REALM_CONS_NAME);
realm.setEnabled(true); realm.setEnabled(true);
realm.setResetPasswordAllowed(true); realm.setResetPasswordAllowed(true);
realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue"));
realm.setEventsEnabled(true);
return realm; return realm;
} }
@ -60,7 +65,6 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration {
client.setClientId(getIDPClientIdInProviderRealm()); client.setClientId(getIDPClientIdInProviderRealm());
client.setName(CLIENT_ID); client.setName(CLIENT_ID);
client.setSecret(CLIENT_SECRET); client.setSecret(CLIENT_SECRET);
client.setEnabled(true);
client.setRedirectUris(Collections.singletonList(getConsumerRoot() + client.setRedirectUris(Collections.singletonList(getConsumerRoot() +
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint/*")); "/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("userInfoUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/userinfo");
config.put("defaultScope", "email profile"); config.put("defaultScope", "email profile");
config.put("backchannelSupported", "true"); 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 @Override

View file

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

View file

@ -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<ClientRepresentation> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<ClientRepresentation> 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<String, String> config = idp.getConfig();
applyDefaultConfiguration(config, syncMode);
return idp;
}
protected void applyDefaultConfiguration(final Map<String, String> 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<String, String> 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;
}
}

View file

@ -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<String> 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<String> 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<EventRepresentation> eventList = adminClient.realm(realmName).getEvents();
Optional<EventRepresentation> 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<EventRepresentation> eventList = adminClient.realm(realmName).getEvents();
Optional<EventRepresentation> 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<EventRepresentation> eventList = adminClient.realm(realmName).getEvents();
Optional<EventRepresentation> 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<String> sessionIdsConsumerRealm, String userIdConsumerRealm) {
List<EventRepresentation> consumerRealmEvents = adminClient.realm(nbc.consumerRealmName()).getEvents();
for (String sessionId : sessionIdsConsumerRealm) {
Optional<EventRepresentation> 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<UserSessionRepresentation> sessions = getClientSessions(realmName, clientId, userId, sessionId);
assertThat(sessions.size(), is(1));
}
private void assertNoSessionsInClient(String realmName, String clientId, String userId, String sessionId) {
List<UserSessionRepresentation> sessions = getClientSessions(realmName, clientId, userId, sessionId);
assertThat(sessions.size(), is(0));
}
private List<UserSessionRepresentation> 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<String, String> 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);
}
}

View file

@ -162,6 +162,9 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2 // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2
Assert.assertTrue(oidcConfig.getTlsClientCertificateBoundAccessTokens()); Assert.assertTrue(oidcConfig.getTlsClientCertificateBoundAccessTokens());
Assert.assertTrue(oidcConfig.getBackchannelLogoutSupported());
Assert.assertTrue(oidcConfig.getBackchannelLogoutSessionSupported());
// Token Revocation // Token Revocation
assertEquals(oidcConfig.getRevocationEndpoint(), oauth.getTokenRevocationUrl()); assertEquals(oidcConfig.getRevocationEndpoint(), oauth.getTokenRevocationUrl());
Assert.assertNames(oidcConfig.getRevocationEndpointAuthMethodsSupported(), "client_secret_basic", Assert.assertNames(oidcConfig.getRevocationEndpointAuthMethodsSupported(), "client_secret_basic",

View file

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

View file

@ -1,14 +1,28 @@
package org.keycloak.testsuite.util; package org.keycloak.testsuite.util;
import org.keycloak.admin.client.resource.ComponentResource;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.util.Base64; import org.keycloak.common.util.Base64;
import org.keycloak.common.util.CertificateUtils; 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.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.List;
import java.util.stream.Collectors;
import javax.ws.rs.core.Response;
/** /**
* @author <a href="mailto:bruno@abstractj.org">Bruno Oliveira</a>. * @author <a href="mailto:bruno@abstractj.org">Bruno Oliveira</a>.
@ -92,6 +106,48 @@ public class RealmManager {
realm.update(rep); 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<String, String> 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<String> 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) { public void ssoSessionMaxLifespan(int ssoSessionMaxLifespan) {
RealmRepresentation rep = realm.toRepresentation(); RealmRepresentation rep = realm.toRepresentation();
rep.setSsoSessionMaxLifespan(ssoSessionMaxLifespan); rep.setSsoSessionMaxLifespan(ssoSessionMaxLifespan);

View file

@ -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. 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=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 '*'. 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=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 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 access-token-signed-response-alg=Access Token Signature Algorithm

View file

@ -1290,6 +1290,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.displayOnConsentScreen = false; $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) { if (!$scope.create) {
@ -1618,6 +1626,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.clientEdit.attributes["display.on.consent.screen"] = "false"; $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.protocol = $scope.protocol;
$scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm; $scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm;
$scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat; $scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat;

View file

@ -359,6 +359,20 @@
<kc-tooltip>{{:: 'web-origins.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'web-origins.tooltip' | translate}}</kc-tooltip>
</div> </div>
<div class="form-group" data-ng-show="protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="backchannelLogoutUrl">{{:: 'backchannel-logout-url' | translate}}</label>
<div class="col-sm-6">
<input class="form-control" type="text" name="backchannelLogoutUrl" id="backchannelLogoutUrl" data-ng-model="clientEdit.attributes['backchannel.logout.url']">
</div>
<kc-tooltip>{{:: 'backchannel-logout-url.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="backchannelLogoutSessionRequired">{{:: 'backchannel-logout-session-required' | translate}}</label>
<div class="col-sm-6">
<input ng-model="backchannelLogoutSessionRequired" name="backchannelLogoutSessionRequired" id="backchannelLogoutSessionRequired" onoffswitch ng-click="switchChange()" on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
<kc-tooltip>{{:: 'backchannel-logout-session-required.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset> </fieldset>
<fieldset data-ng-show="protocol == 'saml'"> <fieldset data-ng-show="protocol == 'saml'">
<legend collapsed><span class="text">{{:: 'fine-saml-endpoint-conf' | translate}}</span> <kc-tooltip>{{:: 'fine-saml-endpoint-conf.tooltip' | translate}}</kc-tooltip></legend> <legend collapsed><span class="text">{{:: 'fine-saml-endpoint-conf' | translate}}</span> <kc-tooltip>{{:: 'fine-saml-endpoint-conf.tooltip' | translate}}</kc-tooltip></legend>