[KEYCLOAK-16676] - Client attributes should not be stored if null or empty

This commit is contained in:
Pedro Igor 2021-02-02 16:00:48 -03:00 committed by Hynek Mlnařík
parent 7780badb2a
commit 0f30b3118a
7 changed files with 132 additions and 4 deletions

View file

@ -293,13 +293,26 @@ public class ClientAdapter implements ClientModel, JpaModel<ClientEntity> {
@Override @Override
public void setAttribute(String name, String value) { public void setAttribute(String name, String value) {
boolean valueUndefined = value == null || "".equals(value.trim());
for (ClientAttributeEntity attr : entity.getAttributes()) { for (ClientAttributeEntity attr : entity.getAttributes()) {
if (attr.getName().equals(name)) { if (attr.getName().equals(name)) {
// clean up, so that attributes previously set with either a empty or null value are removed
// we should remove this in future versions so that new clients never store empty/null attributes
if (valueUndefined) {
removeAttribute(name);
} else {
attr.setValue(value); attr.setValue(value);
}
return; return;
} }
} }
// do not create attributes if empty or null
if (valueUndefined) {
return;
}
ClientAttributeEntity attr = new ClientAttributeEntity(); ClientAttributeEntity attr = new ClientAttributeEntity();
attr.setName(name); attr.setName(name);
attr.setValue(value); attr.setValue(value);

View file

@ -245,6 +245,13 @@ public abstract class MapClientAdapter extends AbstractClientModel<MapClientEnti
@Override @Override
public void setAttribute(String name, String value) { public void setAttribute(String name, String value) {
boolean valueUndefined = value == null || "".equals(value.trim());
if (valueUndefined) {
removeAttribute(name);
return;
}
entity.setAttribute(name, value); entity.setAttribute(name, value);
} }

View file

@ -19,11 +19,12 @@ package org.keycloak.testsuite.rest;
import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.ResteasyProviderFactory; import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.HtmlUtils; import org.keycloak.common.util.HtmlUtils;
import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException; import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.adapters.action.LogoutAction; import org.keycloak.representations.adapters.action.LogoutAction;
import org.keycloak.representations.adapters.action.PushNotBeforeAction; import org.keycloak.representations.adapters.action.PushNotBeforeAction;
import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.representations.adapters.action.TestAvailabilityAction;
@ -55,6 +56,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
private KeycloakSession session; private KeycloakSession session;
private final BlockingQueue<LogoutAction> adminLogoutActions; private final BlockingQueue<LogoutAction> adminLogoutActions;
private final BlockingQueue<LogoutToken> backChannelLogoutTokens;
private final BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions; private final BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions;
private final BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction; private final BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction;
private final TestApplicationResourceProviderFactory.OIDCClientData oidcClientData; private final TestApplicationResourceProviderFactory.OIDCClientData oidcClientData;
@ -63,10 +65,13 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
HttpRequest request; HttpRequest request;
public TestApplicationResourceProvider(KeycloakSession session, BlockingQueue<LogoutAction> adminLogoutActions, public TestApplicationResourceProvider(KeycloakSession session, BlockingQueue<LogoutAction> adminLogoutActions,
BlockingQueue<LogoutToken> backChannelLogoutTokens,
BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions, BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions,
BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction, TestApplicationResourceProviderFactory.OIDCClientData oidcClientData) { BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction,
TestApplicationResourceProviderFactory.OIDCClientData oidcClientData) {
this.session = session; this.session = session;
this.adminLogoutActions = adminLogoutActions; this.adminLogoutActions = adminLogoutActions;
this.backChannelLogoutTokens = backChannelLogoutTokens;
this.adminPushNotBeforeActions = adminPushNotBeforeActions; this.adminPushNotBeforeActions = adminPushNotBeforeActions;
this.adminTestAvailabilityAction = adminTestAvailabilityAction; this.adminTestAvailabilityAction = adminTestAvailabilityAction;
this.oidcClientData = oidcClientData; this.oidcClientData = oidcClientData;
@ -79,6 +84,13 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
adminLogoutActions.add(new JWSInput(data).readJsonContent(LogoutAction.class)); adminLogoutActions.add(new JWSInput(data).readJsonContent(LogoutAction.class));
} }
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path("/admin/backchannelLogout")
public void backchannelLogout() throws JWSInputException {
backChannelLogoutTokens.add(new JWSInput(request.getDecodedFormParameters().getFirst(OAuth2Constants.LOGOUT_TOKEN)).readJsonContent(LogoutToken.class));
}
@POST @POST
@Consumes(MediaType.TEXT_PLAIN_UTF_8) @Consumes(MediaType.TEXT_PLAIN_UTF_8)
@Path("/admin/k_push_not_before") @Path("/admin/k_push_not_before")
@ -100,6 +112,13 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
return adminLogoutActions.poll(10, TimeUnit.SECONDS); return adminLogoutActions.poll(10, TimeUnit.SECONDS);
} }
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/poll-backchannel-logout")
public LogoutToken getBackChannelLogoutAction() throws InterruptedException {
return backChannelLogoutTokens.poll(20, TimeUnit.SECONDS);
}
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Path("/poll-admin-not-before") @Path("/poll-admin-not-before")

View file

@ -24,6 +24,7 @@ import org.keycloak.crypto.KeyType;
import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyUse;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.adapters.action.LogoutAction; import org.keycloak.representations.adapters.action.LogoutAction;
import org.keycloak.representations.adapters.action.PushNotBeforeAction; import org.keycloak.representations.adapters.action.PushNotBeforeAction;
import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.representations.adapters.action.TestAvailabilityAction;
@ -41,6 +42,7 @@ import java.util.concurrent.LinkedBlockingDeque;
public class TestApplicationResourceProviderFactory implements RealmResourceProviderFactory { public class TestApplicationResourceProviderFactory implements RealmResourceProviderFactory {
private BlockingQueue<LogoutAction> adminLogoutActions = new LinkedBlockingDeque<>(); private BlockingQueue<LogoutAction> adminLogoutActions = new LinkedBlockingDeque<>();
private BlockingQueue<LogoutToken> backChannelLogoutTokens = new LinkedBlockingDeque<>();
private BlockingQueue<PushNotBeforeAction> pushNotBeforeActions = new LinkedBlockingDeque<>(); private BlockingQueue<PushNotBeforeAction> pushNotBeforeActions = new LinkedBlockingDeque<>();
private BlockingQueue<TestAvailabilityAction> testAvailabilityActions = new LinkedBlockingDeque<>(); private BlockingQueue<TestAvailabilityAction> testAvailabilityActions = new LinkedBlockingDeque<>();
@ -48,7 +50,8 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv
@Override @Override
public RealmResourceProvider create(KeycloakSession session) { public RealmResourceProvider create(KeycloakSession session) {
TestApplicationResourceProvider provider = new TestApplicationResourceProvider(session, adminLogoutActions, pushNotBeforeActions, testAvailabilityActions, oidcClientData); TestApplicationResourceProvider provider = new TestApplicationResourceProvider(session, adminLogoutActions,
backChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData);
ResteasyProviderFactory.getInstance().injectProperties(provider); ResteasyProviderFactory.getInstance().injectProperties(provider);

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.client.resources; package org.keycloak.testsuite.client.resources;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.adapters.action.LogoutAction; import org.keycloak.representations.adapters.action.LogoutAction;
import org.keycloak.representations.adapters.action.PushNotBeforeAction; import org.keycloak.representations.adapters.action.PushNotBeforeAction;
import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.representations.adapters.action.TestAvailabilityAction;
@ -39,6 +40,11 @@ public interface TestApplicationResource {
@Path("/poll-admin-logout") @Path("/poll-admin-logout")
LogoutAction getAdminLogoutAction(); LogoutAction getAdminLogoutAction();
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/poll-backchannel-logout")
LogoutToken getBackChannelLogoutToken();
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Path("/poll-admin-not-before") @Path("/poll-admin-not-before")

View file

@ -28,6 +28,7 @@ import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.AccountRoles; import org.keycloak.models.AccountRoles;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.adapters.action.GlobalRequestResult;
@ -385,6 +386,13 @@ public class ClientTest extends AbstractAdminTest {
storedClient = realm.clients().get(client.getId()).toRepresentation(); storedClient = realm.clients().get(client.getId()).toRepresentation();
assertClient(client, storedClient); assertClient(client, storedClient);
storedClient.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "");
realm.clients().get(storedClient.getId()).update(storedClient);
storedClient = realm.clients().get(client.getId()).toRepresentation();
assertFalse(storedClient.getAttributes().containsKey(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL));
} }
@Test @Test

View file

@ -23,11 +23,15 @@ import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInput;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.Assert;
@ -328,6 +332,74 @@ public class LogoutTest extends AbstractKeycloakTest {
} }
} }
@Test
public void successfulKLogoutAfterEmptyBackChannelUrl() throws Exception {
ClientsResource clients = adminClient.realm(oauth.getRealm()).clients();
ClientRepresentation rep = clients.findByClientId(oauth.getClientId()).get(0);
rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "");
clients.get(rep.getId()).update(rep);
oauth.doLogin("test-user@localhost", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.clientSessionState("client-session");
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
String idTokenString = tokenResponse.getIdToken();
String logoutUrl = oauth.getLogoutUrl()
.idTokenHint(idTokenString)
.postLogoutRedirectUri(oauth.APP_AUTH_ROOT)
.build();
try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build();
CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) {
assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
assertThat(response.getFirstHeader(HttpHeaders.LOCATION).getValue(), is(oauth.APP_AUTH_ROOT));
}
assertNotNull(testingClient.testApp().getAdminLogoutAction());
}
@Test
public void backChannelPreferenceOverKLogout() throws Exception {
ClientsResource clients = adminClient.realm(oauth.getRealm()).clients();
ClientRepresentation rep = clients.findByClientId(oauth.getClientId()).get(0);
rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, oauth.APP_ROOT + "/admin/backchannelLogout");
ClientResource clientResource = clients.get(rep.getId());
clientResource.update(rep);
try {
oauth.doLogin("test-user@localhost", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.clientSessionState("client-session");
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
String idTokenString = tokenResponse.getIdToken();
String logoutUrl = oauth.getLogoutUrl()
.idTokenHint(idTokenString)
.postLogoutRedirectUri(oauth.APP_AUTH_ROOT)
.build();
try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build();
CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) {
assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
assertThat(response.getFirstHeader(HttpHeaders.LOCATION).getValue(), is(oauth.APP_AUTH_ROOT));
}
assertNotNull(testingClient.testApp().getBackChannelLogoutToken());
} finally {
rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "");
clientResource.update(rep);
}
}
private OAuthClient.AccessTokenResponse loginAndForceNewLoginPage() { private OAuthClient.AccessTokenResponse loginAndForceNewLoginPage() {
oauth.doLogin("test-user@localhost", "password"); oauth.doLogin("test-user@localhost", "password");