Keycloak 10489 support for client secret rotation (#10603)

Closes #10602
This commit is contained in:
Marcelo Daniel Silva Sales 2022-03-09 00:05:14 +01:00 committed by GitHub
parent fd2cd688b8
commit 7335abaf08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1819 additions and 204 deletions

View file

@ -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<Feature> disabledFeatures = new HashSet<>();
private final Set<Feature> previewFeatures = new HashSet<>();
private final Set<Feature> experimentalFeatures = new HashSet<>();
private final Set<Feature> 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;
@ -296,8 +295,4 @@ public class Profile {
}
}
public interface PropertyResolver {
String resolve(String feature);
}
}

View file

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

View file

@ -42,4 +42,8 @@ public class ClientPolicyExecutorConfigurationRepresentation {
public void setConfigAsMap(String name, Object value) {
this.configAsMap.put(name, value);
}
public boolean validateConfig(){
return true;
}
}

View file

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

View file

@ -0,0 +1,17 @@
package org.keycloak.models;
/**
* @author <a href="mailto:masales@redhat.com">Marcelo Sales</a>
*/
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";
}

View file

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

View file

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

View file

@ -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<String> 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<String> attrValues) {
if (attrValues == null || attrValues.size() == 0) {
// Remove attribute
setAttribute(attrKey, null);
} else {
String attrValueFull = String.join(Constants.CFG_DELIMITER, attrValues);
setAttribute(attrKey, attrValueFull);
}
}
}

View file

@ -34,17 +34,12 @@ import java.util.List;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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<String> 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<String> attrValues) {
if (attrValues == null || attrValues.size() == 0) {
// Remove attribute
setAttribute(attrKey, null);
} else {
String attrValueFull = String.join(Constants.CFG_DELIMITER, attrValues);
setAttribute(attrKey, attrValueFull);
}
}
}

View file

@ -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 <a href="mailto:masales@redhat.com">Marcelo Sales</a>
*/
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<String, Object> 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 "";
}
}
}

View file

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

View file

@ -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:

View file

@ -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:

View file

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

View file

@ -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 <a href="mailto:masales@redhat.com">Marcelo Sales</a>
*/
public class ClientSecretRotationExecutor implements
ClientPolicyExecutorProvider<ClientSecretRotationExecutor.Configuration> {
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<Configuration> 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;
}
}
}

View file

@ -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 <a href="mailto:masales@redhat.com">Marcelo Sales</a>
*/
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<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
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<ProviderConfigProperty> 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);
}
}

View file

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

View file

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

View file

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

View file

@ -15,3 +15,4 @@ org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSignedAut
org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory
org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutorFactory
org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory

View file

@ -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 <a href="mailto:masales@redhat.com">Marcelo Sales</a>
*/
@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<RealmRepresentation> testRealms) {
RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"),
RealmRepresentation.class);
List<UserRepresentation> 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<String, String> 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;
}
}

View file

@ -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.

View file

@ -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) {

View file

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

View file

@ -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) {

View file

@ -6,6 +6,7 @@
<div class="row">
<div class="col-sm-6">
<input readonly kc-select-action="click" class="form-control" type="text" id="secret" name="secret" data-ng-model="secret">
<label data-ng-show="serverInfo.featureEnabled('CLIENT_SECRET_ROTATION') && secret_expiration_time" class="control-label">{{:: 'secret-expires-on' | translate}} {{secret_expiration_time | date:'medium'}}</label>
</div>
<div class="col-sm-6" data-ng-show="client.access.configure">
<button type="submit" data-ng-click="changePassword()" class="btn btn-default">{{:: 'regenerate-secret' | translate}}</button>
@ -14,6 +15,21 @@
</div>
</div>
<div class="form-group" data-ng-show="client.access.configure && secretRotated && serverInfo.featureEnabled('CLIENT_SECRET_ROTATION')">
<label class="col-md-2 control-label" for="secretRotated">{{:: 'secret-rotated' | translate}}</label>
<div class="col-sm-6">
<div class="row">
<div class="col-sm-6">
<input readonly kc-select-action="click" class="form-control" type="text" id="secretRotated" name="secretRotated" data-ng-model="secretRotated">
<label class="control-label">{{:: 'secret-expires-on' | translate}} {{rotated_secret_expiration_time | date:'medium'}}</label>
</div>
<div class="col-sm-6" data-ng-show="client.access.configure && secretRotated">
<button type="submit" data-ng-click="removeRotatedSecret()" class="btn btn-default">{{:: 'invalidate-secret' | translate}}</button>
</div>
</div>
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="tokenEndpointAuthSigningAlg">{{:: 'token-endpoint-auth-signing-alg' | translate}}</label>
<div class="col-sm-6">

View file

@ -6,6 +6,7 @@
<div class="row">
<div class="col-sm-6">
<input readonly kc-select-action="click" class="form-control" type="text" id="secret" name="secret" data-ng-model="secret">
<label data-ng-show="serverInfo.featureEnabled('CLIENT_SECRET_ROTATION') && secret_expiration_time" class="control-label">{{:: 'secret-expires-on' | translate}} {{secret_expiration_time | date:'medium'}}</label>
</div>
<div class="col-sm-6" data-ng-show="client.access.configure">
<button type="submit" data-ng-click="changePassword()" class="btn btn-default">{{:: 'regenerate-secret' | translate}}</button>
@ -13,5 +14,20 @@
</div>
</div>
</div>
<div class="form-group" data-ng-show="client.access.configure && secretRotated && serverInfo.featureEnabled('CLIENT_SECRET_ROTATION')">
<label class="col-md-2 control-label" for="secretRotated">{{:: 'secret-rotated' | translate}}</label>
<div class="col-sm-6">
<div class="row">
<div class="col-sm-6">
<input readonly kc-select-action="click" class="form-control" type="text" id="secretRotated" name="secretRotated" data-ng-model="secretRotated">
<label class="control-label">{{:: 'secret-expires-on' | translate}} {{rotated_secret_expiration_time | date:'medium'}}</label>
</div>
<div class="col-sm-6" data-ng-show="client.access.configure && secretRotated">
<button type="submit" data-ng-click="removeRotatedSecret()" class="btn btn-default">{{:: 'invalidate-secret' | translate}}</button>
</div>
</div>
</div>
</div>
</form>
</div>