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

* KEYCLOAK-2940 Backchannel Logout

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

View file

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

View file

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

View file

@ -52,6 +52,12 @@ public class JWSHeader implements Serializable {
this.contentType = contentType;
}
public JWSHeader(Algorithm algorithm, String type, String contentType, String keyId) {
this.algorithm = algorithm;
this.type = type;
this.keyId = keyId;
}
public Algorithm getAlgorithm() {
return algorithm;
}

View file

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

View file

@ -0,0 +1,44 @@
package org.keycloak.representations;
import org.keycloak.TokenCategory;
import org.keycloak.util.TokenUtil;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.HashMap;
import java.util.Map;
public class LogoutToken extends JsonWebToken {
@JsonProperty("sid")
protected String sid;
@JsonProperty("events")
protected Map<String, Object> events = new HashMap<>();
public Map<String, Object> getEvents() {
return events;
}
public void putEvents(String name, Object value) {
events.put(name, value);
}
public String getSid() {
return sid;
}
public LogoutToken setSid(String sid) {
this.sid = sid;
return this;
}
public LogoutToken() {
type(TokenUtil.TOKEN_TYPE_LOGOUT);
}
@Override
public TokenCategory getCategory() {
return TokenCategory.LOGOUT;
}
}

View file

@ -119,6 +119,10 @@ public class OIDCClientRepresentation {
private String registration_access_token;
private String backchannel_logout_uri;
private Boolean backchannel_logout_session_required;
public List<String> getRedirectUris() {
return redirect_uris;
}
@ -449,6 +453,22 @@ public class OIDCClientRepresentation {
this.tls_client_certificate_bound_access_tokens = tls_client_certificate_bound_access_tokens;
}
public String getBackchannelLogoutUri() {
return backchannel_logout_uri;
}
public void setBackchannelLogoutUri(String backchannel_logout_uri) {
this.backchannel_logout_uri = backchannel_logout_uri;
}
public Boolean getBackchannelLogoutSessionRequired() {
return backchannel_logout_session_required;
}
public void setBackchannelLogoutSessionRequired(Boolean backchannel_logout_session_required) {
this.backchannel_logout_session_required = backchannel_logout_session_required;
}
public String getTlsClientAuthSubjectDn() {
return tls_client_auth_subject_dn;
}

View file

@ -48,6 +48,9 @@ public class TokenUtil {
public static final String TOKEN_TYPE_OFFLINE = "Offline";
public static final String TOKEN_TYPE_LOGOUT = "Logout";
public static final String TOKEN_BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
public static String attachOIDCScope(String scopeParam) {
if (scopeParam == null || scopeParam.isEmpty()) {

View file

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

View file

@ -80,7 +80,7 @@ public interface LoginProtocol extends Provider {
Response sendError(AuthenticationSessionModel authSession, Error error);
void backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession);
Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession);
Response frontchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession);
Response finishLogout(UserSessionModel userSession);

View file

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

View file

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

View file

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

View file

@ -16,9 +16,6 @@
*/
package org.keycloak.jose.jws;
import java.io.UnsupportedEncodingException;
import java.security.Key;
import org.jboss.logging.Logger;
import org.keycloak.Token;
import org.keycloak.TokenCategory;
@ -35,13 +32,23 @@ import org.keycloak.jose.jwe.alg.JWEAlgorithmProvider;
import org.keycloak.jose.jwe.enc.JWEEncryptionProvider;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.keys.loader.PublicKeyStorageManager;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.TokenManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.LogoutToken;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import java.io.UnsupportedEncodingException;
import java.security.Key;
public class DefaultTokenManager implements TokenManager {
private static final Logger logger = Logger.getLogger(DefaultTokenManager.class);
@ -129,6 +136,7 @@ public class DefaultTokenManager implements TokenManager {
case ACCESS:
return getSignatureAlgorithm(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG);
case ID:
case LOGOUT:
return getSignatureAlgorithm(OIDCConfigAttributes.ID_TOKEN_SIGNED_RESPONSE_ALG);
case USERINFO:
return getSignatureAlgorithm(OIDCConfigAttributes.USER_INFO_RESPONSE_SIGNATURE_ALG);
@ -202,6 +210,7 @@ public class DefaultTokenManager implements TokenManager {
if (category == null) return null;
switch (category) {
case ID:
case LOGOUT:
return getCekManagementAlgorithm(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ALG);
default:
return null;
@ -222,6 +231,7 @@ public class DefaultTokenManager implements TokenManager {
if (category == null) return null;
switch (category) {
case ID:
case LOGOUT:
return getEncryptAlgorithm(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ENC);
default:
return null;
@ -236,4 +246,21 @@ public class DefaultTokenManager implements TokenManager {
}
return null;
}
public LogoutToken initLogoutToken(ClientModel client, UserModel user,
AuthenticatedClientSessionModel clientSession) {
LogoutToken token = new LogoutToken();
token.id(KeycloakModelUtils.generateId());
token.issuedNow();
token.issuer(clientSession.getNote(OIDCLoginProtocol.ISSUER));
token.putEvents(TokenUtil.TOKEN_BACKCHANNEL_LOGOUT_EVENT, JsonSerialization.createObjectNode());
token.addAudience(client.getClientId());
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isBackchannelLogoutSessionRequired()){
token.setSid(clientSession.getUserSession().getId());
}
token.setSubject(user.getId());
return token;
}
}

View file

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

View file

@ -0,0 +1,49 @@
package org.keycloak.protocol.oidc;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class BackchannelLogoutResponse {
private boolean localLogoutSucceeded;
private List<DownStreamBackchannelLogoutResponse> clientResponses = new ArrayList<>();
public List<DownStreamBackchannelLogoutResponse> getClientResponses() {
return clientResponses;
}
public void addClientResponses(DownStreamBackchannelLogoutResponse clientResponse) {
this.clientResponses.add(clientResponse);
}
public boolean getLocalLogoutSucceeded() {
return localLogoutSucceeded;
}
public void setLocalLogoutSucceeded(boolean localLogoutSucceeded) {
this.localLogoutSucceeded = localLogoutSucceeded;
}
public static class DownStreamBackchannelLogoutResponse {
protected boolean withBackchannelLogoutUrl;
protected Integer responseCode;
public boolean isWithBackchannelLogoutUrl() {
return withBackchannelLogoutUrl;
}
public void setWithBackchannelLogoutUrl(boolean withBackchannelLogoutUrl) {
this.withBackchannelLogoutUrl = withBackchannelLogoutUrl;
}
public Optional<Integer> getResponseCode() {
return Optional.ofNullable(responseCode);
}
public void setResponseCode(Integer responseCode) {
this.responseCode = responseCode;
}
}
}

View file

@ -0,0 +1,24 @@
package org.keycloak.protocol.oidc;
public enum LogoutTokenValidationCode {
VALIDATION_SUCCESS(""),
DECODE_TOKEN_FAILED("The decode of the logoutToken failed"),
COULD_NOT_FIND_IDP("No Identity Provider has been found"),
TOKEN_VERIFICATION_WITH_IDP_FAILED("LogoutToken verification with identity provider failed"),
MISSING_SID_OR_SUBJECT("Missing sid or sub claim"),
BACKCHANNEL_LOGOUT_EVENT_MISSING("The LogoutToken event claim is not as expected"),
NONCE_CLAIM_IN_TOKEN("The LogoutToken contains a nonce claim which is not allowed"),
MISSING_IAT_CLAIM("The LogoutToken doesn't contain an iat claim"),
LOGOUT_TOKEN_ID_MISSING("The logoutToken jti is missing");
private String errorMessage;
LogoutTokenValidationCode(String errorMessage) {
this.errorMessage = errorMessage;
}
public String getErrorMessage() {
return errorMessage;
}
}

View file

@ -166,6 +166,24 @@ public class OIDCAdvancedConfigWrapper {
setAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, algName);
}
public String getBackchannelLogoutUrl() {
return getAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL);
}
public void setBackchannelLogoutUrl(String backchannelLogoutUrl) {
setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, backchannelLogoutUrl);
}
public boolean isBackchannelLogoutSessionRequired() {
String backchannelLogoutSessionRequired = getAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_SESSION_REQUIRED);
return Boolean.parseBoolean(backchannelLogoutSessionRequired);
}
public void setBackchannelLogoutSessionRequired(boolean backchannelLogoutSessionRequired) {
String val = String.valueOf(backchannelLogoutSessionRequired);
setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_SESSION_REQUIRED, val);
}
private String getAttribute(String attrKey) {
if (clientModel != null) {
return clientModel.getAttribute(attrKey);

View file

@ -52,6 +52,10 @@ public final class OIDCConfigAttributes {
public static final String TOKEN_ENDPOINT_AUTH_SIGNING_ALG = "token.endpoint.auth.signing.alg";
public static final String BACKCHANNEL_LOGOUT_URL = "backchannel.logout.url";
public static final String BACKCHANNEL_LOGOUT_SESSION_REQUIRED = "backchannel.logout.session.required";
private OIDCConfigAttributes() {
}

View file

@ -29,7 +29,6 @@ import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.headers.SecurityHeadersProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.BrowserSecurityHeaders;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
@ -50,7 +49,6 @@ import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.services.managers.ResourceAdminManager;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.TokenUtil;
import org.keycloak.utils.MediaType;
import java.io.IOException;
import java.net.URI;
@ -312,9 +310,13 @@ public class OIDCLoginProtocol implements LoginProtocol {
}
@Override
public void backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
public Response backchannelLogout(UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
ClientModel client = clientSession.getClient();
new ResourceAdminManager(session).logoutClientSession(realm, client, clientSession);
if (OIDCAdvancedConfigWrapper.fromClientModel(clientSession.getClient()).getBackchannelLogoutUrl() != null) {
return new ResourceAdminManager(session).logoutClientSessionWithBackchannelLogoutUrl(client, clientSession);
} else {
return new ResourceAdminManager(session).logoutClientSession(realm, client, clientSession);
}
}
@Override

View file

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

View file

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

View file

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

View file

@ -21,8 +21,13 @@ import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.Token;
import org.keycloak.TokenCategory;
import org.keycloak.TokenVerifier;
import org.keycloak.broker.oidc.OIDCIdentityProvider;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.provider.IdentityProviderFactory;
import org.keycloak.cluster.ClusterProvider;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.VerificationException;
@ -32,6 +37,7 @@ import org.keycloak.crypto.SignatureProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.migration.migrators.MigrationUtils;
@ -39,6 +45,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
@ -54,33 +61,43 @@ import org.keycloak.protocol.ProtocolMapperUtils;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.TokenUtil;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import static org.keycloak.representations.IDToken.NONCE;
/**
* Stateless object that creates tokens and manages oauth access codes
*
@ -1070,4 +1087,103 @@ public class TokenManager {
return new NotBeforeCheck(session.users().getNotBeforeOfUser(realmModel, userModel));
}
}
}
public LogoutTokenValidationCode verifyLogoutToken(KeycloakSession session, RealmModel realm, String encodedLogoutToken) {
Optional<LogoutToken> logoutTokenOptional = toLogoutToken(encodedLogoutToken);
if (!logoutTokenOptional.isPresent()) {
return LogoutTokenValidationCode.DECODE_TOKEN_FAILED;
}
LogoutToken logoutToken = logoutTokenOptional.get();
List<OIDCIdentityProvider> identityProviders = getOIDCIdentityProviders(realm, session);
if (identityProviders.isEmpty()) {
return LogoutTokenValidationCode.COULD_NOT_FIND_IDP;
}
List<OIDCIdentityProvider> validOidcIdentityProviders = validateLogoutTokenAgainstIdpProvider(identityProviders, encodedLogoutToken, logoutToken);
if (validOidcIdentityProviders.isEmpty()) {
return LogoutTokenValidationCode.TOKEN_VERIFICATION_WITH_IDP_FAILED;
}
if (logoutToken.getSubject() == null && logoutToken.getSid() == null) {
return LogoutTokenValidationCode.MISSING_SID_OR_SUBJECT;
}
if (!checkLogoutTokenForEvents(logoutToken)) {
return LogoutTokenValidationCode.BACKCHANNEL_LOGOUT_EVENT_MISSING;
}
if (logoutToken.getOtherClaims().get(NONCE) != null) {
return LogoutTokenValidationCode.NONCE_CLAIM_IN_TOKEN;
}
if (logoutToken.getId() == null) {
return LogoutTokenValidationCode.LOGOUT_TOKEN_ID_MISSING;
}
if (logoutToken.getIat() == null) {
return LogoutTokenValidationCode.MISSING_IAT_CLAIM;
}
return LogoutTokenValidationCode.VALIDATION_SUCCESS;
}
public Optional<LogoutToken> toLogoutToken(String encodedLogoutToken) {
try {
JWSInput jws = new JWSInput(encodedLogoutToken);
return Optional.of(jws.readJsonContent(LogoutToken.class));
} catch (JWSInputException e) {
return Optional.empty();
}
}
public List<OIDCIdentityProvider> getValidOIDCIdentityProvidersForBackchannelLogout(RealmModel realm, KeycloakSession session, String encodedLogoutToken, LogoutToken logoutToken) {
List<OIDCIdentityProvider> identityProviders = getOIDCIdentityProviders(realm, session);
return validateLogoutTokenAgainstIdpProvider(identityProviders, encodedLogoutToken, logoutToken);
}
public List<OIDCIdentityProvider> validateLogoutTokenAgainstIdpProvider(List<OIDCIdentityProvider> oidcIdps, String encodedLogoutToken, LogoutToken logoutToken) {
List<OIDCIdentityProvider> validIdps = new ArrayList<>();
for (OIDCIdentityProvider oidcIdp : oidcIdps) {
if (oidcIdp.getConfig().getIssuer() != null) {
if (oidcIdp.isIssuer(logoutToken.getIssuer(), null)) {
try {
oidcIdp.validateToken(encodedLogoutToken);
validIdps.add(oidcIdp);
} catch (IdentityBrokerException e) {
logger.debugf("LogoutToken verification with identity provider failed", e.getMessage());
}
}
}
}
return validIdps;
}
private List<OIDCIdentityProvider> getOIDCIdentityProviders(RealmModel realm, KeycloakSession session) {
List<OIDCIdentityProvider> availableProviders = new ArrayList<>();
try {
for (IdentityProviderModel idpModel : realm.getIdentityProviders()) {
IdentityProviderFactory factory = IdentityBrokerService.getIdentityProviderFactory(session, idpModel);
IdentityProvider identityProvider = factory.create(session, idpModel);
if (identityProvider instanceof OIDCIdentityProvider) {
availableProviders.add(((OIDCIdentityProvider) identityProvider));
}
}
} catch (IdentityBrokerException e) {
logger.warnf("LogoutToken verification with identity provider failed", e.getMessage());
}
return availableProviders;
}
private boolean checkLogoutTokenForEvents(LogoutToken logoutToken) {
for (String eventKey : logoutToken.getEvents().keySet()) {
if (TokenUtil.TOKEN_BACKCHANNEL_LOGOUT_EVENT.equals(eventKey)) {
return true;
}
}
return false;
}
}

View file

@ -34,11 +34,14 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.BackchannelLogoutResponse;
import org.keycloak.protocol.oidc.LogoutTokenValidationCode;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException;
@ -52,16 +55,15 @@ import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.util.TokenUtil;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.QueryParam;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author <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();
}
/**
* 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) {
AuthenticationManager.backchannelLogout(session, realm, userSession, session.getContext().getUri(), clientConnection, headers, true, offline);
event.user(userSession.getUser()).session(userSession).success();

View file

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

View file

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

View file

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

View file

@ -16,7 +16,15 @@
*/
package org.keycloak.services.managers;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenIdGenerator;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.common.util.MultivaluedHashMap;
@ -26,19 +34,21 @@ import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.TokenManager;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.adapters.action.GlobalRequestResult;
import org.keycloak.representations.adapters.action.LogoutAction;
import org.keycloak.representations.adapters.action.TestAvailabilityAction;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.util.ResolveRelative;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.net.URI;
@ -139,11 +149,11 @@ public class ResourceAdminManager {
}
public boolean logoutClientSession(RealmModel realm, ClientModel resource, AuthenticatedClientSessionModel clientSession) {
public Response logoutClientSession(RealmModel realm, ClientModel resource, AuthenticatedClientSessionModel clientSession) {
return logoutClientSessions(realm, resource, Arrays.asList(clientSession));
}
protected boolean logoutClientSessions(RealmModel realm, ClientModel resource, List<AuthenticatedClientSessionModel> clientSessions) {
protected Response logoutClientSessions(RealmModel realm, ClientModel resource, List<AuthenticatedClientSessionModel> clientSessions) {
String managementUrl = getManagementUrl(session, resource);
if (managementUrl != null) {
@ -164,20 +174,19 @@ public class ResourceAdminManager {
if (adapterSessionIds == null || adapterSessionIds.isEmpty()) {
logger.debugv("Can't logout {0}: no logged adapter sessions", resource.getClientId());
return false;
return null;
}
if (managementUrl.contains(CLIENT_SESSION_HOST_PROPERTY)) {
boolean allPassed = true;
// Send logout separately to each host (needed for single-sign-out in cluster for non-distributable apps - KEYCLOAK-748)
for (Map.Entry<String, List<String>> entry : adapterSessionIds.entrySet()) {
String host = entry.getKey();
List<String> sessionIds = entry.getValue();
String currentHostMgmtUrl = managementUrl.replace(CLIENT_SESSION_HOST_PROPERTY, host);
allPassed = sendLogoutRequest(realm, resource, sessionIds, userSessions, 0, currentHostMgmtUrl) && allPassed;
sendLogoutRequest(realm, resource, sessionIds, userSessions, 0, currentHostMgmtUrl);
}
return Response.ok().build();
return allPassed;
} else {
// Send single logout request
List<String> allSessionIds = new ArrayList<String>();
@ -189,7 +198,68 @@ public class ResourceAdminManager {
}
} else {
logger.debugv("Can't logout {0}: no management url", resource.getClientId());
return false;
return null;
}
}
public Response logoutClientSessionWithBackchannelLogoutUrl(ClientModel resource,
AuthenticatedClientSessionModel clientSession) {
String backchannelLogoutUrl = getBackchannelLogoutUrl(session, resource);
// Send logout separately to each host (needed for single-sign-out in cluster for non-distributable apps -
// KEYCLOAK-748)
if (backchannelLogoutUrl.contains(CLIENT_SESSION_HOST_PROPERTY)) {
String host = clientSession.getNote(AdapterConstants.CLIENT_SESSION_HOST);
String currentHostMgmtUrl = backchannelLogoutUrl.replace(CLIENT_SESSION_HOST_PROPERTY, host);
return sendBackChannelLogoutRequestToClientUri(resource, clientSession, currentHostMgmtUrl);
} else {
return sendBackChannelLogoutRequestToClientUri(resource, clientSession, backchannelLogoutUrl);
}
}
public static String getBackchannelLogoutUrl(KeycloakSession session, ClientModel client) {
String backchannelLogoutUrl = OIDCAdvancedConfigWrapper.fromClientModel(client).getBackchannelLogoutUrl();
if (backchannelLogoutUrl == null || backchannelLogoutUrl.equals("")) {
return null;
}
String absoluteURI = ResolveRelative.resolveRelativeUri(session, client.getRootUrl(), backchannelLogoutUrl);
// this is for resolving URI like "http://${jboss.host.name}:8080/..." in order to send request to same machine
// and avoid request to LB in cluster environment
return StringPropertyReplacer.replaceProperties(absoluteURI);
}
protected Response sendBackChannelLogoutRequestToClientUri(ClientModel resource,
AuthenticatedClientSessionModel clientSessionModel, String managementUrl) {
UserModel user = clientSessionModel.getUserSession().getUser();
LogoutToken logoutToken = session.tokens().initLogoutToken(resource, user, clientSessionModel);
String token = session.tokens().encode(logoutToken);
if (logger.isDebugEnabled())
logger.debugv("logout resource {0} url: {1} sessionIds: ", resource.getClientId(), managementUrl);
HttpPost post = null;
try {
post = new HttpPost(managementUrl);
List<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
GlobalRequestResult result = new GlobalRequestResult();
for (String mgmtUrl : mgmtUrls) {
if (sendLogoutRequest(realm, resource, null, null, notBefore, mgmtUrl)) {
if (sendLogoutRequest(realm, resource, null, null, notBefore, mgmtUrl) != null) {
result.addSuccessRequest(mgmtUrl);
} else {
result.addFailedRequest(mgmtUrl);
@ -240,7 +310,7 @@ public class ResourceAdminManager {
return result;
}
protected boolean sendLogoutRequest(RealmModel realm, ClientModel resource, List<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);
String token = session.tokens().encode(adminAction);
if (logger.isDebugEnabled()) logger.debugv("logout resource {0} url: {1} sessionIds: " + adapterSessionIds, resource.getClientId(), managementUrl);
@ -249,10 +319,10 @@ public class ResourceAdminManager {
int status = session.getProvider(HttpClientProvider.class).postText(target.toString(), token);
boolean success = status == 204 || status == 200;
logger.debugf("logout success for %s: %s", managementUrl, success);
return success;
return Response.ok().build();
} catch (IOException e) {
ServicesLogger.LOGGER.logoutFailed(e, resource.getClientId());
return false;
return Response.serverError().build();
}
}

View file

@ -17,6 +17,7 @@
package org.keycloak.validation;
import org.keycloak.models.ClientModel;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.services.util.ResolveRelative;
import java.net.MalformedURLException;
@ -47,8 +48,13 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
String resolvedRootUrl = ResolveRelative.resolveRootUrl(authServerUrl, authServerUrl, client.getRootUrl());
String resolvedBaseUrl = ResolveRelative.resolveRelativeUri(authServerUrl, authServerUrl, resolvedRootUrl, client.getBaseUrl());
String backchannelLogoutUrl = OIDCAdvancedConfigWrapper.fromClientModel(client).getBackchannelLogoutUrl();
String resolvedBackchannelLogoutUrl =
ResolveRelative.resolveRelativeUri(authServerUrl, authServerUrl, resolvedRootUrl, backchannelLogoutUrl);
validateRootUrl(resolvedRootUrl);
validateBaseUrl(resolvedBaseUrl);
validateBackchannelLogoutUrl(resolvedBackchannelLogoutUrl);
}
private void validateRootUrl(String rootUrl) throws ValidationException {
@ -63,6 +69,12 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
}
}
private void validateBackchannelLogoutUrl(String backchannelLogoutUrl) throws ValidationException {
if (backchannelLogoutUrl != null && !backchannelLogoutUrl.isEmpty()) {
basicHttpUrlCheck("backchannelLogoutUrl", backchannelLogoutUrl);
}
}
private void basicHttpUrlCheck(String field, String url) throws ValidationException {
boolean valid = true;
try {

View file

@ -17,7 +17,12 @@
package org.keycloak.testsuite.util;
import com.google.common.base.Charsets;
import static org.keycloak.testsuite.admin.Users.getPasswordOf;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts;
import static org.keycloak.testsuite.util.UIUtils.clickLink;
import static org.keycloak.testsuite.util.WaitUtils.waitUntilElement;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.apache.http.Header;
@ -70,9 +75,8 @@ import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Form;
import javax.ws.rs.core.UriBuilder;
import com.google.common.base.Charsets;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
@ -88,10 +92,9 @@ import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import static org.keycloak.testsuite.admin.Users.getPasswordOf;
import static org.keycloak.testsuite.util.UIUtils.clickLink;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Form;
import javax.ws.rs.core.UriBuilder;
/**
* @author <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) {
this.driver = driver;
@ -251,6 +264,30 @@ public class OAuthClient {
return new AuthorizationEndpointResponse(this);
}
public void updateAccountInformation(String username, String email) {
WaitUtils.waitForPageToLoad();
updateAccountInformation(username, email, "First", "Last");
}
public void linkUsers(String username, String password) {
WaitUtils.waitForPageToLoad();
WebElement linkAccountButton = driver.findElement(By.id("linkAccount"));
waitUntilElement(linkAccountButton).is().clickable();
linkAccountButton.click();
WaitUtils.waitForPageToLoad();
WebElement usernameInput = driver.findElement(By.id("username"));
usernameInput.clear();
usernameInput.sendKeys(username);
WebElement passwordInput = driver.findElement(By.id("password"));
passwordInput.clear();
passwordInput.sendKeys(password);
WebElement loginButton = driver.findElement(By.id("kc-login"));
waitUntilElement(loginButton).is().clickable();
loginButton.click();
}
public AuthorizationEndpointResponse doLogin(UserRepresentation user) {
return doLogin(user.getUsername(), getPasswordOf(user));
@ -283,6 +320,29 @@ public class OAuthClient {
}
}
private void updateAccountInformation(String username, String email, String firstName, String lastName) {
WebElement usernameInput = driver.findElement(By.id("username"));
usernameInput.clear();
usernameInput.sendKeys(username);
WebElement emailInput = driver.findElement(By.id("email"));
emailInput.clear();
emailInput.sendKeys(email);
WebElement firstNameInput = driver.findElement(By.id("firstName"));
firstNameInput.clear();
firstNameInput.sendKeys(firstName);
WebElement lastNameInput = driver.findElement(By.id("lastName"));
lastNameInput.clear();
lastNameInput.sendKeys(lastName);
WebElement submitButton = driver.findElement(By.cssSelector("input[type=\"submit\"]"));
waitUntilElement(submitButton).is().clickable();
submitButton.click();
}
public void doLoginGrant(String username, String password) {
openLoginForm();
fillLoginForm(username, password);
@ -663,6 +723,32 @@ public class OAuthClient {
return client.execute(post);
}
public CloseableHttpResponse doBackchannelLogout(String logoutTokon) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
return doBackchannelLogout(logoutTokon, client);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
public CloseableHttpResponse doBackchannelLogout(String logoutToken, CloseableHttpClient client) throws IOException {
HttpPost post = new HttpPost(getBackchannelLogoutUrl().build());
List<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) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
return doTokenRevoke(token, tokenTypeHint, clientSecret, client);
@ -985,6 +1071,10 @@ public class OAuthClient {
return new LogoutUrlBuilder();
}
public BackchannelLogoutUrlBuilder getBackchannelLogoutUrl() {
return new BackchannelLogoutUrlBuilder();
}
public String getTokenRevocationUrl() {
UriBuilder b = OIDCLoginProtocolService.tokenRevocationUrl(UriBuilder.fromUri(baseUrl));
return b.build(realm).toString();

View file

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

View file

@ -0,0 +1,48 @@
package org.keycloak.testsuite.broker;
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;
import org.junit.After;
import org.junit.Before;
public abstract class AbstractNestedBrokerTest extends AbstractBaseBrokerTest {
protected NestedBrokerConfiguration nbc = getNestedBrokerConfiguration();
protected abstract NestedBrokerConfiguration getNestedBrokerConfiguration();
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return getNestedBrokerConfiguration();
}
@Before
public void createSubConsumerRealm() {
importRealm(nbc.createSubConsumerRealm());
}
@After
public void removeSubConsumerRealm() {
adminClient.realm(nbc.subConsumerRealmName()).remove();
}
/** Logs in subconsumer realm via consumer IDP via provider IDP and updates account information */
protected void logInAsUserInNestedIDPForFirstTime() {
driver.navigate().to(getAccountUrl(getConsumerRoot(), nbc.subConsumerRealmName()));
waitForPage(driver, "log in to", true);
log.debug("Clicking social " + nbc.getSubConsumerIDPDisplayName());
loginPage.clickSocial(nbc.getSubConsumerIDPDisplayName());
waitForPage(driver, "log in to", true);
log.debug("Clicking social " + nbc.getIDPAlias());
loginPage.clickSocial(nbc.getIDPAlias());
waitForPage(driver, "log in to", true);
log.debug("Logging in");
loginPage.login(nbc.getUserLogin(), nbc.getUserPassword());
waitForPage(driver, "update account information", false);
log.debug("Updating info on updateAccount page");
updateAccountInformationPage.updateAccountInformation(bc.getUserLogin(), bc.getUserEmail(), "Firstname",
"Lastname");
}
}

View file

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

View file

@ -0,0 +1,15 @@
package org.keycloak.testsuite.broker;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
public interface NestedBrokerConfiguration extends BrokerConfiguration {
RealmRepresentation createSubConsumerRealm();
String subConsumerRealmName();
IdentityProviderRepresentation setUpConsumerIdentityProvider();
String getSubConsumerIDPDisplayName();
}

View file

@ -0,0 +1,326 @@
package org.keycloak.testsuite.broker;
import static org.keycloak.testsuite.broker.BrokerTestConstants.CLIENT_ID;
import static org.keycloak.testsuite.broker.BrokerTestConstants.CLIENT_SECRET;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_PROVIDER_ID;
import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_CONS_NAME;
import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_PROV_NAME;
import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_EMAIL;
import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_LOGIN;
import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_PASSWORD;
import static org.keycloak.testsuite.broker.BrokerTestTools.createIdentityProvider;
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.protocol.ProtocolMapperUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.HardcodedClaim;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.protocol.oidc.mappers.UserAttributeMapper;
import org.keycloak.protocol.oidc.mappers.UserPropertyMapper;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class OidcBackchannelLogoutBrokerConfiguration implements NestedBrokerConfiguration {
public static final OidcBackchannelLogoutBrokerConfiguration INSTANCE =
new OidcBackchannelLogoutBrokerConfiguration();
protected static final String ATTRIBUTE_TO_MAP_NAME = "user-attribute";
protected static final String ATTRIBUTE_TO_MAP_NAME_2 = "user-attribute-2";
public static final String USER_INFO_CLAIM = "user-claim";
public static final String HARDOCDED_CLAIM = "test";
public static final String HARDOCDED_VALUE = "value";
public static final String REALM_SUB_CONS_NAME = "subconsumer";
public static final String CONSUMER_CLIENT_ID = "consumer-brokerapp";
public static final String CONSUMER_CLIENT_SECRET = "consumer-secret";
public static final String SUB_CONSUMER_IDP_OIDC_ALIAS = "consumer-kc-oidc-idp";
public static final String SUB_CONSUMER_IDP_OIDC_PROVIDER_ID = "keycloak-oidc";
@Override
public RealmRepresentation createProviderRealm() {
RealmRepresentation realm = new RealmRepresentation();
realm.setRealm(REALM_PROV_NAME);
realm.setEnabled(true);
realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue"));
realm.setEventsEnabled(true);
return realm;
}
@Override
public RealmRepresentation createConsumerRealm() {
RealmRepresentation realm = new RealmRepresentation();
realm.setRealm(REALM_CONS_NAME);
realm.setEnabled(true);
realm.setResetPasswordAllowed(true);
realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue"));
realm.setEventsEnabled(true);
return realm;
}
@Override
public List<ClientRepresentation> createProviderClients() {
ClientRepresentation client = new ClientRepresentation();
client.setId(CLIENT_ID);
client.setClientId(getIDPClientIdInProviderRealm());
client.setName(CLIENT_ID);
client.setSecret(CLIENT_SECRET);
client.setEnabled(true);
OIDCAdvancedConfigWrapper oidcAdvancedConfigWrapper =
OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
oidcAdvancedConfigWrapper.setBackchannelLogoutSessionRequired(true);
oidcAdvancedConfigWrapper.setBackchannelLogoutUrl(getConsumerRoot() +
"/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/logout/backchannel-logout");
client.setRedirectUris(Collections.singletonList(getConsumerRoot() +
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint/*"));
client.setAdminUrl(getConsumerRoot() +
"/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint");
ProtocolMapperRepresentation emailMapper = new ProtocolMapperRepresentation();
emailMapper.setName("email");
emailMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
emailMapper.setProtocolMapper(UserPropertyMapper.PROVIDER_ID);
Map<String, String> emailMapperConfig = emailMapper.getConfig();
emailMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, "email");
emailMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "email");
emailMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE);
emailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
emailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
emailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true");
ProtocolMapperRepresentation nestedAttrMapper = new ProtocolMapperRepresentation();
nestedAttrMapper.setName("attribute - nested claim");
nestedAttrMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
nestedAttrMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
Map<String, String> nestedEmailMapperConfig = nestedAttrMapper.getConfig();
nestedEmailMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, "nested.email");
nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "nested.email");
nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE);
nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
nestedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true");
ProtocolMapperRepresentation dottedAttrMapper = new ProtocolMapperRepresentation();
dottedAttrMapper.setName("attribute - claim with dot in name");
dottedAttrMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
dottedAttrMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
Map<String, String> dottedEmailMapperConfig = dottedAttrMapper.getConfig();
dottedEmailMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, "dotted.email");
dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "dotted\\.email");
dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE);
dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
dottedEmailMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true");
ProtocolMapperRepresentation userAttrMapper = new ProtocolMapperRepresentation();
userAttrMapper.setName("attribute - name");
userAttrMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
userAttrMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
Map<String, String> userAttrMapperConfig = userAttrMapper.getConfig();
userAttrMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, ATTRIBUTE_TO_MAP_NAME);
userAttrMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, ATTRIBUTE_TO_MAP_NAME);
userAttrMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE);
userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true");
userAttrMapperConfig.put(ProtocolMapperUtils.MULTIVALUED, "true");
ProtocolMapperRepresentation userAttrMapper2 = new ProtocolMapperRepresentation();
userAttrMapper2.setName("attribute - name - 2");
userAttrMapper2.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
userAttrMapper2.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
Map<String, String> userAttrMapperConfig2 = userAttrMapper2.getConfig();
userAttrMapperConfig2.put(ProtocolMapperUtils.USER_ATTRIBUTE, ATTRIBUTE_TO_MAP_NAME_2);
userAttrMapperConfig2.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, ATTRIBUTE_TO_MAP_NAME_2);
userAttrMapperConfig2.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE);
userAttrMapperConfig2.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
userAttrMapperConfig2.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
userAttrMapperConfig2.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true");
userAttrMapperConfig2.put(ProtocolMapperUtils.MULTIVALUED, "true");
ProtocolMapperRepresentation hardcodedJsonClaim = new ProtocolMapperRepresentation();
hardcodedJsonClaim.setName("json-mapper");
hardcodedJsonClaim.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
hardcodedJsonClaim.setProtocolMapper(HardcodedClaim.PROVIDER_ID);
Map<String, String> hardcodedJsonClaimMapperConfig = hardcodedJsonClaim.getConfig();
hardcodedJsonClaimMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME,
OidcBackchannelLogoutBrokerConfiguration.USER_INFO_CLAIM);
hardcodedJsonClaimMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, "JSON");
hardcodedJsonClaimMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
hardcodedJsonClaimMapperConfig.put(HardcodedClaim.CLAIM_VALUE,
"{\"" + HARDOCDED_CLAIM + "\": \"" + HARDOCDED_VALUE + "\"}");
client.setProtocolMappers(Arrays.asList(emailMapper, userAttrMapper, userAttrMapper2, nestedAttrMapper,
dottedAttrMapper, hardcodedJsonClaim));
return Collections.singletonList(client);
}
@Override
public List<ClientRepresentation> createConsumerClients() {
ClientRepresentation client = new ClientRepresentation();
client.setId(CONSUMER_CLIENT_ID);
client.setClientId(CONSUMER_CLIENT_ID);
client.setName(CONSUMER_CLIENT_ID);
client.setSecret(CONSUMER_CLIENT_SECRET);
client.setEnabled(true);
client.setDirectAccessGrantsEnabled(true);
client.setRedirectUris(Collections.singletonList(getConsumerRoot() +
"/auth/realms/" + REALM_SUB_CONS_NAME + "/broker/" + SUB_CONSUMER_IDP_OIDC_ALIAS + "/endpoint/*"));
client.setBaseUrl(getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/app");
OIDCAdvancedConfigWrapper oidcAdvancedConfigWrapper =
OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
oidcAdvancedConfigWrapper.setBackchannelLogoutSessionRequired(true);
oidcAdvancedConfigWrapper.setBackchannelLogoutUrl(getConsumerRoot() +
"/auth/realms/" + REALM_SUB_CONS_NAME + "/protocol/openid-connect/logout/backchannel-logout");
return Collections.singletonList(client);
}
@Override
public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) {
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID);
Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(config, syncMode);
return idp;
}
protected void applyDefaultConfiguration(final Map<String, String> config,
IdentityProviderSyncMode syncMode) {
config.put(IdentityProviderModel.SYNC_MODE, syncMode.toString());
config.put("clientId", CLIENT_ID);
config.put("clientSecret", CLIENT_SECRET);
config.put("prompt", "login");
config.put("issuer", getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME);
config.put("authorizationUrl",
getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/auth");
config.put("tokenUrl",
getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/token");
config.put("logoutUrl",
getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/logout");
config.put("userInfoUrl",
getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/userinfo");
config.put("defaultScope", "email profile");
config.put("backchannelSupported", "true");
config.put(OIDCIdentityProviderConfig.JWKS_URL,
getConsumerRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/certs");
config.put(OIDCIdentityProviderConfig.USE_JWKS_URL, "true");
config.put(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true");
}
@Override
public String getUserLogin() {
return USER_LOGIN;
}
@Override
public String getIDPClientIdInProviderRealm() {
return CLIENT_ID;
}
@Override
public String getUserPassword() {
return USER_PASSWORD;
}
@Override
public String getUserEmail() {
return USER_EMAIL;
}
@Override
public String providerRealmName() {
return REALM_PROV_NAME;
}
@Override
public String consumerRealmName() {
return REALM_CONS_NAME;
}
@Override
public String getIDPAlias() {
return IDP_OIDC_ALIAS;
}
@Override
public RealmRepresentation createSubConsumerRealm() {
RealmRepresentation realm = new RealmRepresentation();
realm.setRealm(REALM_SUB_CONS_NAME);
realm.setEnabled(true);
realm.setResetPasswordAllowed(true);
realm.setEventsListeners(Arrays.asList("jboss-logging", "event-queue"));
realm.setEventsEnabled(true);
return realm;
}
@Override
public String subConsumerRealmName() {
return REALM_SUB_CONS_NAME;
}
@Override
public IdentityProviderRepresentation setUpConsumerIdentityProvider() {
IdentityProviderRepresentation idp =
createIdentityProvider(SUB_CONSUMER_IDP_OIDC_ALIAS, SUB_CONSUMER_IDP_OIDC_PROVIDER_ID);
Map<String, String> config = idp.getConfig();
config.put(IdentityProviderModel.SYNC_MODE, IdentityProviderSyncMode.IMPORT.toString());
config.put("clientId", CONSUMER_CLIENT_ID);
config.put("clientSecret", CONSUMER_CLIENT_SECRET);
config.put("prompt", "login");
config.put("issuer", getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME);
config.put("authorizationUrl",
getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/auth");
config.put("tokenUrl",
getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/token");
config.put("logoutUrl",
getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/logout");
config.put("userInfoUrl",
getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/userinfo");
config.put("defaultScope", "email profile");
config.put("backchannelSupported", "true");
config.put(OIDCIdentityProviderConfig.JWKS_URL,
getConsumerRoot() + "/auth/realms/" + REALM_CONS_NAME + "/protocol/openid-connect/certs");
config.put(OIDCIdentityProviderConfig.USE_JWKS_URL, "true");
config.put(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true");
return idp;
}
@Override
public String getSubConsumerIDPDisplayName() {
return SUB_CONSUMER_IDP_OIDC_ALIAS;
}
}

View file

@ -0,0 +1,694 @@
package org.keycloak.testsuite.oauth;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;
import static org.keycloak.testsuite.admin.ApiUtil.createUserWithAdminClient;
import static org.keycloak.testsuite.admin.ApiUtil.resetUserPassword;
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.protocol.oidc.LogoutTokenValidationCode;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.UserSessionRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.broker.AbstractNestedBrokerTest;
import org.keycloak.testsuite.broker.NestedBrokerConfiguration;
import org.keycloak.testsuite.broker.OidcBackchannelLogoutBrokerConfiguration;
import org.keycloak.testsuite.util.CredentialBuilder;
import org.keycloak.testsuite.util.LogoutTokenUtil;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmManager;
import org.keycloak.testsuite.util.SecondBrowser;
import org.keycloak.util.JsonSerialization;
import org.openqa.selenium.WebDriver;
import java.io.IOException;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.ws.rs.core.Response;
public class BackchannelLogoutTest extends AbstractNestedBrokerTest {
public static final String ACCOUNT_CLIENT_NAME = "account";
public static final String BROKER_CLIENT_ID = "brokerapp";
public static final String USER_PASSWORD_CONSUMER_REALM = "password";
private static final KeyPair KEY_PAIR = KeyUtils.generateRsaKeyPair(2048);
private String userIdProviderRealm;
private String realmIdConsumerRealm;
private String accountClientIdConsumerRealm;
private String accountClientIdSubConsumerRealm;
private String providerId;
private RealmManager providerRealmManager;
@Rule
public AssertEvents events = new AssertEvents(this);
@Drone
@SecondBrowser
WebDriver driver2;
@Override
protected NestedBrokerConfiguration getNestedBrokerConfiguration() {
return OidcBackchannelLogoutBrokerConfiguration.INSTANCE;
}
@Before
public void createProviderRealmUser() {
log.debug("creating user for realm " + nbc.providerRealmName());
final UserRepresentation userProviderRealm = new UserRepresentation();
userProviderRealm.setUsername(nbc.getUserLogin());
userProviderRealm.setEmail(nbc.getUserEmail());
userProviderRealm.setEmailVerified(true);
userProviderRealm.setEnabled(true);
final RealmResource realmResource = adminClient.realm(nbc.providerRealmName());
userIdProviderRealm = createUserWithAdminClient(realmResource, userProviderRealm);
resetUserPassword(realmResource.users().get(userIdProviderRealm), nbc.getUserPassword(), false);
}
@Before
public void addIdentityProviders() {
log.debug("adding identity provider to realm " + nbc.consumerRealmName());
RealmResource realm = adminClient.realm(nbc.consumerRealmName());
realm.identityProviders().create(nbc.setUpIdentityProvider()).close();
log.debug("adding identity provider to realm " + nbc.subConsumerRealmName());
realm = adminClient.realm(nbc.subConsumerRealmName());
realm.identityProviders().create(nbc.setUpConsumerIdentityProvider()).close();
}
@Before
public void addClients() {
addClientsToProviderAndConsumer();
}
@Before
public void fetchConsumerRealmDetails() {
RealmResource realmResourceConsumerRealm = adminClient.realm(nbc.consumerRealmName());
realmIdConsumerRealm = realmResourceConsumerRealm.toRepresentation().getId();
accountClientIdConsumerRealm =
adminClient.realm(nbc.consumerRealmName()).clients().findByClientId(ACCOUNT_CLIENT_NAME).get(0).getId();
RealmResource realmResourceSubConsumerRealm = adminClient.realm(nbc.subConsumerRealmName());
accountClientIdSubConsumerRealm =
adminClient.realm(nbc.subConsumerRealmName()).clients().findByClientId(ACCOUNT_CLIENT_NAME).get(0)
.getId();
}
@Before
public void createNewRsaKeyForProviderRealm() {
providerRealmManager = RealmManager.realm(adminClient.realm(nbc.providerRealmName()));
providerId = providerRealmManager.generateNewRsaKey(KEY_PAIR, "rsa-test-2");
}
@Test
public void postBackchannelLogoutWithSessionId() throws Exception {
logInAsUserInIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionIdConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionIdConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionIdProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
assertConsumerLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionIdConsumerRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionIdProviderRealm);
}
@Test
public void postBackchannelLogoutWithoutSessionId() throws Exception {
logInAsUserInIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionIdConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionIdConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
assertConsumerLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionIdConsumerRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionIdProviderRealm);
}
@Test
public void postBackchannelLogoutWithoutLogoutToken() throws Exception {
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(null)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.BAD_REQUEST));
assertThat(response, Matchers.bodyHC(containsString("No logout token")));
}
events.expectLogoutError(Errors.INVALID_TOKEN)
.realm(realmIdConsumerRealm)
.assertEvent();
}
@Test
public void postBackchannelLogoutWithInvalidLogoutToken() throws Exception {
String logoutTokenMissingContent =
Base64Url.encode(JsonSerialization.writeValueAsBytes(JsonSerialization.createObjectNode()));
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenMissingContent)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.BAD_REQUEST));
assertThat(response,
Matchers.bodyHC(containsString(LogoutTokenValidationCode.DECODE_TOKEN_FAILED.getErrorMessage())));
}
events.expectLogoutError(Errors.INVALID_TOKEN)
.realm(realmIdConsumerRealm)
.assertEvent();
}
@Test
public void postBackchannelLogoutWithSessionIdUserNotLoggedIn() throws Exception {
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, UUID.randomUUID().toString());
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
}
@Test
public void postBackchannelLogoutWithoutSessionIdUserNotLoggedIn() throws Exception {
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
}
@Test
public void postBackchannelLogoutWithoutSessionIdUserDoesntExist() throws Exception {
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(UUID.randomUUID().toString());
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
}
@Test
public void postBackchannelLogoutWithSessionIdMultipleOpenSession() throws Exception {
logInAsUserInIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
String sessionId1ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionId1ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId1ConsumerRealm);
OAuthClient oauth2 = new OAuthClient();
oauth2.init(driver2);
oauth2.realm(nbc.consumerRealmName())
.clientId(ACCOUNT_CLIENT_NAME)
.redirectUri(getAuthServerRoot() + "realms/" + nbc.consumerRealmName() + "/account")
.doLoginSocial(nbc.getIDPAlias(), nbc.getUserLogin(), nbc.getUserPassword());
String sessionId2ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionId2ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId2ConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionId1ProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
assertConsumerLogoutEvent(sessionId1ConsumerRealm, userIdConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId1ConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId2ConsumerRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionId1ProviderRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionId2ProviderRealm);
}
@Test
public void postBackchannelLogoutWithoutSessionIdMultipleOpenSession() throws Exception {
logInAsUserInIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
String sessionId1ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionId1ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId1ConsumerRealm);
loginWithSecondBrowser(nbc.getIDPAlias());
String sessionId2ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionId2ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId2ConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
List<String> expectedSessionIdsInLogoutEvents = Arrays.asList(sessionId1ConsumerRealm, sessionId2ConsumerRealm);
assertConsumerLogoutEvents(expectedSessionIdsInLogoutEvents, userIdConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId1ConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId2ConsumerRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionId1ProviderRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionId2ProviderRealm);
}
@Test
public void postBackchannelLogoutWithSessionIdMultipleOpenSessionDifferentIdentityProvider() throws Exception {
IdentityProviderRepresentation identityProvider2 = addSecondIdentityProviderToConsumerRealm();
logInAsUserInIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
adminClient.realm(nbc.consumerRealmName()).users().get(userIdConsumerRealm)
.resetPassword(CredentialBuilder.create().password(USER_PASSWORD_CONSUMER_REALM).build());
String sessionId1ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionId1ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId1ConsumerRealm);
OAuthClient oauth2 = loginWithSecondBrowser(identityProvider2.getDisplayName());
linkUsers(oauth2);
String sessionId2ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionId2ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId2ConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionId1ProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
assertConsumerLogoutEvent(sessionId1ConsumerRealm, userIdConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId1ConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId2ConsumerRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionId1ProviderRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionId2ProviderRealm);
}
@Test
public void postBackchannelLogoutWithoutSessionIdMultipleOpenSessionDifferentIdentityProvider() throws Exception {
IdentityProviderRepresentation identityProvider2 = addSecondIdentityProviderToConsumerRealm();
logInAsUserInIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
adminClient.realm(nbc.consumerRealmName()).users().get(userIdConsumerRealm)
.resetPassword(CredentialBuilder.create().password(USER_PASSWORD_CONSUMER_REALM).build());
String sessionId1ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionId1ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId1ConsumerRealm);
OAuthClient oauth2 = loginWithSecondBrowser(identityProvider2.getDisplayName());
linkUsers(oauth2);
String sessionId2ProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionId2ConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId2ConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
List<String> expectedSessionIdsInLogoutEvents = Arrays.asList(sessionId1ConsumerRealm, sessionId2ConsumerRealm);
assertConsumerLogoutEvents(expectedSessionIdsInLogoutEvents, userIdConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId1ConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionId2ConsumerRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionId1ProviderRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionId2ProviderRealm);
}
@Test
public void postBackchannelLogoutOnDisabledClientReturnsNotImplemented() throws Exception {
logInAsUserInIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionIdConsumerRealm = assertConsumerLoginEventAccountManagement(userIdConsumerRealm);
assertActiveSessionInClient(nbc.consumerRealmName(), accountClientIdConsumerRealm, userIdConsumerRealm,
sessionIdConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionIdProviderRealm);
disableClient(nbc.consumerRealmName(), accountClientIdConsumerRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.NOT_IMPLEMENTED));
assertThat(response, Matchers.bodyHC(containsString("There was an error in the local logout")));
}
assertLogoutErrorEvent(nbc.consumerRealmName());
}
@Test
public void postBackchannelLogoutNestedBrokering() throws Exception {
String consumerClientId = OidcBackchannelLogoutBrokerConfiguration.CONSUMER_CLIENT_ID;
logInAsUserInNestedIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
String userIdSubConsumerRealm = getUserIdSubConsumerRealm();
String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionIdConsumerRealm = assertConsumerLoginEvent(userIdConsumerRealm, consumerClientId);
assertActiveSessionInClient(nbc.consumerRealmName(), consumerClientId, userIdConsumerRealm,
sessionIdConsumerRealm);
String sessionIdSubConsumerRealm =
assertLoginEvent(userIdSubConsumerRealm, ACCOUNT_CLIENT_NAME, nbc.subConsumerRealmName());
assertActiveSessionInClient(nbc.subConsumerRealmName(), accountClientIdSubConsumerRealm, userIdSubConsumerRealm,
sessionIdSubConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionIdProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.OK));
}
assertConsumerLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm);
assertLogoutEvent(sessionIdSubConsumerRealm, userIdSubConsumerRealm, nbc.subConsumerRealmName());
assertNoSessionsInClient(nbc.consumerRealmName(), consumerClientId, userIdConsumerRealm,
sessionIdConsumerRealm);
assertNoSessionsInClient(nbc.subConsumerRealmName(), accountClientIdSubConsumerRealm, userIdSubConsumerRealm,
sessionIdSubConsumerRealm);
assertActiveSessionInClient(nbc.providerRealmName(), BROKER_CLIENT_ID, userIdProviderRealm,
sessionIdProviderRealm);
}
@Test
public void postBackchannelLogoutNestedBrokeringDownstreamLogoutOfSubConsumerFails() throws Exception {
String consumerClientId = OidcBackchannelLogoutBrokerConfiguration.CONSUMER_CLIENT_ID;
logInAsUserInNestedIDPForFirstTime();
String userIdConsumerRealm = getUserIdConsumerRealm();
String userIdSubConsumerRealm = getUserIdSubConsumerRealm();
String sessionIdProviderRealm = assertProviderLoginEventIdpClient(userIdProviderRealm);
String sessionIdConsumerRealm = assertConsumerLoginEvent(userIdConsumerRealm, consumerClientId);
assertActiveSessionInClient(nbc.consumerRealmName(), consumerClientId, userIdConsumerRealm,
sessionIdConsumerRealm);
String sessionIdSubConsumerRealm =
assertLoginEvent(userIdSubConsumerRealm, ACCOUNT_CLIENT_NAME, nbc.subConsumerRealmName());
assertActiveSessionInClient(nbc.subConsumerRealmName(), accountClientIdSubConsumerRealm, userIdSubConsumerRealm,
sessionIdSubConsumerRealm);
disableClient(nbc.subConsumerRealmName(), accountClientIdSubConsumerRealm);
String logoutTokenEncoded = getLogoutTokenEncodedAndSigned(userIdProviderRealm, sessionIdProviderRealm);
oauth.realm(nbc.consumerRealmName());
try (CloseableHttpResponse response = oauth.doBackchannelLogout(logoutTokenEncoded)) {
assertThat(response, Matchers.statusCodeIsHC(Response.Status.GATEWAY_TIMEOUT));
}
assertLogoutErrorEvent(nbc.subConsumerRealmName());
assertConsumerLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm);
assertNoSessionsInClient(nbc.consumerRealmName(), consumerClientId, userIdConsumerRealm,
sessionIdConsumerRealm);
}
private String getLogoutTokenEncodedAndSigned(String userId) throws IOException {
return getLogoutTokenEncodedAndSigned(userId, null);
}
private String getLogoutTokenEncodedAndSigned(String userId, String sessionId) throws IOException {
String keyId = adminClient.realm(nbc.providerRealmName())
.keys().getKeyMetadata().getKeys().stream()
.filter(key -> providerId.equals(key.getProviderId()))
.findFirst().get()
.getKid();
return LogoutTokenUtil.generateSignedLogoutToken(KEY_PAIR.getPrivate(),
keyId,
getConsumerRoot() + "/auth/realms/" + nbc.providerRealmName(),
nbc.getIDPClientIdInProviderRealm(),
userId,
sessionId);
}
private String assertConsumerLoginEventAccountManagement(String userIdConsumerRealm) {
return assertConsumerLoginEvent(userIdConsumerRealm, ACCOUNT_CLIENT_NAME);
}
private String assertConsumerLoginEvent(String userIdConsumerRealm, String clientId) {
return assertLoginEvent(userIdConsumerRealm, clientId, nbc.consumerRealmName());
}
private String assertLoginEvent(String userId, String clientId, String realmName) {
String sessionId = null;
String realmId = adminClient.realm(realmName).toRepresentation().getId();
List<EventRepresentation> eventList = adminClient.realm(realmName).getEvents();
Optional<EventRepresentation> loginEventOptional = eventList.stream()
.filter(event -> userId.equals(event.getUserId()))
.filter(event -> event.getType().equals(EventType.LOGIN.name()))
.findAny();
if (loginEventOptional.isPresent()) {
EventRepresentation loginEvent = loginEventOptional.get();
this.events.expectLogin()
.realm(realmId)
.client(clientId)
.user(userId)
.removeDetail(Details.CODE_ID)
.removeDetail(Details.REDIRECT_URI)
.removeDetail(Details.CONSENT)
.assertEvent(loginEvent);
sessionId = loginEvent.getSessionId();
} else {
fail("No Login event found for user " + userId);
}
return sessionId;
}
private String assertProviderLoginEventIdpClient(String userIdProviderRealm) {
return assertLoginEvent(userIdProviderRealm, BROKER_CLIENT_ID, nbc.providerRealmName());
}
private void assertConsumerLogoutEvent(String sessionIdConsumerRealm, String userIdConsumerRealm) {
assertLogoutEvent(sessionIdConsumerRealm, userIdConsumerRealm, nbc.consumerRealmName());
}
private void assertLogoutEvent(String sessionId, String userId, String realmName) {
String realmId = adminClient.realm(realmName).toRepresentation().getId();
List<EventRepresentation> eventList = adminClient.realm(realmName).getEvents();
Optional<EventRepresentation> logoutEventOptional = eventList.stream()
.filter(event -> sessionId.equals(event.getSessionId()))
.findAny();
if (logoutEventOptional.isPresent()) {
EventRepresentation logoutEvent = logoutEventOptional.get();
this.events.expectLogout(sessionId)
.realm(realmId)
.user(userId)
.removeDetail(Details.REDIRECT_URI)
.assertEvent(logoutEvent);
} else {
fail("No Logout event found for session " + sessionId);
}
}
private void assertLogoutErrorEvent(String realmName) {
String realmId = adminClient.realm(realmName).toRepresentation().getId();
List<EventRepresentation> eventList = adminClient.realm(realmName).getEvents();
Optional<EventRepresentation> logoutErrorEventOptional = eventList.stream()
.filter(event -> event.getError().equals(Errors.LOGOUT_FAILED))
.findAny();
if (logoutErrorEventOptional.isPresent()) {
EventRepresentation logoutEvent = logoutErrorEventOptional.get();
this.events.expectLogoutError(Errors.LOGOUT_FAILED)
.realm(realmId)
.assertEvent(logoutEvent);
} else {
fail("No Logout error event found in realm " + realmName);
}
}
private void assertConsumerLogoutEvents(List<String> sessionIdsConsumerRealm, String userIdConsumerRealm) {
List<EventRepresentation> consumerRealmEvents = adminClient.realm(nbc.consumerRealmName()).getEvents();
for (String sessionId : sessionIdsConsumerRealm) {
Optional<EventRepresentation> logoutEventOptional = consumerRealmEvents.stream()
.filter(event -> sessionId.equals(event.getSessionId()))
.findAny();
if (logoutEventOptional.isPresent()) {
EventRepresentation logoutEvent = logoutEventOptional.get();
this.events.expectLogout(sessionId)
.realm(realmIdConsumerRealm)
.user(userIdConsumerRealm)
.removeDetail(Details.REDIRECT_URI)
.assertEvent(logoutEvent);
} else {
fail("No Logout event found for session " + sessionId);
}
}
}
private String getUserIdConsumerRealm() {
return getUserId(nbc.consumerRealmName());
}
private String getUserIdSubConsumerRealm() {
return getUserId(nbc.subConsumerRealmName());
}
private String getUserId(String realmName) {
RealmResource realmResourceConsumerRealm = adminClient.realm(realmName);
return realmResourceConsumerRealm.users().list().get(0).getId();
}
private void assertActiveSessionInClient(String realmName, String clientId, String userId,
String sessionId) {
List<UserSessionRepresentation> sessions = getClientSessions(realmName, clientId, userId, sessionId);
assertThat(sessions.size(), is(1));
}
private void assertNoSessionsInClient(String realmName, String clientId, String userId, String sessionId) {
List<UserSessionRepresentation> sessions = getClientSessions(realmName, clientId, userId, sessionId);
assertThat(sessions.size(), is(0));
}
private List<UserSessionRepresentation> getClientSessions(String realmName, String clientId, String userId,
String sessionId) {
return adminClient.realm(realmName)
.clients()
.get(clientId)
.getUserSessions(0, 5)
.stream()
.filter(s -> s.getUserId().equals(userId) && s.getId().equals(sessionId))
.collect(Collectors.toList());
}
private IdentityProviderRepresentation addSecondIdentityProviderToConsumerRealm() {
log.debug("adding second identity provider to realm " + nbc.consumerRealmName());
IdentityProviderRepresentation identityProvider2 = nbc.setUpIdentityProvider();
identityProvider2.setAlias(identityProvider2.getAlias() + "2");
identityProvider2.setDisplayName(identityProvider2.getDisplayName() + "2");
Map<String, String> config = identityProvider2.getConfig();
config.put("clientId", BROKER_CLIENT_ID);
adminClient.realm(nbc.consumerRealmName()).identityProviders().create(identityProvider2).close();
ClientResource ipdClientResource = adminClient.realm(nbc.providerRealmName()).clients()
.get(nbc.getIDPClientIdInProviderRealm());
ClientRepresentation clientRepresentation = ipdClientResource.toRepresentation();
clientRepresentation.getRedirectUris().add(getConsumerRoot() + "/auth/realms/" + nbc.consumerRealmName()
+ "/broker/" + identityProvider2.getAlias() + "/endpoint/*");
ipdClientResource.update(clientRepresentation);
return identityProvider2;
}
private void disableClient(String realmName, String clientId) {
ClientResource accountClient = adminClient.realm(realmName)
.clients()
.get(clientId);
ClientRepresentation clientRepresentation = accountClient.toRepresentation();
clientRepresentation.setEnabled(false);
accountClient.update(clientRepresentation);
}
private OAuthClient loginWithSecondBrowser(String identityProviderDisplayName) {
OAuthClient oauth2 = new OAuthClient();
oauth2.init(driver2);
oauth2.realm(nbc.consumerRealmName())
.clientId(ACCOUNT_CLIENT_NAME)
.redirectUri(getAuthServerRoot() + "realms/" + nbc.consumerRealmName() + "/account")
.doLoginSocial(identityProviderDisplayName, nbc.getUserLogin(), nbc.getUserPassword());
return oauth2;
}
private void linkUsers(OAuthClient oauth) {
oauth.updateAccountInformation(nbc.getUserLogin(), nbc.getUserEmail());
oauth.linkUsers(nbc.getUserLogin(), USER_PASSWORD_CONSUMER_REALM);
}
}

View file

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

View file

@ -0,0 +1,53 @@
package org.keycloak.testsuite.util;
import org.apache.http.entity.ContentType;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.Base64Url;
import org.keycloak.crypto.JavaAlgorithm;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.representations.LogoutToken;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.SignatureException;
import java.util.HashMap;
import java.util.UUID;
public class LogoutTokenUtil {
public static String generateSignedLogoutToken(PrivateKey privateKey, String keyId,
String issuer, String clientId, String userId, String sessionId) throws IOException {
JWSHeader jwsHeader =
new JWSHeader(Algorithm.RS256, OAuth2Constants.JWT, ContentType.APPLICATION_JSON.toString(), keyId);
String logoutTokenHeaderEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(jwsHeader));
LogoutToken logoutToken = new LogoutToken();
logoutToken.setSid(sessionId);
logoutToken.putEvents(TokenUtil.TOKEN_BACKCHANNEL_LOGOUT_EVENT, new HashMap<>());
logoutToken.setSubject(userId);
logoutToken.issuer(issuer);
logoutToken.id(UUID.randomUUID().toString());
logoutToken.issuedNow();
logoutToken.audience(clientId);
String logoutTokenPayloadEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(logoutToken));
try {
Signature signature = Signature.getInstance(JavaAlgorithm.RS256);
signature.initSign(privateKey);
String data = logoutTokenHeaderEncoded + "." + logoutTokenPayloadEncoded;
byte[] dataByteArray = data.getBytes();
signature.update(dataByteArray);
byte[] signatureByteArray = signature.sign();
return data + "." + Base64Url.encode(signatureByteArray);
} catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
return null;
}
}
}

View file

@ -1,14 +1,28 @@
package org.keycloak.testsuite.util;
import org.keycloak.admin.client.resource.ComponentResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.util.Base64;
import org.keycloak.common.util.CertificateUtils;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.PemUtils;
import org.keycloak.crypto.KeyType;
import org.keycloak.keys.Attributes;
import org.keycloak.keys.ImportedRsaKeyProviderFactory;
import org.keycloak.keys.KeyProvider;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.stream.Collectors;
import javax.ws.rs.core.Response;
/**
* @author <a href="mailto:bruno@abstractj.org">Bruno Oliveira</a>.
@ -92,6 +106,48 @@ public class RealmManager {
realm.update(rep);
}
public String generateNewRsaKey(KeyPair keyPair, String name) {
RealmRepresentation rep = realm.toRepresentation();
Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate(keyPair, "test");
String certificatePem = PemUtils.encodeCertificate(certificate);
ComponentRepresentation keyProviderRepresentation = new ComponentRepresentation();
keyProviderRepresentation.setName(name);
keyProviderRepresentation.setParentId(rep.getId());
keyProviderRepresentation.setProviderId(ImportedRsaKeyProviderFactory.ID);
keyProviderRepresentation.setProviderType(KeyProvider.class.getName());
MultivaluedHashMap<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) {
RealmRepresentation rep = realm.toRepresentation();
rep.setSsoSessionMaxLifespan(ssoSessionMaxLifespan);

View file

@ -353,6 +353,10 @@ idp-sso-relay-state=IDP Initiated SSO Relay State
idp-sso-relay-state.tooltip=Relay state you want to send with SAML request when you want to do IDP Initiated SSO.
web-origins=Web Origins
web-origins.tooltip=Allowed CORS origins. To permit all origins of Valid Redirect URIs, add '+'. This does not include the '*' wildcard though. To permit all origins, explicitly add '*'.
backchannel-logout-url=Backchannel Logout URL
backchannel-logout-url.tooltip=URL that will cause the client to log itself out when a logout request is sent to this realm (via end_session_endpoint). If omitted, no logout request will be sent to the client is this case.
backchannel-logout-session-required=Backchannel Logout Session Required
backchannel-logout-session-required.tooltip=Specifying whether a sid (session ID) Claim is included in the Logout Token when the Backchannel Logout URL is used.
fine-oidc-endpoint-conf=Fine Grain OpenID Connect Configuration
fine-oidc-endpoint-conf.tooltip=Expand this section to configure advanced settings of this client related to OpenID Connect protocol
access-token-signed-response-alg=Access Token Signature Algorithm

View file

@ -1290,6 +1290,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.displayOnConsentScreen = false;
}
}
if ($scope.client.attributes["backchannel.logout.session.required"]) {
if ($scope.client.attributes["backchannel.logout.session.required"] == "true") {
$scope.backchannelLogoutSessionRequired = true;
} else {
$scope.backchannelLogoutSessionRequired = false;
}
}
}
if (!$scope.create) {
@ -1618,6 +1626,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.clientEdit.attributes["display.on.consent.screen"] = "false";
}
if ($scope.backchannelLogoutSessionRequired == true) {
$scope.clientEdit.attributes["backchannel.logout.session.required"] = "true";
} else {
$scope.clientEdit.attributes["backchannel.logout.session.required"] = "false";
}
$scope.clientEdit.protocol = $scope.protocol;
$scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm;
$scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat;

View file

@ -359,6 +359,20 @@
<kc-tooltip>{{:: 'web-origins.tooltip' | translate}}</kc-tooltip>
</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 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>