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:
parent
4ff34c1be9
commit
ddc2c25951
42 changed files with 2057 additions and 91 deletions
|
@ -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";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -21,5 +21,6 @@ public enum TokenCategory {
|
||||||
ACCESS,
|
ACCESS,
|
||||||
ID,
|
ID,
|
||||||
ADMIN,
|
ADMIN,
|
||||||
USERINFO
|
USERINFO,
|
||||||
|
LOGOUT
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()) {
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,27 +229,40 @@ 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(),
|
||||||
expireUserSessionCookie(session, userSession, realm, uriInfo, headers, connection);
|
userSession.isOffline());
|
||||||
|
boolean expireUserSessionCookieSucceeded =
|
||||||
|
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,
|
||||||
checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession);
|
headers, logoutBroker);
|
||||||
|
userSessionOnlyHasLoggedOutClients =
|
||||||
|
checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession);
|
||||||
} finally {
|
} finally {
|
||||||
RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession();
|
RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession();
|
||||||
rootAuthSession.removeAuthenticationSessionByTabId(logoutAuthSession.getTabId());
|
rootAuthSession.removeAuthenticationSessionByTabId(logoutAuthSession.getTabId());
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue