diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 584e98e671..eddf339bb9 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -17,8 +17,6 @@ package org.keycloak.common; -import org.jboss.logging.Logger; - import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -26,6 +24,8 @@ import java.util.HashSet; import java.util.Properties; import java.util.Set; +import org.jboss.logging.Logger; + import static org.keycloak.common.Profile.Type.DEPRECATED; /** @@ -34,105 +34,17 @@ import static org.keycloak.common.Profile.Type.DEPRECATED; */ public class Profile { - private static final Logger logger = Logger.getLogger(Profile.class); - public static final String PRODUCT_NAME = ProductValue.RHSSO.getName(); public static final String PROJECT_NAME = ProductValue.KEYCLOAK.getName(); - - public enum Type { - DEFAULT, - DISABLED_BY_DEFAULT, - PREVIEW, - EXPERIMENTAL, - DEPRECATED; - } - - public enum Feature { - AUTHORIZATION("Authorization Service", Type.DEFAULT), - ACCOUNT2("New Account Management Console", Type.DEFAULT), - ACCOUNT_API("Account Management REST API", Type.DEFAULT), - ADMIN_FINE_GRAINED_AUTHZ("Fine-Grained Admin Permissions", Type.PREVIEW), - ADMIN2("New Admin Console", Type.EXPERIMENTAL), - DOCKER("Docker Registry protocol", Type.DISABLED_BY_DEFAULT), - IMPERSONATION("Ability for admins to impersonate users", Type.DEFAULT), - OPENSHIFT_INTEGRATION("Extension to enable securing OpenShift", Type.PREVIEW), - SCRIPTS("Write custom authenticators using JavaScript", Type.PREVIEW), - TOKEN_EXCHANGE("Token Exchange Service", Type.PREVIEW), - UPLOAD_SCRIPTS("Ability to upload custom JavaScript through Admin REST API", DEPRECATED), - WEB_AUTHN("W3C Web Authentication (WebAuthn)", Type.DEFAULT, Type.PREVIEW), - CLIENT_POLICIES("Client configuration policies", Type.DEFAULT), - CIBA("OpenID Connect Client Initiated Backchannel Authentication (CIBA)", Type.DEFAULT), - MAP_STORAGE("New store", Type.EXPERIMENTAL), - PAR("OAuth 2.0 Pushed Authorization Requests (PAR)", Type.DEFAULT), - DECLARATIVE_USER_PROFILE("Configure user profiles using a declarative style", Type.PREVIEW), - DYNAMIC_SCOPES("Dynamic OAuth 2.0 scopes", Type.EXPERIMENTAL), - STEP_UP_AUTHENTICATION("Step-up Authentication", Type.DEFAULT); - - private String label; - private final Type typeProject; - private final Type typeProduct; - - Feature(String label, Type type) { - this(label, type, type); - } - - Feature(String label, Type typeProject, Type typeProduct) { - this.label = label; - this.typeProject = typeProject; - this.typeProduct = typeProduct; - } - - public String getLabel() { - return label; - } - - public Type getTypeProject() { - return typeProject; - } - - public Type getTypeProduct() { - return typeProduct; - } - - public boolean hasDifferentProductType() { - return typeProject != typeProduct; - } - } - - private enum ProductValue { - KEYCLOAK("Keycloak"), - RHSSO("RH-SSO"); - - private final String name; - - ProductValue(String name) { - this.name = name; - } - - public String getName() { - return name; - } - } - - private enum ProfileValue { - COMMUNITY, - PRODUCT, - PREVIEW - } - + private static final Logger logger = Logger.getLogger(Profile.class); private static Profile CURRENT; - private final ProductValue product; - private final ProfileValue profile; - private final Set disabledFeatures = new HashSet<>(); private final Set previewFeatures = new HashSet<>(); private final Set experimentalFeatures = new HashSet<>(); private final Set deprecatedFeatures = new HashSet<>(); - private final PropertyResolver propertyResolver; - public Profile(PropertyResolver resolver) { this.propertyResolver = resolver; Config config = new Config(); @@ -191,14 +103,14 @@ public class Profile { return CURRENT; } - public static void init() { - CURRENT = new Profile(null); - } - public static void setInstance(Profile instance) { CURRENT = instance; } + public static void init() { + CURRENT = new Profile(null); + } + public static String getName() { return getInstance().profile.name().toLowerCase(); } @@ -227,6 +139,93 @@ public class Profile { return getInstance().profile.equals(ProfileValue.PRODUCT); } + public enum Type { + DEFAULT, + DISABLED_BY_DEFAULT, + PREVIEW, + EXPERIMENTAL, + DEPRECATED; + } + + public enum Feature { + AUTHORIZATION("Authorization Service", Type.DEFAULT), + ACCOUNT2("New Account Management Console", Type.DEFAULT), + ACCOUNT_API("Account Management REST API", Type.DEFAULT), + ADMIN_FINE_GRAINED_AUTHZ("Fine-Grained Admin Permissions", Type.PREVIEW), + ADMIN2("New Admin Console", Type.EXPERIMENTAL), + DOCKER("Docker Registry protocol", Type.DISABLED_BY_DEFAULT), + IMPERSONATION("Ability for admins to impersonate users", Type.DEFAULT), + OPENSHIFT_INTEGRATION("Extension to enable securing OpenShift", Type.PREVIEW), + SCRIPTS("Write custom authenticators using JavaScript", Type.PREVIEW), + TOKEN_EXCHANGE("Token Exchange Service", Type.PREVIEW), + UPLOAD_SCRIPTS("Ability to upload custom JavaScript through Admin REST API", DEPRECATED), + WEB_AUTHN("W3C Web Authentication (WebAuthn)", Type.DEFAULT, Type.PREVIEW), + CLIENT_POLICIES("Client configuration policies", Type.DEFAULT), + CIBA("OpenID Connect Client Initiated Backchannel Authentication (CIBA)", Type.DEFAULT), + MAP_STORAGE("New store", Type.EXPERIMENTAL), + PAR("OAuth 2.0 Pushed Authorization Requests (PAR)", Type.DEFAULT), + DECLARATIVE_USER_PROFILE("Configure user profiles using a declarative style", Type.PREVIEW), + DYNAMIC_SCOPES("Dynamic OAuth 2.0 scopes", Type.EXPERIMENTAL), + CLIENT_SECRET_ROTATION("Client Secret Rotation", Type.PREVIEW), + STEP_UP_AUTHENTICATION("Step-up Authentication", Type.DEFAULT); + + + private final Type typeProject; + private final Type typeProduct; + private String label; + + Feature(String label, Type type) { + this(label, type, type); + } + + Feature(String label, Type typeProject, Type typeProduct) { + this.label = label; + this.typeProject = typeProject; + this.typeProduct = typeProduct; + } + + public String getLabel() { + return label; + } + + public Type getTypeProject() { + return typeProject; + } + + public Type getTypeProduct() { + return typeProduct; + } + + public boolean hasDifferentProductType() { + return typeProject != typeProduct; + } + } + + private enum ProductValue { + KEYCLOAK("Keycloak"), + RHSSO("RH-SSO"); + + private final String name; + + ProductValue(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + private enum ProfileValue { + COMMUNITY, + PRODUCT, + PREVIEW + } + + public interface PropertyResolver { + String resolve(String feature); + } + private class Config { private Properties properties; @@ -287,17 +286,13 @@ public class Profile { if (value != null) { return value; } - + if (propertyResolver != null) { return propertyResolver.resolve(name); } - + return null; } } - - public interface PropertyResolver { - String resolve(String feature); - } } diff --git a/common/src/test/java/org/keycloak/common/ProfileTest.java b/common/src/test/java/org/keycloak/common/ProfileTest.java index 8fc41c6a99..ff18cc3f62 100644 --- a/common/src/test/java/org/keycloak/common/ProfileTest.java +++ b/common/src/test/java/org/keycloak/common/ProfileTest.java @@ -12,6 +12,7 @@ import java.util.Arrays; import java.util.Comparator; import java.util.Properties; import java.util.Set; +import org.keycloak.common.Profile.Feature; public class ProfileTest { @@ -21,8 +22,8 @@ public class ProfileTest { @Test public void checkDefaultsKeycloak() { Assert.assertEquals("community", Profile.getName()); - assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.DECLARATIVE_USER_PROFILE); + assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); @@ -37,8 +38,8 @@ public class ProfileTest { Profile.init(); Assert.assertEquals("product", Profile.getName()); - assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.DECLARATIVE_USER_PROFILE); + assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); diff --git a/core/src/main/java/org/keycloak/representations/idm/ClientPolicyExecutorConfigurationRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ClientPolicyExecutorConfigurationRepresentation.java index e38a900f03..46bcd8ecd6 100644 --- a/core/src/main/java/org/keycloak/representations/idm/ClientPolicyExecutorConfigurationRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/ClientPolicyExecutorConfigurationRepresentation.java @@ -42,4 +42,8 @@ public class ClientPolicyExecutorConfigurationRepresentation { public void setConfigAsMap(String name, Object value) { this.configAsMap.put(name, value); } + + public boolean validateConfig(){ + return true; + } } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientResource.java index dfb31c8180..87cfc7199a 100755 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/ClientResource.java @@ -17,15 +17,8 @@ package org.keycloak.admin.client.resource; -import org.jboss.resteasy.annotations.cache.NoCache; -import org.keycloak.representations.adapters.action.GlobalRequestResult; -import org.keycloak.representations.idm.ClientRepresentation; -import org.keycloak.representations.idm.ClientScopeRepresentation; -import org.keycloak.representations.idm.CredentialRepresentation; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.representations.idm.UserSessionRepresentation; -import org.keycloak.representations.idm.ManagementPermissionReference; -import org.keycloak.representations.idm.ManagementPermissionRepresentation; +import java.util.List; +import java.util.Map; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -37,8 +30,16 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; -import java.util.List; -import java.util.Map; + +import org.jboss.resteasy.annotations.cache.NoCache; +import org.keycloak.representations.adapters.action.GlobalRequestResult; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.ManagementPermissionReference; +import org.keycloak.representations.idm.ManagementPermissionRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.UserSessionRepresentation; /** * @author rodrigo.sasaki@icarros.com.br @@ -207,4 +208,16 @@ public interface ClientResource { @Path("/authz/resource-server") AuthorizationResource authorization(); + + + @Path("client-secret/rotated") + @GET + @Produces(MediaType.APPLICATION_JSON) + public CredentialRepresentation getClientRotatedSecret(); + + @Path("client-secret/rotated") + @DELETE + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public void invalidateRotatedSecret(); } \ No newline at end of file diff --git a/server-spi-private/src/main/java/org/keycloak/models/ClientSecretConstants.java b/server-spi-private/src/main/java/org/keycloak/models/ClientSecretConstants.java new file mode 100644 index 0000000000..63849c90e9 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/ClientSecretConstants.java @@ -0,0 +1,17 @@ +package org.keycloak.models; + +/** + * @author Marcelo Sales + */ +public class ClientSecretConstants { + + // client attribute names + public static final String CLIENT_SECRET_ROTATION_ENABLED = "client.secret.rotation.enabled"; + public static final String CLIENT_SECRET_CREATION_TIME = "client.secret.creation.time"; + public static final String CLIENT_SECRET_EXPIRATION = "client.secret.expiration.time"; + public static final String CLIENT_ROTATED_SECRET = "client.secret.rotated"; + public static final String CLIENT_ROTATED_SECRET_CREATION_TIME = "client.secret.rotated.creation.time"; + public static final String CLIENT_ROTATED_SECRET_EXPIRATION_TIME = "client.secret.rotated.expiration.time"; + public static final String CLIENT_SECRET_REMAINING_EXPIRATION_TIME = "client.secret.remaining.expiration.time"; + +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java index a867f5e346..90eab70ef0 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java @@ -25,11 +25,13 @@ import org.keycloak.common.util.CertificateUtils; import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.PemUtils; import org.keycloak.common.util.SecretGenerator; +import org.keycloak.common.util.Time; import org.keycloak.component.ComponentModel; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.ClientSecretConstants; import org.keycloak.models.Constants; import org.keycloak.models.GroupModel; import org.keycloak.models.IdentityProviderModel; @@ -148,6 +150,7 @@ public final class KeycloakModelUtils { public static String generateSecret(ClientModel client) { String secret = SecretGenerator.getInstance().randomString(); client.setSecret(secret); + client.setAttribute(ClientSecretConstants.CLIENT_SECRET_CREATION_TIME,String.valueOf(Time.currentTime())); return secret; } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java index 351ce13ca4..1ac1d37aaf 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java @@ -22,6 +22,7 @@ import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.ClientModel; +import org.keycloak.protocol.oidc.OIDCClientSecretConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.idm.CredentialRepresentation; @@ -127,14 +128,21 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator } if (client.getSecret() == null) { - Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "unauthorized_client", "Invalid client secret"); - context.failure(AuthenticationFlowError.INVALID_CLIENT_CREDENTIALS, challengeResponse); + reportFailedAuth(context); return; } + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientModel(client); + if (!client.validateSecret(clientSecret)) { - Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "unauthorized_client", "Invalid client secret"); - context.failure(AuthenticationFlowError.INVALID_CLIENT_CREDENTIALS, challengeResponse); + if (!wrapper.validateRotatedSecret(clientSecret)){ + reportFailedAuth(context); + return; + } + } + + if (wrapper.isClientSecretExpired()){ + reportFailedAuth(context); return; } @@ -195,4 +203,9 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator return Collections.emptySet(); } } + + private void reportFailedAuth(ClientAuthenticationFlowContext context) { + Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "unauthorized_client", "Invalid client secret"); + context.failure(AuthenticationFlowError.INVALID_CLIENT_CREDENTIALS, challengeResponse); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/AbstractClientConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/AbstractClientConfigWrapper.java new file mode 100644 index 0000000000..472eeb4d41 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/AbstractClientConfigWrapper.java @@ -0,0 +1,74 @@ +package org.keycloak.protocol.oidc; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; +import org.keycloak.representations.idm.ClientRepresentation; + +public abstract class AbstractClientConfigWrapper { + protected final ClientModel clientModel; + protected final ClientRepresentation clientRep; + + protected AbstractClientConfigWrapper(ClientModel clientModel, + ClientRepresentation clientRep) { + this.clientModel = clientModel; + this.clientRep = clientRep; + } + + protected String getAttribute(String attrKey) { + if (clientModel != null) { + return clientModel.getAttribute(attrKey); + } else { + return clientRep.getAttributes() == null ? null : clientRep.getAttributes().get(attrKey); + } + } + + protected String getAttribute(String attrKey, String defaultValue) { + String value = getAttribute(attrKey); + if (value == null) { + return defaultValue; + } + return value; + } + + protected void setAttribute(String attrKey, String attrValue) { + if (clientModel != null) { + if (attrValue != null) { + clientModel.setAttribute(attrKey, attrValue); + } else { + clientModel.removeAttribute(attrKey); + } + } else { + if (attrValue != null) { + if (clientRep.getAttributes() == null) { + clientRep.setAttributes(new HashMap<>()); + } + clientRep.getAttributes().put(attrKey, attrValue); + } else { + if (clientRep.getAttributes() != null) { + clientRep.getAttributes().put(attrKey, null); + } + } + } + } + + public List getAttributeMultivalued(String attrKey) { + String attrValue = getAttribute(attrKey); + if (attrValue == null) return Collections.emptyList(); + return Arrays.asList(Constants.CFG_DELIMITER_PATTERN.split(attrValue)); + } + + public void setAttributeMultivalued(String attrKey, List attrValues) { + if (attrValues == null || attrValues.size() == 0) { + // Remove attribute + setAttribute(attrKey, null); + } else { + String attrValueFull = String.join(Constants.CFG_DELIMITER, attrValues); + setAttribute(attrKey, attrValueFull); + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java index 8905fadda5..65d513e826 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -34,17 +34,12 @@ import java.util.List; /** * @author Marek Posolda */ -public class OIDCAdvancedConfigWrapper { - - private final ClientModel clientModel; - private final ClientRepresentation clientRep; +public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper { private OIDCAdvancedConfigWrapper(ClientModel client, ClientRepresentation clientRep) { - this.clientModel = client; - this.clientRep = clientRep; + super(client,clientRep); } - public static OIDCAdvancedConfigWrapper fromClientModel(ClientModel client) { return new OIDCAdvancedConfigWrapper(client, null); } @@ -338,56 +333,4 @@ public class OIDCAdvancedConfigWrapper { setAttribute(ClientModel.TOS_URI, tosUri); } - private String getAttribute(String attrKey) { - if (clientModel != null) { - return clientModel.getAttribute(attrKey); - } else { - return clientRep.getAttributes()==null ? null : clientRep.getAttributes().get(attrKey); - } - } - - private String getAttribute(String attrKey, String defaultValue) { - String value = getAttribute(attrKey); - if (value == null) { - return defaultValue; - } - return value; - } - - private void setAttribute(String attrKey, String attrValue) { - if (clientModel != null) { - if (attrValue != null) { - clientModel.setAttribute(attrKey, attrValue); - } else { - clientModel.removeAttribute(attrKey); - } - } else { - if (attrValue != null) { - if (clientRep.getAttributes() == null) { - clientRep.setAttributes(new HashMap<>()); - } - clientRep.getAttributes().put(attrKey, attrValue); - } else { - if (clientRep.getAttributes() != null) { - clientRep.getAttributes().put(attrKey, null); - } - } - } - } - - public List getAttributeMultivalued(String attrKey) { - String attrValue = getAttribute(attrKey); - if (attrValue == null) return Collections.emptyList(); - return Arrays.asList(Constants.CFG_DELIMITER_PATTERN.split(attrValue)); - } - - public void setAttributeMultivalued(String attrKey, List attrValues) { - if (attrValues == null || attrValues.size() == 0) { - // Remove attribute - setAttribute(attrKey, null); - } else { - String attrValueFull = String.join(Constants.CFG_DELIMITER, attrValues); - setAttribute(attrKey, attrValueFull); - } - } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCClientSecretConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCClientSecretConfigWrapper.java new file mode 100644 index 0000000000..03b7fc253e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCClientSecretConfigWrapper.java @@ -0,0 +1,210 @@ +package org.keycloak.protocol.oidc; + +import java.security.MessageDigest; +import java.text.SimpleDateFormat; +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.keycloak.common.util.Time; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSecretConstants; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.utils.StringUtil; + +import static org.keycloak.models.ClientSecretConstants.CLIENT_ROTATED_SECRET; +import static org.keycloak.models.ClientSecretConstants.CLIENT_ROTATED_SECRET_CREATION_TIME; +import static org.keycloak.models.ClientSecretConstants.CLIENT_ROTATED_SECRET_EXPIRATION_TIME; +import static org.keycloak.models.ClientSecretConstants.CLIENT_SECRET_CREATION_TIME; +import static org.keycloak.models.ClientSecretConstants.CLIENT_SECRET_EXPIRATION; + +/** + * @author Marcelo Sales + */ +public class OIDCClientSecretConfigWrapper extends AbstractClientConfigWrapper { + + private OIDCClientSecretConfigWrapper(ClientModel client, ClientRepresentation clientRep) { + super(client, clientRep); + } + + public static OIDCClientSecretConfigWrapper fromClientModel(ClientModel client) { + return new OIDCClientSecretConfigWrapper(client, null); + } + + public static OIDCClientSecretConfigWrapper fromClientRepresentation( + ClientRepresentation clientRep) { + return new OIDCClientSecretConfigWrapper(null, clientRep); + } + + public String getSecret() { + if (clientModel != null) { + return clientModel.getSecret(); + } else { + return clientRep.getSecret(); + } + } + + public String getId() { + if (clientModel != null) { + return clientModel.getId(); + } else { + return clientRep.getId(); + } + } + + public String getName() { + if (clientModel != null) { + return clientModel.getName(); + } else { + return clientRep.getName(); + } + } + + public void removeClientSecretRotated() { + if (hasRotatedSecret()) { + setAttribute(CLIENT_ROTATED_SECRET, null); + setAttribute(CLIENT_ROTATED_SECRET_CREATION_TIME, null); + setAttribute(CLIENT_ROTATED_SECRET_EXPIRATION_TIME, null); + } + } + + public int getClientSecretCreationTime() { + String creationTime = getAttribute(CLIENT_SECRET_CREATION_TIME); + return StringUtil.isBlank(creationTime) ? 0 : Integer.parseInt(creationTime); + } + + public void setClientSecretCreationTime(int creationTime) { + setAttribute(CLIENT_SECRET_CREATION_TIME, String.valueOf(creationTime)); + } + + public boolean hasRotatedSecret() { + return StringUtil.isNotBlank(getAttribute(CLIENT_ROTATED_SECRET)) && StringUtil.isNotBlank( + getAttribute(CLIENT_ROTATED_SECRET_CREATION_TIME)); + } + + public String getClientRotatedSecret() { + return getAttribute(CLIENT_ROTATED_SECRET); + } + + public void setClientRotatedSecret(String secret) { + setAttribute(CLIENT_ROTATED_SECRET, secret); + } + + public int getClientRotatedSecretCreationTime() { + String rotatedCreationTime = getAttribute(CLIENT_ROTATED_SECRET_CREATION_TIME); + if (StringUtil.isNotBlank(rotatedCreationTime)) return Integer.parseInt(rotatedCreationTime); + return 0; + } + + public void setClientRotatedSecretCreationTime(Integer rotatedTime) { + setAttribute(CLIENT_ROTATED_SECRET_CREATION_TIME, rotatedTime != null ? String.valueOf(rotatedTime) : null); + } + + /* + Update the creation time of a secret with current date time value + */ + public void setClientSecretCreationTime() { + setClientSecretCreationTime(Time.currentTime()); + } + + public void setClientRotatedSecretCreationTime() { + setClientRotatedSecretCreationTime(Time.currentTime()); + } + + public void updateClientRepresentationAttributes(ClientRepresentation rep) { + rep.getAttributes().put(CLIENT_ROTATED_SECRET, getAttribute(CLIENT_ROTATED_SECRET)); + rep.getAttributes() + .put(CLIENT_SECRET_CREATION_TIME, getAttribute(CLIENT_SECRET_CREATION_TIME)); + rep.getAttributes().put(CLIENT_SECRET_EXPIRATION, getAttribute(CLIENT_SECRET_EXPIRATION)); + rep.getAttributes().put(CLIENT_ROTATED_SECRET_CREATION_TIME, + getAttribute(CLIENT_ROTATED_SECRET_CREATION_TIME)); + rep.getAttributes().put(CLIENT_ROTATED_SECRET_EXPIRATION_TIME, + getAttribute(CLIENT_ROTATED_SECRET_EXPIRATION_TIME)); + } + + public boolean hasClientSecretExpirationTime() { + return getClientSecretExpirationTime() > 0; + } + + public int getClientSecretExpirationTime() { + String expiration = getAttribute(CLIENT_SECRET_EXPIRATION); + return expiration == null ? 0 : Integer.parseInt(expiration); + } + + public void setClientSecretExpirationTime(Integer expiration) { + setAttribute(ClientSecretConstants.CLIENT_SECRET_EXPIRATION, expiration != null ? String.valueOf(expiration) : null); + } + + public boolean isClientSecretExpired() { + if (hasClientSecretExpirationTime()) { + if (getClientSecretExpirationTime() < Time.currentTime()) { + return true; + } + } + return false; + } + + public int getClientRotatedSecretExpirationTime() { + if (hasClientRotatedSecretExpirationTime()) { + return Integer.valueOf( + getAttribute(ClientSecretConstants.CLIENT_ROTATED_SECRET_EXPIRATION_TIME)); + } + return 0; + } + + public void setClientRotatedSecretExpirationTime(Integer expiration) { + setAttribute(ClientSecretConstants.CLIENT_ROTATED_SECRET_EXPIRATION_TIME, + expiration != null ? String.valueOf(expiration) : null); + } + + public boolean hasClientRotatedSecretExpirationTime() { + return StringUtil.isNotBlank( + getAttribute(ClientSecretConstants.CLIENT_ROTATED_SECRET_EXPIRATION_TIME)); + } + + public boolean isClientRotatedSecretExpired() { + if (hasClientRotatedSecretExpirationTime()) { + return getClientRotatedSecretExpirationTime() < Time.currentTime(); + } + return true; + } + + //validates the rotated secret (value and expiration) + public boolean validateRotatedSecret(String secret) { + + // there must exist a rotated_secret + if (hasRotatedSecret()) { + // the rotated secret must not be outdated + if (isClientRotatedSecretExpired()) { + return false; + } + } else { + return false; + } + + return MessageDigest.isEqual(secret.getBytes(), getClientRotatedSecret().getBytes()); + + } + + public String toJson() { + ObjectMapper mapper = new ObjectMapper(); + Map map = new HashMap<>(); + try { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); + map.put("clientId", getId()); + map.put("clientName", getName()); + map.put("secretCreationTimeSeconds", getClientSecretCreationTime()); + map.put("secretCreationTime", sdf.format(Time.toDate(getClientSecretCreationTime()))); + map.put("secretExpirationTimeSeconds", getClientSecretExpirationTime()); + map.put("secretExpirationTime", sdf.format(Time.toDate(getClientSecretExpirationTime()))); + map.put("rotatedSecretCreationTimeSeconds", getClientRotatedSecretCreationTime()); + map.put("rotatedSecretCreationTime", sdf.format(Time.toDate(getClientRotatedSecretCreationTime()))); + map.put("rotatedSecretExpirationTimeSeconds", getClientRotatedSecretExpirationTime()); + map.put("rotatedSecretExpirationTime", sdf.format(Time.toDate(getClientRotatedSecretExpirationTime()))); + return mapper.writeValueAsString(map); + } catch (JsonProcessingException e) { + return ""; + } + } +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java b/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java index 23a3e49e72..7343e7c991 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -169,7 +170,7 @@ public class ClientPoliciesUtil { if (proposedProfileRep.getExecutors() != null) { for (ClientPolicyExecutorRepresentation executorRep : proposedProfileRep.getExecutors()) { // Skip the check if feature is disabled as then the executor implementations are disabled - if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_POLICIES) && !isValidExecutor(session, executorRep.getExecutorProviderId())) { + if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_POLICIES) && !isValidExecutor(session, executorRep)) { throw new ClientPolicyException("proposed client profile contains the executor with its invalid configuration."); } profileRep.getExecutors().add(executorRep); @@ -247,7 +248,8 @@ public class ClientPoliciesUtil { for (ClientProfileRepresentation proposedProfileRep : proposedProfilesRep.getProfiles()) { if (proposedProfileRep.getExecutors() != null) { for (ClientPolicyExecutorRepresentation executorRep : proposedProfileRep.getExecutors()) { - if (!isValidExecutor(session, executorRep.getExecutorProviderId())) { + if (!isValidExecutor(session, executorRep)) { + proposedProfileRep.getExecutors().remove(executorRep); throw new ClientPolicyException("proposed client profile contains the executor, which does not have valid provider, or has invalid configuration."); } } @@ -264,10 +266,17 @@ public class ClientPoliciesUtil { * check whether the proposed executor's provider can be found in keycloak's ClientPolicyExecutorProvider list. * not return null. */ - private static boolean isValidExecutor(KeycloakSession session, String executorProviderId) { + private static boolean isValidExecutor(KeycloakSession session, ClientPolicyExecutorRepresentation executorRep) { + String executorProviderId = executorRep.getExecutorProviderId(); Set providerSet = session.listProviderIds(ClientPolicyExecutorProvider.class); if (providerSet != null && providerSet.contains(executorProviderId)) { - return true; + if (Objects.nonNull(session.getContext().getRealm())){ + ClientPolicyExecutorProvider provider = getExecutorProvider(session, session.getContext().getRealm(), executorProviderId, executorRep.getConfiguration()); + ClientPolicyExecutorConfigurationRepresentation configuration = (ClientPolicyExecutorConfigurationRepresentation) JsonSerialization.mapper.convertValue(executorRep.getConfiguration(), provider.getExecutorConfigurationClass()); + return configuration.validateConfig(); + } else { + return true; + } } logger.warnv("no executor provider found. providerId = {0}", executorProviderId); return false; diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAccessTypeCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAccessTypeCondition.java index 6db2725c18..a600e2f140 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAccessTypeCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAccessTypeCondition.java @@ -74,6 +74,8 @@ public class ClientAccessTypeCondition extends AbstractClientPolicyConditionProv case TOKEN_INTROSPECT: case USERINFO_REQUEST: case LOGOUT_REQUEST: + case UPDATED: + case REGISTERED: if (isClientAccessTypeMatched()) return ClientPolicyVote.YES; return ClientPolicyVote.NO; default: diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientRolesCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientRolesCondition.java index e5a483c157..6977c1ed71 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientRolesCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientRolesCondition.java @@ -79,6 +79,8 @@ public class ClientRolesCondition extends AbstractClientPolicyConditionProvider< case BACKCHANNEL_AUTHENTICATION_REQUEST: case BACKCHANNEL_TOKEN_REQUEST: case PUSHED_AUTHORIZATION_REQUEST: + case REGISTERED: + case UPDATED: if (isRolesMatched(session.getContext().getClient())) return ClientPolicyVote.YES; return ClientPolicyVote.NO; default: diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/context/ClientSecretRotationContext.java b/services/src/main/java/org/keycloak/services/clientpolicy/context/ClientSecretRotationContext.java new file mode 100644 index 0000000000..b43b5d14f8 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/context/ClientSecretRotationContext.java @@ -0,0 +1,30 @@ +package org.keycloak.services.clientpolicy.context; + +import org.keycloak.models.ClientModel; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyEvent; +import org.keycloak.utils.StringUtil; + +public class ClientSecretRotationContext extends AdminClientUpdateContext { + + private final String currentSecret; + + public ClientSecretRotationContext(ClientRepresentation proposedClientRepresentation, + ClientModel targetClient, String currentSecret) { + super(proposedClientRepresentation, targetClient, null); + this.currentSecret = currentSecret; + } + + @Override + public ClientPolicyEvent getEvent() { + return ClientPolicyEvent.UPDATED; + } + + public String getCurrentSecret() { + return currentSecret; + } + + public boolean isForceRotation() { + return StringUtil.isNotBlank(currentSecret); + } +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/ClientSecretRotationExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/ClientSecretRotationExecutor.java new file mode 100644 index 0000000000..c83c0e5416 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/ClientSecretRotationExecutor.java @@ -0,0 +1,238 @@ +package org.keycloak.services.clientpolicy.executor; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCClientSecretConfigWrapper; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.ClientCRUDContext; +import org.keycloak.services.clientpolicy.context.ClientSecretRotationContext; + +import static org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory.DEFAULT_SECRET_EXPIRATION_PERIOD; +import static org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory.DEFAULT_SECRET_REMAINING_ROTATION_PERIOD; +import static org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory.DEFAULT_SECRET_ROTATED_EXPIRATION_PERIOD; + + +/** + * @author Marcelo Sales + */ +public class ClientSecretRotationExecutor implements + ClientPolicyExecutorProvider { + + private static final Logger logger = Logger.getLogger(ClientSecretRotationExecutor.class); + private final KeycloakSession session; + private Configuration configuration; + + public ClientSecretRotationExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public String getProviderId() { + return ClientSecretRotationExecutorFactory.PROVIDER_ID; + } + + @Override + public Class getExecutorConfigurationClass() { + return Configuration.class; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + if (!session.getContext().getClient().isPublicClient() && !session.getContext().getClient() + .isBearerOnly()) { + + switch (context.getEvent()) { + case REGISTERED: + case UPDATED: + executeOnClientCreateOrUpdate((ClientCRUDContext) context); + break; + + case AUTHORIZATION_REQUEST: + case TOKEN_REQUEST: + executeOnAuthRequest(); + return; + + default: + return; + } + } + } + + @Override + public void setupConfiguration(ClientSecretRotationExecutor.Configuration config) { + + if (config == null) { + configuration = new Configuration().parseWithDefaultValues(); + } else { + configuration = config.parseWithDefaultValues(); + } + + } + + private void executeOnAuthRequest() { + ClientModel client = session.getContext().getClient(); + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientModel( + client); + + if (!wrapper.hasClientSecretExpirationTime()) { + //first login with policy + updatedSecretExpiration(wrapper); + } + + } + + private void executeOnClientCreateOrUpdate(ClientCRUDContext adminContext) { + OIDCClientSecretConfigWrapper clientConfigWrapper = OIDCClientSecretConfigWrapper.fromClientModel( + adminContext.getTargetClient()); + logger.debugv("Executing policy {0} for client {1}-{2} with configuration [ expirationPeriod: {3}, rotatedExpirationPeriod: {4}, remainExpirationPeriod: {5} ]", getName(), clientConfigWrapper.getId(), clientConfigWrapper.getName(), configuration.getExpirationPeriod(), configuration.getRotatedExpirationPeriod(), configuration.getRemainExpirationPeriod()); + if (adminContext instanceof ClientSecretRotationContext + || clientConfigWrapper.isClientSecretExpired() + || !clientConfigWrapper.hasClientSecretExpirationTime()) { + rotateSecret(adminContext, clientConfigWrapper); + } else { + //TODO validation for client dynamic registration + + int secondsRemaining = clientConfigWrapper.getClientSecretExpirationTime() + - configuration.remainExpirationPeriod; + if (secondsRemaining <= configuration.remainExpirationPeriod) { +// rotateSecret(adminContext); + } + } + } + + private void rotateSecret(ClientCRUDContext crudContext, + OIDCClientSecretConfigWrapper clientConfigWrapper) { + + if (crudContext instanceof ClientSecretRotationContext) { + ClientSecretRotationContext secretRotationContext = ((ClientSecretRotationContext) crudContext); + if (secretRotationContext.isForceRotation()) { + logger.debugv("Force rotation for client {0}", clientConfigWrapper.getId()); + updateRotateSecret(clientConfigWrapper, secretRotationContext.getCurrentSecret()); + updateClientConfigProperties(clientConfigWrapper); + } + } else if (!clientConfigWrapper.hasClientSecretExpirationTime()) { + logger.debugv("client {0} has no secret rotation expiration time configured",clientConfigWrapper.getId()); + updatedSecretExpiration(clientConfigWrapper); + } else { + logger.debugv("Execute typical secret rotation for client {0}",clientConfigWrapper.getId()); + updatedSecretExpiration(clientConfigWrapper); + updateRotateSecret(clientConfigWrapper, clientConfigWrapper.getSecret()); + KeycloakModelUtils.generateSecret(crudContext.getTargetClient()); + updateClientConfigProperties(clientConfigWrapper); + } + + if (Objects.nonNull(crudContext.getProposedClientRepresentation())) { + clientConfigWrapper.updateClientRepresentationAttributes( + crudContext.getProposedClientRepresentation()); + } + + logger.debugv("Client configured: {0}",clientConfigWrapper.toJson()); + } + + private void updatedSecretExpiration(OIDCClientSecretConfigWrapper clientConfigWrapper) { + clientConfigWrapper.setClientSecretExpirationTime( + Time.currentTime() + configuration.getExpirationPeriod()); + logger.debugv("A new secret expiration is configured for client {0}. Expires at {1}", clientConfigWrapper.getId(), Time.toDate(clientConfigWrapper.getClientSecretExpirationTime())); + } + + private void updateClientConfigProperties(OIDCClientSecretConfigWrapper clientConfigWrapper) { + clientConfigWrapper.setClientSecretCreationTime(Time.currentTime()); + updatedSecretExpiration(clientConfigWrapper); + } + + private void updateRotateSecret(OIDCClientSecretConfigWrapper clientConfigWrapper, + String secret) { + if (configuration.rotatedExpirationPeriod > 0) { + clientConfigWrapper.setClientRotatedSecret(secret); + clientConfigWrapper.setClientRotatedSecretCreationTime(); + clientConfigWrapper.setClientRotatedSecretExpirationTime( + Time.currentTime() + configuration.getRotatedExpirationPeriod()); + logger.debugv("Rotating the secret for client {0}. Secret creation at {1}. Secret expiration at {2}", clientConfigWrapper.getId(), Time.toDate(clientConfigWrapper.getClientRotatedSecretCreationTime()), Time.toDate(clientConfigWrapper.getClientRotatedSecretExpirationTime())); + } else { + logger.debugv("Removing rotation for client {0}", clientConfigWrapper.getId()); + clientConfigWrapper.setClientRotatedSecret(null); + clientConfigWrapper.setClientRotatedSecretCreationTime(null); + clientConfigWrapper.setClientRotatedSecretExpirationTime(null); + } + } + + public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation { + + @JsonProperty(ClientSecretRotationExecutorFactory.SECRET_EXPIRATION_PERIOD) + protected Integer expirationPeriod; + @JsonProperty(ClientSecretRotationExecutorFactory.SECRET_REMAINING_ROTATION_PERIOD) + protected Integer remainExpirationPeriod; + @JsonProperty(ClientSecretRotationExecutorFactory.SECRET_ROTATED_EXPIRATION_PERIOD) + private Integer rotatedExpirationPeriod; + + @Override + public boolean validateConfig() { + logger.debugv("Validating configuration: [ expirationPeriod: {0}, rotatedExpirationPeriod: {1}, remainExpirationPeriod: {2} ]", expirationPeriod, rotatedExpirationPeriod, remainExpirationPeriod); + // expiration must be a positive value greater than 0 (seconds) + if (expirationPeriod <= 0) { + return false; + } + + // rotated secret duration could not be bigger than the main secret + if (rotatedExpirationPeriod > expirationPeriod) { + return false; + } + + // remaining secret expiration period could not be bigger than main secret + if (remainExpirationPeriod > expirationPeriod) { + return false; + } + + return true; + } + + public Integer getExpirationPeriod() { + return expirationPeriod; + } + + public void setExpirationPeriod(Integer expirationPeriod) { + this.expirationPeriod = expirationPeriod; + } + + public Integer getRemainExpirationPeriod() { + return remainExpirationPeriod; + } + + public void setRemainExpirationPeriod(Integer remainExpirationPeriod) { + this.remainExpirationPeriod = remainExpirationPeriod; + } + + public Integer getRotatedExpirationPeriod() { + return rotatedExpirationPeriod; + } + + public void setRotatedExpirationPeriod(Integer rotatedExpirationPeriod) { + this.rotatedExpirationPeriod = rotatedExpirationPeriod; + } + + public Configuration parseWithDefaultValues() { + if (getExpirationPeriod() == null) { + setExpirationPeriod(DEFAULT_SECRET_EXPIRATION_PERIOD); + } + + if (getRemainExpirationPeriod() == null) { + setRemainExpirationPeriod(DEFAULT_SECRET_REMAINING_ROTATION_PERIOD); + } + + if (getRotatedExpirationPeriod() == null) { + setRotatedExpirationPeriod(DEFAULT_SECRET_ROTATED_EXPIRATION_PERIOD); + } + + return this; + } + } + +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/ClientSecretRotationExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/ClientSecretRotationExecutorFactory.java new file mode 100644 index 0000000000..8d81b878a7 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/ClientSecretRotationExecutorFactory.java @@ -0,0 +1,95 @@ +package org.keycloak.services.clientpolicy.executor; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.keycloak.Config.Scope; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; + +/** + * @author Marcelo Sales + */ +public class ClientSecretRotationExecutorFactory implements ClientPolicyExecutorProviderFactory, + EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "secret-rotation"; + + public static final String SECRET_EXPIRATION_PERIOD = "expiration-period"; + public static final Integer DEFAULT_SECRET_EXPIRATION_PERIOD = Long.valueOf( + TimeUnit.DAYS.toSeconds(29)).intValue(); + + public static final String SECRET_REMAINING_ROTATION_PERIOD = "remaining-rotation-period"; + public static final Integer DEFAULT_SECRET_REMAINING_ROTATION_PERIOD = Long.valueOf( + TimeUnit.DAYS.toSeconds(10)).intValue(); + + public static final String SECRET_ROTATED_EXPIRATION_PERIOD = "rotated-expiration-period"; + public static final Integer DEFAULT_SECRET_ROTATED_EXPIRATION_PERIOD = Long.valueOf( + TimeUnit.DAYS.toSeconds(2)).intValue(); + private static final List configProperties = new ArrayList(); + + static { + ProviderConfigProperty secretExpirationPeriod = new ProviderConfigProperty( + SECRET_EXPIRATION_PERIOD, "Secret expiration", + "When the secret is rotated. The time frequency for generating a new secret. (In seconds)", + ProviderConfigProperty.STRING_TYPE, DEFAULT_SECRET_EXPIRATION_PERIOD); + configProperties.add(secretExpirationPeriod); + + ProviderConfigProperty secretRotatedPeriod = new ProviderConfigProperty( + SECRET_ROTATED_EXPIRATION_PERIOD, "Rotated Secret expiration", + "When secret is rotated, this is the remaining expiration time for the old secret. This value should be always smaller than Secret expiration. When this is set to 0, the old secret will be immediately removed during client rotation (In seconds)", + ProviderConfigProperty.STRING_TYPE, DEFAULT_SECRET_ROTATED_EXPIRATION_PERIOD); + configProperties.add(secretRotatedPeriod); + + ProviderConfigProperty secretRemainingExpirationPeriod = new ProviderConfigProperty( + SECRET_REMAINING_ROTATION_PERIOD, "Remain Expiration Time", + "During dynamic client registration client-update request, the client secret will be automatically rotated if the remaining expiration time of the current secret is smaller than the value specified by this option. This configuration option is relevant only for dynamic client update requests (In seconds)", + ProviderConfigProperty.STRING_TYPE, DEFAULT_SECRET_REMAINING_ROTATION_PERIOD); + configProperties.add(secretRemainingExpirationPeriod); + + } + + @Override + public String getHelpText() { + return "The executor verifies that secret rotation is enabled for the client. If rotation is enabled, it provides validation of secrets and performs rotation if necessary."; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session) { + return new ClientSecretRotationExecutor(session); + } + + @Override + public void init(Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Feature.CLIENT_SECRET_ROTATION); + } +} diff --git a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java index eaf16dd96f..9c752a427d 100755 --- a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java @@ -22,7 +22,6 @@ import org.keycloak.authentication.ClientAuthenticator; import org.keycloak.authentication.ClientAuthenticatorFactory; import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator; -import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKParser; @@ -33,9 +32,9 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.ParConfig; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCClientSecretConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper; -import org.keycloak.protocol.oidc.utils.AcrUtils; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.PairwiseSubMapperUtils; @@ -317,7 +316,8 @@ public class DescriptionConverter { if (client.getClientAuthenticatorType().equals(ClientIdAndSecretAuthenticator.PROVIDER_ID)) { response.setClientSecret(client.getSecret()); - response.setClientSecretExpiresAt(0); + response.setClientSecretExpiresAt( + OIDCClientSecretConfigWrapper.fromClientRepresentation(client).getClientSecretExpirationTime()); } response.setClientName(client.getName()); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java index a38946deb0..326be4f8f3 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java @@ -16,6 +16,7 @@ */ package org.keycloak.services.resources.admin; +import javax.ws.rs.core.Response.Status; import org.jboss.logging.Logger; import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.spi.BadRequestException; @@ -43,6 +44,7 @@ import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.protocol.ClientInstallationProvider; +import org.keycloak.protocol.oidc.OIDCClientSecretConfigWrapper; import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; @@ -57,6 +59,7 @@ import org.keycloak.services.clientpolicy.context.AdminClientUnregisterContext; import org.keycloak.services.clientpolicy.context.AdminClientUpdateContext; import org.keycloak.services.clientpolicy.context.AdminClientUpdatedContext; import org.keycloak.services.clientpolicy.context.AdminClientViewContext; +import org.keycloak.services.clientpolicy.context.ClientSecretRotationContext; import org.keycloak.services.clientregistration.ClientRegistrationTokenUtils; import org.keycloak.services.clientregistration.policy.RegistrationAuth; import org.keycloak.services.managers.ClientManager; @@ -65,8 +68,10 @@ import org.keycloak.services.managers.ResourceAdminManager; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement; import org.keycloak.services.resources.admin.permissions.AdminPermissions; +import org.keycloak.utils.CredentialHelper; import org.keycloak.utils.ProfileHelper; import org.keycloak.utils.ReservedCharValidator; +import org.keycloak.utils.StringUtil; import org.keycloak.validation.ValidationUtil; import javax.ws.rs.Consumes; @@ -244,17 +249,32 @@ public class ClientResource { @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public CredentialRepresentation regenerateSecret() { - auth.clients().requireConfigure(client); + try{ + auth.clients().requireConfigure(client); - logger.debug("regenerateSecret"); - String secret = KeycloakModelUtils.generateSecret(client); + logger.debug("regenerateSecret"); - CredentialRepresentation rep = new CredentialRepresentation(); - rep.setType(CredentialRepresentation.SECRET); - rep.setValue(secret); + ClientRepresentation representation = ModelToRepresentation.toRepresentation(client, session); + ClientSecretRotationContext secretRotationContext = new ClientSecretRotationContext( + representation, client, client.getSecret()); - adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(rep).success(); - return rep; + String secret = KeycloakModelUtils.generateSecret(client); + + session.clientPolicy().triggerOnEvent(secretRotationContext); + + CredentialRepresentation rep = new CredentialRepresentation(); + rep.setType(CredentialRepresentation.SECRET); + rep.setValue(secret); + rep.setCreatedDate( + (long) OIDCClientSecretConfigWrapper.fromClientModel(client).getClientSecretCreationTime()); + + adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(rep).success(); + + return rep; + } catch (ClientPolicyException cpe) { + throw new ErrorResponseException(cpe.getError(), cpe.getErrorDetail(), + Response.Status.BAD_REQUEST); + } } /** @@ -665,6 +685,59 @@ public class ClientResource { } } + /** + * Invalidate the rotated secret for the client + * + * @return + */ + @Path("client-secret/rotated") + @DELETE + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response invalidateRotatedSecret() { + try{ + auth.clients().requireConfigure(client); + + logger.debug("delete rotated secret"); + + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientModel(client); + + CredentialRepresentation rep = new CredentialRepresentation(); + rep.setType(CredentialRepresentation.SECRET); + rep.setValue(wrapper.getClientRotatedSecret()); + + adminEvent.operation(OperationType.DELETE).resourcePath(session.getContext().getUri()).representation(rep).success(); + + wrapper.removeClientSecretRotated(); + + return Response.noContent().build(); + } catch (RuntimeException rte) { + throw new ErrorResponseException(rte.getCause().getMessage(), rte.getMessage(), + Status.INTERNAL_SERVER_ERROR); + } + } + + /** + * Get the client secret + * + * @return + */ + @Path("client-secret/rotated") + @GET + @NoCache + @Produces(MediaType.APPLICATION_JSON) + public CredentialRepresentation getClientRotatedSecret() { + auth.clients().requireView(client); + + logger.debug("getClientRotatedSecret"); + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientModel(client); + if (!wrapper.hasRotatedSecret()) + throw new NotFoundException("Client does not have a rotated secret"); + else { + UserCredentialModel model = UserCredentialModel.secret(wrapper.getClientRotatedSecret()); + return ModelToRepresentation.toRepresentation(model); + } + } private void updateClientFromRep(ClientRepresentation rep, ClientModel client, KeycloakSession session) throws ModelDuplicateException { UserModel serviceAccount = this.session.users().getServiceAccount(client); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientsResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientsResource.java index fafb39d44d..f05e573e02 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientsResource.java @@ -207,6 +207,7 @@ public class ClientsResource { Response.Status.BAD_REQUEST); }); + session.getContext().setClient(clientModel); session.clientPolicy().triggerOnEvent(new AdminClientRegisteredContext(clientModel, auth.adminAuth())); return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(clientModel.getId()).build()).build(); diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory index adae5d1654..43d03ba9c6 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory @@ -14,4 +14,5 @@ org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSessionEn org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSignedAuthenticationRequestExecutorFactory org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutorFactory org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory -org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutorFactory \ No newline at end of file +org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutorFactory +org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientSecretRotationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientSecretRotationTest.java new file mode 100644 index 0000000000..6ae0fc031c --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientSecretRotationTest.java @@ -0,0 +1,818 @@ +package org.keycloak.testsuite.client; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import javax.ws.rs.BadRequestException; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jboss.logging.Logger; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.ClientPoliciesPoliciesResource; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; +import org.keycloak.common.Profile.Feature; +import org.keycloak.common.util.Time; +import org.keycloak.events.Details; +import org.keycloak.models.AdminRoles; +import org.keycloak.models.ClientSecretConstants; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCClientSecretConfigWrapper; +import org.keycloak.representations.idm.ClientPoliciesRepresentation; +import org.keycloak.representations.idm.ClientProfileRepresentation; +import org.keycloak.representations.idm.ClientProfilesRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.condition.ClientAccessTypeCondition.Configuration; +import org.keycloak.services.clientpolicy.condition.ClientAccessTypeConditionFactory; +import org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutor; +import org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.account.AbstractRestServiceTest; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPoliciesBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfileBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfilesBuilder; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.OAuthClient.AuthorizationEndpointResponse; +import org.keycloak.testsuite.util.ServerURLs; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; + +/** + * @author Marcelo Sales + */ +@AuthServerContainerExclude(AuthServer.REMOTE) +@EnableFeature(value = Feature.CLIENT_SECRET_ROTATION) +public class ClientSecretRotationTest extends AbstractRestServiceTest { + + private static final String OIDC = "openid-connect"; + private static final String DEFAULT_CLIENT_ID = KeycloakModelUtils.generateId(); + private static final String REALM_NAME = "test"; + private static final String CLIENT_NAME = "confidential-client"; + private static final String DEFAULT_SECRET = "GFyDEriVTA9nAu92DenBknb5bjR5jdUM"; + private static final String PROFILE_NAME = "ClientSecretRotationProfile"; + private static final String POLICY_NAME = "ClientSecretRotationPolicy"; + + private static final String TEST_USER_NAME = "test-user@localhost"; + private static final String TEST_USER_PASSWORD = "password"; + + private static final String ADMIN_USER_NAME = "admin-user"; + private static final String COMMON_USER_NAME = "common-user"; + private static final String COMMON_USER_ID = KeycloakModelUtils.generateId(); + private static final String USER_PASSWORD = "password"; + + private static final Logger logger = Logger.getLogger(ClientSecretRotationTest.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final int DEFAULT_EXPIRATION_PERIOD = Long.valueOf(TimeUnit.HOURS.toSeconds(1)) + .intValue(); + private static final int DEFAULT_ROTATED_EXPIRATION_PERIOD = Long.valueOf( + TimeUnit.MINUTES.toSeconds(10)).intValue(); + private static final int DEFAULT_REMAIN_EXPIRATION_PERIOD = Long.valueOf( + TimeUnit.MINUTES.toSeconds(30)).intValue(); + + @Rule + public AssertEvents events = new AssertEvents(this); + + @After + public void after() { + try { + revertToBuiltinProfiles(); + revertToBuiltinPolicies(); + } catch (ClientPolicyException e) { + throw new RuntimeException(e); + } + resetTimeOffset(); + } + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), + RealmRepresentation.class); + + List users = realm.getUsers(); + + UserRepresentation user = UserBuilder.create().enabled(Boolean.TRUE) + .username(ADMIN_USER_NAME) + .password(USER_PASSWORD).addRoles(new String[]{AdminRoles.MANAGE_CLIENTS}).build(); + users.add(user); + + UserRepresentation commonUser = UserBuilder.create().id(COMMON_USER_ID) + .enabled(Boolean.TRUE) + .username(COMMON_USER_NAME).email(COMMON_USER_NAME + "@localhost") + .password(USER_PASSWORD) + .build(); + users.add(commonUser); + + realm.setUsers(users); + testRealms.add(realm); + } + + /** + * When create a client even without policy secret rotation enabled the client must have a + * secret creation time + * + * @throws Exception + */ + @Test + public void whenCreateClientSecretCreationTimeMustExist() throws Exception { + + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + assertThat(wrapper.getClientSecretCreationTime(), is(notNullValue())); + String secret = clientResource.getSecret().getValue(); + assertThat(secret, is(notNullValue())); + assertThat(secret, equalTo(DEFAULT_SECRET)); + } + + /** + * When regenerate a client secret the creation time attribute must be updated, when the + * rotation secret policy is not enable + * + * @throws Exception + */ + @Test + public void regenerateSecret() throws Exception { + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String secret = clientResource.getSecret().getValue(); + int secretCreationTime = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()).getClientSecretCreationTime(); + assertThat(secret, equalTo(DEFAULT_SECRET)); + String newSecret = clientResource.generateNewSecret().getValue(); + assertThat(newSecret, not(equalTo(secret))); + int updatedSecretCreationTime = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()).getClientSecretCreationTime(); + assertThat(updatedSecretCreationTime, greaterThanOrEqualTo(secretCreationTime)); + } + + /** + * When update a client with policy enabled and secret expiration is still valid the rotation + * must not be performed + * + * @throws Exception + */ + @Test + public void updateClientWithPolicyAndSecretNotExpired() throws Exception { + + configureDefaultProfileAndPolicy(); + + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String secret = clientResource.getSecret().getValue(); + ClientRepresentation clientRepresentation = clientResource.toRepresentation(); + int secretCreationTime = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientRepresentation) + .getClientSecretCreationTime(); + clientRepresentation.setDescription("New Description Updated"); + clientResource.update(clientRepresentation); + assertThat(clientResource.getSecret().getValue(), equalTo(secret)); + assertThat(OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()) + .getClientSecretCreationTime(), equalTo(secretCreationTime)); + } + + /** + * When regenerate the secret for a client with policy enabled and the secret is not yet + * expired, the secret must be rotated + * + * @throws Exception + */ + @Test + public void regenerateSecretOnCurrentSecretNotExpired() throws Exception { + //apply policy + configureDefaultProfileAndPolicy(); + + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String firstSecret = clientResource.getSecret().getValue(); + String secondSecret = clientResource.generateNewSecret().getValue(); + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + + assertThat(secondSecret, not(equalTo(firstSecret))); + assertThat(wrapper.hasRotatedSecret(), is(Boolean.TRUE)); + assertThat(wrapper.getClientRotatedSecret(), equalTo(firstSecret)); + } + + /** + * When regenerate secret for a client and the expiration date is reached the policy must force + * a secret rotation + * + * @throws Exception + */ + @Test + public void regenerateSecretAfterCurrentSecretExpires() throws Exception { + + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String firstSecret = clientResource.getSecret().getValue(); + String secondSecret = clientResource.generateNewSecret().getValue(); + assertThat(secondSecret, not(equalTo(firstSecret))); + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + assertThat(wrapper.hasRotatedSecret(), is(Boolean.FALSE)); + + //apply policy + configureDefaultProfileAndPolicy(); + + //advance 1 hour + setTimeOffset(3600); + + String newSecret = clientResource.generateNewSecret().getValue(); + assertThat(newSecret, not(equalTo(secondSecret))); + wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + assertThat(wrapper.hasRotatedSecret(), is(Boolean.TRUE)); + assertThat(wrapper.getClientRotatedSecret(), equalTo(secondSecret)); + int rotatedCreationTime = wrapper.getClientSecretCreationTime(); + assertThat(rotatedCreationTime, is(notNullValue())); + assertThat(rotatedCreationTime, greaterThan(0)); + + } + + /** + * When update a client with policy enabled and secret expired the secret rotation must be + * performed + * + * @throws Exception + */ + @Test + public void updateClientPolicyEnabledSecretExpired() throws Exception { + + configureDefaultProfileAndPolicy(); + + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String firstSecret = clientResource.getSecret().getValue(); + ClientRepresentation clientRepresentation = clientResource.toRepresentation(); + clientRepresentation.setDescription("New Description Updated"); + clientResource.update(clientRepresentation); + + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + int secretCreationTime = wrapper.getClientSecretCreationTime(); + + logger.debug("Current time " + Time.toDate(Time.currentTime())); + //advance 1 hour + setTimeOffset(3601); + logger.debug("Time after offset " + Time.toDate(Time.currentTime())); + + clientRepresentation = clientResource.toRepresentation(); + clientRepresentation.setDescription("Force second Updated"); + clientResource.update(clientRepresentation); + + wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + + assertThat(clientResource.getSecret().getValue(), not(equalTo(firstSecret))); + + wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + assertThat(wrapper.getClientSecretCreationTime(), not(equalTo(secretCreationTime))); + assertThat(wrapper.hasRotatedSecret(), is(Boolean.TRUE)); + assertThat(wrapper.getClientRotatedSecret(), equalTo(firstSecret)); + } + + /** + * When authenticate with client-id and secret and the policy is not enable the login must be + * successfully (Keeps flow compatibility without secret rotation) + * + * @throws ClientPolicyException + */ + @Test + public void authenticateWithValidClientNoPolicy() throws ClientPolicyException { + String clientId = generateSuffixedName(CLIENT_NAME); + String cId = createClientByAdmin(clientId); + successfulLoginAndLogout(clientId, DEFAULT_SECRET); + } + + /** + * When the secret rotation policy is active and the client's main secret has not yet expired, + * the login should be successful. + * + * @throws Exception + */ + @Test + public void authenticateWithValidClientPolicyEnable() throws Exception { + configureDefaultProfileAndPolicy(); + String clientId = generateSuffixedName(CLIENT_NAME); + String cId = createClientByAdmin(clientId); + successfulLoginAndLogout(clientId, DEFAULT_SECRET); + } + + /** + * When the secret rotation policy is active and the client's main secret has expired, the login + * should not be successful. + * + * @throws Exception + */ + @Test + public void authenticateWithInvalidClientPolicyEnable() throws Exception { + configureDefaultProfileAndPolicy(); + String clientId = generateSuffixedName(CLIENT_NAME); + String cId = createClientByAdmin(clientId); + + //The first login will be successful + oauth.clientId(clientId); + oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, DEFAULT_SECRET); + assertThat(res.getStatusCode(), equalTo(Status.OK.getStatusCode())); + oauth.doLogout(res.getRefreshToken(), DEFAULT_SECRET); + + //advance 1 hour + setTimeOffset(3601); + + // the second login must fail + oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + res = oauth.doAccessTokenRequest(code, DEFAULT_SECRET); + assertThat(res.getStatusCode(), equalTo(Status.UNAUTHORIZED.getStatusCode())); + } + + /** + * When a client goes through a secret rotation, the current secret becomes a rotated secret. A + * login attempt with the new secret and the rotated secret should be successful as long as none + * of the client's secrets are expired. + * + * @throws Exception + */ + @Test + public void authenticateWithValidActualAndRotatedSecret() throws Exception { + configureDefaultProfileAndPolicy(); + String clientId = generateSuffixedName(CLIENT_NAME); + String cidConfidential = createClientByAdmin(clientId); + + // force client update. First update will not rotate the secret + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String firstSecret = clientResource.getSecret().getValue(); + ClientRepresentation clientRepresentation = clientResource.toRepresentation(); + clientRepresentation.setDescription("New Description Updated"); + clientResource.update(clientRepresentation); + + //advance 1 hour + setTimeOffset(3601); + + // force client update (rotate the secret according to the policy) + clientRepresentation = clientResource.toRepresentation(); + clientResource.update(clientRepresentation); + + String updatedSecret = clientResource.getSecret().getValue(); + assertThat(clientResource.getSecret().getValue(), not(equalTo(firstSecret))); + + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + + oauth.clientId(clientId); + + //login with new secret + AuthorizationEndpointResponse loginResponse = oauth.doLogin(TEST_USER_NAME, + TEST_USER_PASSWORD); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, updatedSecret); + assertThat(res.getStatusCode(), equalTo(Status.OK.getStatusCode())); + oauth.doLogout(res.getRefreshToken(), updatedSecret); + + //login with rotated secret + loginResponse = oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + res = oauth.doAccessTokenRequest(code, firstSecret); + assertThat(res.getStatusCode(), equalTo(Status.OK.getStatusCode())); + oauth.doLogout(res.getRefreshToken(), firstSecret); + + } + + /** + * When a client goes through a secret rotation, the current secret becomes a rotated secret. A + * login attempt with the rotated secret should not be successful if secret is expired. + * + * @throws Exception + */ + @Test + public void authenticateWithInValidRotatedSecret() throws Exception { + configureDefaultProfileAndPolicy(); + String clientId = generateSuffixedName(CLIENT_NAME); + String cidConfidential = createClientByAdmin(clientId); + + // force client update (rotate the secret according to the policy) + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String firstSecret = clientResource.getSecret().getValue(); + ClientRepresentation clientRepresentation = clientResource.toRepresentation(); + clientRepresentation.setDescription("New Description Updated"); + clientResource.update(clientRepresentation); + + logger.debug(">>> secret creation time " + Time.toDate(Time.currentTime())); + + setTimeOffset(3601); + clientResource.update(clientResource.toRepresentation()); + + logger.debug(">>> secret expiration time after first update " + Time.toDate( + OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()) + .getClientSecretExpirationTime()) + " | Time: " + Time.toDate(Time.currentTime())); + + // force rotation + String updatedSecret = clientResource.getSecret().getValue(); + assertThat(updatedSecret, not(equalTo(firstSecret))); + clientRepresentation = clientResource.toRepresentation(); + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientRepresentation); + + logger.debug( + ">>> secret expiration configured " + Time.toDate( + wrapper.getClientSecretExpirationTime()) + + " | Time: " + Time.toDate(Time.currentTime())); + + oauth.clientId(clientId); + + setTimeOffset(7201); + + logger.debug("client secret:" + updatedSecret + "\nsecret expiration: " + Time.toDate( + wrapper.getClientSecretExpirationTime()) + "\nrotated secret: " + + wrapper.getClientRotatedSecret() + "\nrotated expiration: " + Time.toDate( + wrapper.getClientRotatedSecretExpirationTime()) + " | Time: " + Time.toDate( + Time.currentTime())); + logger.debug(">>> trying login at time " + Time.toDate(Time.currentTime())); + + // try to login with rotated secret (must fail) + oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, firstSecret); + assertThat(res.getStatusCode(), equalTo(Status.UNAUTHORIZED.getStatusCode())); + oauth.doLogout(res.getRefreshToken(), firstSecret); + + } + + /** + * When a client goes through a secret rotation and the configuration for rotated secret is zero + * then the rotated secret is automatically invalidated, therefore the rotated secret is not + * valid for a successful login + * + * @throws Exception + */ + @Test + public void authenticateWithRotatedSecretWithZeroExpirationTime() throws Exception { + configureCustomProfileAndPolicy(DEFAULT_EXPIRATION_PERIOD, 0, 0); + String clientId = generateSuffixedName(CLIENT_NAME); + String cidConfidential = createClientByAdmin(clientId); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + clientResource.update(clientResource.toRepresentation()); + + //advance 1 hour + setTimeOffset(3601); + + // force client update (rotate the secret according to the policy) + String firstSecret = clientResource.getSecret().getValue(); + ClientRepresentation clientRepresentation = clientResource.toRepresentation(); + clientRepresentation.setDescription("New Description Updated"); + + clientResource.update(clientRepresentation); + String updatedSecret = clientResource.getSecret().getValue(); + //confirms rotation + assertThat(updatedSecret, not(equalTo(firstSecret))); + + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + assertThat(wrapper.hasRotatedSecret(), is(Boolean.FALSE)); + + // try to login with rotated secret (must fail) + oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, firstSecret); + assertThat(res.getStatusCode(), equalTo(Status.UNAUTHORIZED.getStatusCode())); + oauth.doLogout(res.getRefreshToken(), firstSecret); + + } + + /** + * When create a confidential client with policy enabled the client must have secret expiration + * time configured + * + * @throws Exception + */ + @Test + public void createClientWithPolicyEnableSecretExpiredTime() throws Exception { + + configureDefaultProfileAndPolicy(); + + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + ClientRepresentation clientRepresentation = clientResource.toRepresentation(); + + OIDCClientSecretConfigWrapper wrapper = OIDCClientSecretConfigWrapper.fromClientRepresentation( + clientResource.toRepresentation()); + int clientSecretExpirationTime = wrapper.getClientSecretExpirationTime(); + assertThat(clientSecretExpirationTime, is(not(0))); + + } + + /** + * After rotate the secret the endpoint must return the rotated secret + * + * @throws Exception + */ + @Test + public void getClientRotatedSecret() throws Exception { + configureDefaultProfileAndPolicy(); + + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String firstSecret = clientResource.getSecret().getValue(); + try { + clientResource.getClientRotatedSecret(); + } catch (Exception e) { + assertThat(e, is(instanceOf(NotFoundException.class))); + } + + String newSecret = clientResource.generateNewSecret().getValue(); + String rotatedSecret = clientResource.getClientRotatedSecret().getValue(); + assertThat(firstSecret, not(equalTo(newSecret))); + assertThat(firstSecret, equalTo(rotatedSecret)); + } + + /** + * After rotate the secret it must be possible to invalidate the rotated secret + * + * @throws Exception + */ + @Test + public void invalidateClientRotatedSecret() throws Exception { + configureDefaultProfileAndPolicy(); + + String cidConfidential = createClientByAdmin(DEFAULT_CLIENT_ID); + ClientResource clientResource = adminClient.realm(REALM_NAME).clients() + .get(cidConfidential); + String firstSecret = clientResource.getSecret().getValue(); + String newSecret = clientResource.generateNewSecret().getValue(); + String rotatedSecret = clientResource.getClientRotatedSecret().getValue(); + assertThat(firstSecret, not(equalTo(newSecret))); + assertThat(firstSecret, equalTo(rotatedSecret)); + clientResource.invalidateRotatedSecret(); + try { + clientResource.getClientRotatedSecret(); + } catch (Exception e) { + assertThat(e, is(instanceOf(NotFoundException.class))); + } + } + + /** + * When try to create an executor for client secret rotation the configuration must be valid. + * If the rules expressed in services/src/main/java/org/keycloak/services/clientpolicy/executor/ClientSecretRotationExecutor.Configuration is invalid, then the resource must not be created + * + * @throws Exception + */ + @Test + public void createExecutorConfigurationWithInvalidValues() throws Exception { + try { + configureCustomProfileAndPolicy(60, 61, 30); + } catch (Exception e) { + assertThat(e,instanceOf(ClientPolicyException.class)); + } + // no police must have been created due to the above error + ClientPoliciesPoliciesResource policiesResource = adminClient.realm(REALM_NAME).clientPoliciesPoliciesResource(); + ClientPoliciesRepresentation policies = policiesResource.getPolicies(); + assertThat(policies.getPolicies(),is(empty())); + } + + /** + * -------------------- support methods -------------------- + **/ + + private void configureCustomProfileAndPolicy(int secretExpiration, int rotatedExpiration, + int remainingExpiration) throws Exception { + ClientProfileBuilder profileBuilder = new ClientProfileBuilder(); + ClientSecretRotationExecutor.Configuration profileConfig = getClientProfileConfiguration( + secretExpiration, rotatedExpiration, remainingExpiration); + + doConfigProfileAndPolicy(profileBuilder, profileConfig); + } + + private void configureDefaultProfileAndPolicy() throws Exception { + // register profiles + ClientProfileBuilder profileBuilder = new ClientProfileBuilder(); + ClientSecretRotationExecutor.Configuration profileConfig = getClientProfileConfiguration( + DEFAULT_EXPIRATION_PERIOD, DEFAULT_ROTATED_EXPIRATION_PERIOD, + DEFAULT_REMAIN_EXPIRATION_PERIOD); + + doConfigProfileAndPolicy(profileBuilder, profileConfig); + } + + private void doConfigProfileAndPolicy(ClientProfileBuilder profileBuilder, + ClientSecretRotationExecutor.Configuration profileConfig) throws Exception { + String json = (new ClientProfilesBuilder()).addProfile( + profileBuilder.createProfile(PROFILE_NAME, "Enable Client Secret Rotation") + .addExecutor(ClientSecretRotationExecutorFactory.PROVIDER_ID, profileConfig) + .toRepresentation()).toString(); + updateProfiles(json); + + // register policies + Configuration config = new Configuration(); + config.setType(Arrays.asList(ClientAccessTypeConditionFactory.TYPE_CONFIDENTIAL)); + json = (new ClientPoliciesBuilder()).addPolicy( + (new ClientPolicyBuilder()).createPolicy(POLICY_NAME, + "Policy for Client Secret Rotation", + Boolean.TRUE).addCondition(ClientAccessTypeConditionFactory.PROVIDER_ID, config) + .addProfile(PROFILE_NAME).toRepresentation()).toString(); + updatePolicies(json); + } + + @NotNull + private ClientSecretRotationExecutor.Configuration getClientProfileConfiguration( + int expirationPeriod, int rotatedExpirationPeriod, int remainExpirationPeriod) { + ClientSecretRotationExecutor.Configuration profileConfig = new ClientSecretRotationExecutor.Configuration(); + profileConfig.setExpirationPeriod(expirationPeriod); + profileConfig.setRotatedExpirationPeriod(rotatedExpirationPeriod); + profileConfig.setRemainExpirationPeriod(remainExpirationPeriod); + return profileConfig; + } + + protected String createClientByAdmin(String clientId) throws ClientPolicyException { + ClientRepresentation clientRep = getClientRepresentation(clientId); + + Response resp = adminClient.realm(REALM_NAME).clients().create(clientRep); + if (resp.getStatus() == Response.Status.BAD_REQUEST.getStatusCode()) { + String respBody = resp.readEntity(String.class); + Map responseJson = null; + try { + responseJson = JsonSerialization.readValue(respBody, Map.class); + } catch (IOException e) { + fail(); + } + throw new ClientPolicyException(responseJson.get(OAuth2Constants.ERROR), + responseJson.get(OAuth2Constants.ERROR_DESCRIPTION)); + } + resp.close(); + assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus()); + // registered components will be removed automatically when a test method finishes regardless of its success or failure. + String cId = ApiUtil.getCreatedId(resp); + testContext.getOrCreateCleanup(REALM_NAME).addClientUuid(cId); + return cId; + } + + @NotNull + private ClientRepresentation getClientRepresentation(String clientId) { + ClientRepresentation clientRep = new ClientRepresentation(); + clientRep.setClientId(clientId); + clientRep.setName(CLIENT_NAME); + clientRep.setSecret(DEFAULT_SECRET); + clientRep.setAttributes(new HashMap<>()); + clientRep.getAttributes() + .put(ClientSecretConstants.CLIENT_SECRET_CREATION_TIME, + String.valueOf(Time.currentTime())); + clientRep.setProtocol(OIDC); + clientRep.setBearerOnly(Boolean.FALSE); + clientRep.setPublicClient(Boolean.FALSE); + clientRep.setServiceAccountsEnabled(Boolean.TRUE); + clientRep.setStandardFlowEnabled(Boolean.TRUE); + clientRep.setImplicitFlowEnabled(Boolean.TRUE); + clientRep.setClientAuthenticatorType(ClientIdAndSecretAuthenticator.PROVIDER_ID); + + clientRep.setRedirectUris(Collections.singletonList( + ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth")); + return clientRep; + } + + protected String generateSuffixedName(String name) { + return name + "-" + UUID.randomUUID().toString().subSequence(0, 7); + } + + protected void updateProfiles(String json) throws ClientPolicyException { + try { + ClientProfilesRepresentation clientProfiles = JsonSerialization.readValue(json, + ClientProfilesRepresentation.class); + adminClient.realm(REALM_NAME).clientPoliciesProfilesResource() + .updateProfiles(clientProfiles); + } catch (BadRequestException e) { + throw new ClientPolicyException("update profiles failed", + e.getResponse().getStatusInfo().toString()); + } catch (Exception e) { + throw new ClientPolicyException("update profiles failed", e.getMessage()); + } + } + + protected void updateProfiles(ClientProfilesRepresentation reps) throws ClientPolicyException { + updateProfiles(convertToProfilesJson(reps)); + } + + protected void revertToBuiltinProfiles() throws ClientPolicyException { + updateProfiles("{}"); + } + + protected String convertToProfilesJson(ClientProfilesRepresentation reps) { + String json = null; + try { + json = objectMapper.writeValueAsString(reps); + } catch (JsonProcessingException e) { + fail(); + } + return json; + } + + protected String convertToProfileJson(ClientProfileRepresentation rep) { + String json = null; + try { + json = objectMapper.writeValueAsString(rep); + } catch (JsonProcessingException e) { + fail(); + } + return json; + } + + protected ClientProfileRepresentation convertToProfile(String json) { + ClientProfileRepresentation rep = null; + try { + rep = JsonSerialization.readValue(json, ClientProfileRepresentation.class); + } catch (IOException e) { + fail(); + } + return rep; + } + + protected void revertToBuiltinPolicies() throws ClientPolicyException { + updatePolicies("{}"); + } + + protected void updatePolicies(String json) throws ClientPolicyException { + try { + ClientPoliciesRepresentation clientPolicies = json == null ? null + : JsonSerialization.readValue(json, ClientPoliciesRepresentation.class); + adminClient.realm(REALM_NAME).clientPoliciesPoliciesResource() + .updatePolicies(clientPolicies); + } catch (BadRequestException e) { + throw new ClientPolicyException("update policies failed", + e.getResponse().getStatusInfo().toString()); + } catch (IOException e) { + throw new ClientPolicyException("update policies failed", e.getMessage()); + } + } + + private void successfulLoginAndLogout(String clientId, String clientSecret) { + OAuthClient.AccessTokenResponse res = successfulLogin(clientId, clientSecret); + oauth.doLogout(res.getRefreshToken(), clientSecret); + events.expectLogout(res.getSessionState()).client(clientId).clearDetails().assertEvent(); + } + + private OAuthClient.AccessTokenResponse successfulLogin(String clientId, String clientSecret) { + oauth.clientId(clientId); + oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + + EventRepresentation loginEvent = events.expectLogin().client(clientId).assertEvent(); + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, clientSecret); + assertEquals(200, res.getStatusCode()); + events.expectCodeToToken(codeId, sessionId).client(clientId).assertEvent(); + + return res; + } +} diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 6331ec7866..826395692c 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -528,6 +528,12 @@ import-client-certificate=Import Client Certificate jwt-import.key-alias.tooltip=Archive alias for your certificate. secret=Secret regenerate-secret=Regenerate Secret +secret-rotation=Secret Rotation +secret-rotation-enabled.tooltip=This enables client secret rotation. +rotate.secret=Rotate Secret +secret-rotated=Secret Rotated +invalidate-secret=Invalidate Secret +secret-expires-on=Secret expires on registrationAccessToken=Registration access token registrationAccessToken.regenerate=Regenerate registration access token registrationAccessToken.tooltip=The registration access token provides access for clients to the client registration service. diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js index 996607de20..b339d12be6 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js @@ -136,7 +136,8 @@ module.controller('ClientCredentialsCtrl', function($scope, $location, realm, cl }; }); -module.controller('ClientSecretCtrl', function($scope, $location, Client, ClientSecret, Notifications) { +module.controller('ClientSecretCtrl', function($scope, $location, Client, ClientSecret, Notifications, $route) { + var secret = ClientSecret.get({ realm : $scope.realm.realm, client : $scope.client.id }, function() { $scope.secret = secret.value; @@ -146,8 +147,8 @@ module.controller('ClientSecretCtrl', function($scope, $location, Client, Client $scope.changePassword = function() { var secret = ClientSecret.update({ realm : $scope.realm.realm, client : $scope.client.id }, function() { + $route.reload(); Notifications.success('The secret has been changed.'); - $scope.secret = secret.value; }, function() { Notifications.error("The secret was not changed due to a problem."); @@ -156,8 +157,32 @@ module.controller('ClientSecretCtrl', function($scope, $location, Client, Client ); }; + $scope.removeRotatedSecret = function(){ + ClientSecret.invalidate({realm: $scope.realm.realm, client: $scope.client.id }, + function(){ + $route.reload(); + Notifications.success('The rotated secret has been invalidated.'); + }, + function(){ + Notifications.error("The rotated secret was not invalidated due to a problem."); + } + ); + }; + $scope.tokenEndpointAuthSigningAlg = $scope.client.attributes['token.endpoint.auth.signing.alg']; + if ($scope.client.attributes['client.secret.expiration.time']){ + $scope.secret_expiration_time = $scope.client.attributes['client.secret.expiration.time'] * 1000; + } + + if ($scope.client.attributes["client.secret.rotated"]) { + $scope.secretRotated = $scope.client.attributes["client.secret.rotated"]; + } + + if ($scope.client.attributes['client.secret.rotated.expiration.time']){ + $scope.rotated_secret_expiration_time = $scope.client.attributes['client.secret.rotated.expiration.time'] * 1000; + } + $scope.switchChange = function() { $scope.changed = true; } @@ -183,7 +208,9 @@ module.controller('ClientSecretCtrl', function($scope, $location, Client, Client $scope.cancel = function() { $location.url("/realms/" + $scope.realm.realm + "/clients/" + $scope.client.id + "/credentials"); + $route.reload(); }; + }); module.controller('ClientX509Ctrl', function($scope, $location, Client, Notifications) { @@ -807,7 +834,7 @@ module.controller('ClientRoleDetailCtrl', function($scope, $route, realm, client $scope.create = !role.name; $scope.changed = $scope.create; - + $scope.save = function() { convertAttributeValuesToLists(); if ($scope.create) { @@ -852,7 +879,7 @@ module.controller('ClientRoleDetailCtrl', function($scope, $route, realm, client delete $scope.newAttribute; } - $scope.removeAttribute = function(key) { + $scope.removeAttribute = function(key) { delete $scope.role.attributes[key]; } @@ -983,14 +1010,14 @@ module.controller('ClientListCtrl', function($scope, realm, Client, ClientListSe ClientListSearchState.query.realm = realm.realm; $scope.query = ClientListSearchState.query; - if (!ClientListSearchState.isFirstSearch) { + if (!ClientListSearchState.isFirstSearch) { $scope.searchQuery(); } else { $scope.query.clientId = null; $scope.firstPage(); } }; - + $scope.searchQuery = function() { console.log("query.search: ", $scope.query); $scope.searchLoaded = false; diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 3804cec3d2..987a1e0a30 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -3856,6 +3856,7 @@ module.controller('ClientPoliciesProfilesEditExecutorCtrl', function($scope, rea executor: $scope.executorType.id, configuration: $scope.executor.config }; + $scope.executors = $scope.editedProfile.executors.map((ex) => ex); //clone current executors $scope.editedProfile.executors.push(selectedExecutor); } else { var currentExecutor = getExecutorByIndex($scope.editedProfile, updatedExecutorIndex); @@ -3876,6 +3877,8 @@ module.controller('ClientPoliciesProfilesEditExecutorCtrl', function($scope, rea }, function(errorResponse) { var errDetails = (!errorResponse.data.errorMessage) ? "unknown error, please see the server log" : errorResponse.data.errorMessage if ($scope.createNew) { + $scope.editedProfile.executors = $scope.executors.map((ex) => ex); + $scope.executors = undefined; Notifications.error('Failed to create executor: ' + errDetails); } else { Notifications.error('Failed to update executor: ' + errDetails); diff --git a/themes/src/main/resources/theme/base/admin/resources/js/services.js b/themes/src/main/resources/theme/base/admin/resources/js/services.js index 689724fe68..370f303da4 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/services.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/services.js @@ -1518,10 +1518,15 @@ module.factory('ClientSecret', function($resource) { realm : '@realm', client : '@client' }, { - update : { - method : 'POST' + update : { + method : 'POST' + }, + invalidate: { + url: authUrl + '/admin/realms/:realm/clients/:client/client-secret/rotated', + method: 'DELETE' + } } - }); + ); }); module.factory('ClientRegistrationAccessToken', function($resource) { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-secret-jwt.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-secret-jwt.html index 444bf02faf..4189963c85 100644 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-secret-jwt.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-secret-jwt.html @@ -6,6 +6,7 @@
+
@@ -14,6 +15,21 @@
+
+ +
+
+
+ + +
+
+ +
+
+
+
+
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-secret.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-secret.html index 7cf5bf1359..d1b1b80e54 100644 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-secret.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-secret.html @@ -6,6 +6,7 @@
+
@@ -13,5 +14,20 @@
+ +
+ +
+
+
+ + +
+
+ +
+
+
+