diff --git a/docs/UAA-APIs.rst b/docs/UAA-APIs.rst index 6bd1204d750..56079d778fe 100644 --- a/docs/UAA-APIs.rst +++ b/docs/UAA-APIs.rst @@ -10,7 +10,7 @@ Overview The User Account and Authentication Service (UAA): * is a separate application from Cloud Foundry the Cloud Controller -* owns the user accounts and authentication sources (SAML, LDAP, Keystone) +* owns the user accounts and authentication sources (SAML, OpenID Connect, LDAP, Keystone) * is invoked via JSON APIs * supports standard protocols to provide single sign-on and delegated authorization to web applications in addition to JSON APIs to support the Cloud Controller and team features of Cloud Foundry * supports APIs and a basic login/approval UI for web client apps @@ -35,6 +35,7 @@ Here is a summary of the different scopes that are known to the UAA. * **clients.write** - scope required to create and modify clients. The scopes are limited to be prefixed with the scope holder's client id. For example, id:testclient authorities:client.write may create a client that has scopes that have the 'testclient.' prefix. Authorities are limited to uaa.resource * **clients.read** - scope to read information about clients * **clients.secret** - ``/oauth/clients/*/secret`` endpoint. Scope required to change the password of a client. Considered an admin scope. +* **clients.trust** - ``/oauth/clients/*/clientjwt`` endpoint. Scope required to change the JWT configuration of a client. Considered an admin scope. * **scim.write** - Admin write access to all SCIM endpoints, ``/Users``, ``/Groups/``. * **scim.read** - Admin read access to all SCIM endpoints, ``/Users``, ``/Groups/``. * **scim.create** - Reduced scope to be able to create a user using ``POST /Users`` (get verification links ``GET /Users/{id}/verify-link`` or verify their account using ``GET /Users/{id}/verify``) but not be able to modify, read or delete users. @@ -3103,6 +3104,24 @@ Example:: } +Change Client JWT Configuration: ``PUT /oauth/clients/{client_id}/clientjwt`` +--------------------------------------------------------------- + +============== =============================================== +Request ``PUT /oauth/clients/{client_id}/clientjwt`` +Request body *jwt trust configuration change request* +Reponse code ``200 OK`` if successful +Response body a status message (hash) +============== =============================================== + +Example:: + + PUT /oauth/clients/foo/clientjwt + { + "jwks_uri": "http://localhost:8080/uaa/token_keys" + } + + Register Multiple Clients: ``POST /oauth/clients/tx`` ----------------------------------------------------- diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientDetailsCreation.java b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientDetailsCreation.java index 5e0d6d249b2..b8f213744f1 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientDetailsCreation.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientDetailsCreation.java @@ -13,6 +13,12 @@ public class ClientDetailsCreation extends BaseClientDetails { @JsonProperty("secondary_client_secret") private String secondaryClientSecret; + @JsonProperty("jwks_uri") + private String jsonWebKeyUri; + + @JsonProperty("jwks") + private String jsonWebKeySet; + @JsonIgnore public String getSecondaryClientSecret() { return secondaryClientSecret; @@ -21,4 +27,20 @@ public String getSecondaryClientSecret() { public void setSecondaryClientSecret(final String secondaryClientSecret) { this.secondaryClientSecret = secondaryClientSecret; } + + public String getJsonWebKeyUri() { + return jsonWebKeyUri; + } + + public void setJsonWebKeyUri(String jsonWebKeyUri) { + this.jsonWebKeyUri = jsonWebKeyUri; + } + + public String getJsonWebKeySet() { + return jsonWebKeySet; + } + + public void setJsonWebKeySet(String jsonWebKeySet) { + this.jsonWebKeySet = jsonWebKeySet; + } } diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtChangeRequest.java b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtChangeRequest.java new file mode 100644 index 00000000000..337a2282889 --- /dev/null +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtChangeRequest.java @@ -0,0 +1,86 @@ +package org.cloudfoundry.identity.uaa.oauth.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import static org.cloudfoundry.identity.uaa.oauth.client.ClientJwtChangeRequest.ChangeMode.ADD; +import static org.cloudfoundry.identity.uaa.oauth.client.ClientJwtChangeRequest.ChangeMode.DELETE; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class ClientJwtChangeRequest { + + public static final String JWKS_URI = "jwks_uri"; + public static final String JWKS = "jwks"; + + public enum ChangeMode { + UPDATE, + ADD, + DELETE + } + @JsonProperty("kid") + private String keyId; + @JsonProperty(JWKS_URI) + private String jsonWebKeyUri; + @JsonProperty(JWKS) + private String jsonWebKeySet; + @JsonProperty("client_id") + private String clientId; + private ChangeMode changeMode = ADD; + + public ClientJwtChangeRequest() { + } + + public ClientJwtChangeRequest(String clientId, String jsonWebKeyUri, String jsonWebKeySet) { + this.jsonWebKeyUri = jsonWebKeyUri; + this.jsonWebKeySet = jsonWebKeySet; + this.clientId = clientId; + } + + public String getJsonWebKeyUri() { + return jsonWebKeyUri; + } + + public void setJsonWebKeyUri(String jsonWebKeyUri) { + this.jsonWebKeyUri = jsonWebKeyUri; + } + + public String getJsonWebKeySet() { + return jsonWebKeySet; + } + + public void setJsonWebKeySet(String jsonWebKeySet) { + this.jsonWebKeySet = jsonWebKeySet; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public ChangeMode getChangeMode() { + return changeMode; + } + + public void setChangeMode(ChangeMode changeMode) { + this.changeMode = changeMode; + } + + public String getKeyId() { return keyId;} + + public void setKeyId(String keyId) { + this.keyId = keyId; + } + + public String getChangeValue() { + // Depending on change mode, allow different values + if (changeMode == DELETE && keyId != null) { + return keyId; + } + return jsonWebKeyUri != null ? jsonWebKeyUri : jsonWebKeySet; + } +} diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/oauth/client/ClientDetailsCreationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/oauth/client/ClientDetailsCreationTest.java new file mode 100644 index 00000000000..96a62aef809 --- /dev/null +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/oauth/client/ClientDetailsCreationTest.java @@ -0,0 +1,20 @@ +package org.cloudfoundry.identity.uaa.oauth.client; + +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ClientDetailsCreationTest { + + ClientDetailsCreation clientDetailsCreation = new ClientDetailsCreation(); + + @Test + void testRequestSerialization() { + clientDetailsCreation.setJsonWebKeyUri("https://uri.domain.net"); + clientDetailsCreation.setJsonWebKeySet("{}"); + String jsonRequest = JsonUtils.writeValueAsString(clientDetailsCreation); + ClientDetailsCreation request = JsonUtils.readValue(jsonRequest, ClientDetailsCreation.class); + assertEquals(clientDetailsCreation, request); + } +} \ No newline at end of file diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtChangeRequestTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtChangeRequestTest.java new file mode 100644 index 00000000000..67734f5200c --- /dev/null +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/oauth/client/ClientJwtChangeRequestTest.java @@ -0,0 +1,23 @@ +package org.cloudfoundry.identity.uaa.oauth.client; + +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class ClientJwtChangeRequestTest { + + @Test + void testRequestSerialization() { + ClientJwtChangeRequest def = new ClientJwtChangeRequest(null, null, null); + def.setKeyId("key-1"); + def.setChangeMode(ClientJwtChangeRequest.ChangeMode.DELETE); + def.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + def.setJsonWebKeySet("{}"); + def.setClientId("admin"); + String jsonRequest = JsonUtils.writeValueAsString(def); + ClientJwtChangeRequest request = JsonUtils.readValue(jsonRequest, ClientJwtChangeRequest.class); + assertNotEquals(def, request); + } + +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/audit/AuditEventType.java b/server/src/main/java/org/cloudfoundry/identity/uaa/audit/AuditEventType.java index da1dacb7771..e032d2b9f9f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/audit/AuditEventType.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/audit/AuditEventType.java @@ -61,7 +61,9 @@ public enum AuditEventType { IdentityProviderAuthenticationSuccess(37), IdentityProviderAuthenticationFailure(38), MfaAuthenticationSuccess(39), - MfaAuthenticationFailure(40); + MfaAuthenticationFailure(40), + ClientJwtChangeSuccess(41), + ClientJwtChangeFailure(42); private final int code; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java index e1bbc625833..feb597eccda 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java @@ -1,6 +1,8 @@ package org.cloudfoundry.identity.uaa.client; import static java.util.Optional.ofNullable; +import static org.cloudfoundry.identity.uaa.client.ClientJwtConfiguration.JWKS; +import static org.cloudfoundry.identity.uaa.client.ClientJwtConfiguration.JWKS_URI; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_IMPLICIT; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_REFRESH_TOKEN; @@ -158,7 +160,7 @@ private void addNewClients() { if (map.get("authorized-grant-types") == null) { throw new InvalidClientDetailsException("Client must have at least one authorized-grant-type. client ID: " + clientId); } - BaseClientDetails client = new BaseClientDetails(clientId, (String) map.get("resource-ids"), + UaaClientDetails client = new UaaClientDetails(clientId, (String) map.get("resource-ids"), (String) map.get("scope"), (String) map.get("authorized-grant-types"), (String) map.get("authorities"), getRedirectUris(map)); @@ -204,11 +206,23 @@ private void addNewClients() { } for (String key : Arrays.asList("resource-ids", "scope", "authorized-grant-types", "authorities", "redirect-uri", "secret", "id", "override", "access-token-validity", - "refresh-token-validity", "show-on-homepage", "app-launch-url", "app-icon")) { + "refresh-token-validity", "show-on-homepage", "app-launch-url", "app-icon", JWKS, JWKS_URI)) { info.remove(key); } client.setAdditionalInformation(info); + + if (map.get(JWKS_URI) instanceof String || map.get(JWKS) instanceof String) { + String jwksUri = (String) map.get(JWKS_URI); + String jwks = (String) map.get(JWKS); + ClientJwtConfiguration keyConfig = ClientJwtConfiguration.parse(jwksUri, jwks); + if (keyConfig != null && keyConfig.getCleanString() != null) { + keyConfig.writeValue(client); + } else { + throw new InvalidClientDetailsException("Client jwt configuration invalid syntax. ClientID: " + client.getClientId()); + } + } + try { clientRegistrationService.addClientDetails(client, IdentityZone.getUaaZoneId()); if (secondSecret != null) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpoints.java index cf98e995a61..84cf4aa7d29 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpoints.java @@ -20,6 +20,7 @@ import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsCreation; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsModification; +import org.cloudfoundry.identity.uaa.oauth.client.ClientJwtChangeRequest; import org.cloudfoundry.identity.uaa.oauth.client.SecretChangeRequest; import org.cloudfoundry.identity.uaa.resources.ActionResult; import org.cloudfoundry.identity.uaa.resources.AttributeNameMapper; @@ -65,6 +66,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -114,6 +116,7 @@ public class ClientAdminEndpoints implements ApplicationEventPublisherAware { private final AtomicInteger clientUpdates; private final AtomicInteger clientDeletes; private final AtomicInteger clientSecretChanges; + private final AtomicInteger clientJwtChanges; private ApplicationEventPublisher publisher; @@ -154,6 +157,7 @@ public ClientAdminEndpoints(final SecurityContextAccessor securityContextAccesso this.clientUpdates = new AtomicInteger(); this.clientDeletes = new AtomicInteger(); this.clientSecretChanges = new AtomicInteger(); + this.clientJwtChanges = new AtomicInteger(); } @ManagedMetric(metricType = MetricType.COUNTER, displayName = "Client Registration Count") @@ -176,6 +180,11 @@ public int getClientSecretChanges() { return clientSecretChanges.get(); } + @ManagedMetric(metricType = MetricType.COUNTER, displayName = "Client Jwt Config Change Count (Since Startup)") + public int getClientJwtChanges() { + return clientJwtChanges.get(); + } + @ManagedMetric(displayName = "Errors Since Startup") public Map getErrorCounts() { return errorCounts; @@ -535,6 +544,52 @@ public ActionResult changeSecret(@PathVariable String client_id, @RequestBody Se return result; } + @PutMapping(value = "/oauth/clients/{client_id}/clientjwt") + @ResponseBody + public ActionResult changeClientJwt(@PathVariable String client_id, @RequestBody ClientJwtChangeRequest change) { + + UaaClientDetails uaaClientDetails; + try { + uaaClientDetails = (UaaClientDetails) clientDetailsService.retrieve(client_id, IdentityZoneHolder.get().getId()); + } catch (InvalidClientException e) { + throw new NoSuchClientException("No such client: " + client_id); + } + + try { + checkPasswordChangeIsAllowed(uaaClientDetails, ""); + } catch (IllegalStateException e) { + throw new InvalidClientDetailsException(e.getMessage()); + } + + ActionResult result; + switch (change.getChangeMode()){ + case ADD : + if (change.getChangeValue() != null) { + clientRegistrationService.addClientJwtConfig(client_id, change.getChangeValue(), IdentityZoneHolder.get().getId(), false); + result = new ActionResult("ok", "Client jwt configuration is added"); + } else { + result = new ActionResult("ok", "No key added"); + } + break; + + case DELETE : + if (ClientJwtConfiguration.readValue(uaaClientDetails) != null && change.getChangeValue() != null) { + clientRegistrationService.deleteClientJwtConfig(client_id, change.getChangeValue(), IdentityZoneHolder.get().getId()); + result = new ActionResult("ok", "Client jwt configuration is deleted"); + } else { + result = new ActionResult("ok", "No key deleted"); + } + break; + + default: + clientRegistrationService.addClientJwtConfig(client_id, change.getChangeValue(), IdentityZoneHolder.get().getId(), true); + result = new ActionResult("ok", "Client jwt configuration updated"); + } + clientJwtChanges.incrementAndGet(); + + return result; + } + private boolean validateCurrentClientSecretAdd(String clientSecret) { return clientSecret == null || clientSecret.split(" ").length == 1; } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 804dd5bfc3e..0f3849577f1 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -12,14 +12,15 @@ *******************************************************************************/ package org.cloudfoundry.identity.uaa.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.cloudfoundry.identity.uaa.constants.OriginKeys; +import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsCreation; import org.cloudfoundry.identity.uaa.resources.QueryableResourceManager; import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor; import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.zone.ClientSecretValidator; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.oauth2.provider.ClientDetails; @@ -245,6 +246,20 @@ public ClientDetails validate(ClientDetails prototype, boolean create, boolean c } clientSecretValidator.validate(client.getClientSecret()); } + + if (prototype instanceof ClientDetailsCreation) { + ClientDetailsCreation clientDetailsCreation = (ClientDetailsCreation) prototype; + if (StringUtils.hasText(clientDetailsCreation.getJsonWebKeyUri()) || StringUtils.hasText(clientDetailsCreation.getJsonWebKeySet())) { + ClientJwtConfiguration clientJwtConfiguration = ClientJwtConfiguration.parse(clientDetailsCreation.getJsonWebKeyUri(), + clientDetailsCreation.getJsonWebKeySet()); + if (clientJwtConfiguration != null) { + clientJwtConfiguration.writeValue(client); + } else { + throw new InvalidClientDetailsException( + "Client with client jwt configuration not valid"); + } + } + } } return client; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfiguration.java new file mode 100644 index 00000000000..792499e100b --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfiguration.java @@ -0,0 +1,325 @@ +package org.cloudfoundry.identity.uaa.client; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import org.cloudfoundry.identity.uaa.oauth.client.ClientJwtChangeRequest; +import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKey; +import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKeyHelper; +import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKeySet; +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; +import org.springframework.security.oauth2.provider.ClientDetails; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +import java.net.URI; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class ClientJwtConfiguration implements Cloneable{ + + public static final String JWKS_URI = ClientJwtChangeRequest.JWKS_URI; + public static final String JWKS = ClientJwtChangeRequest.JWKS; + + @JsonIgnore + private static final int MAX_KEY_SIZE = 10; + + @JsonProperty(JWKS_URI) + private String jwksUri; + + @JsonProperty(JWKS) + private JsonWebKeySet jwkSet; + + public ClientJwtConfiguration() { + } + + public ClientJwtConfiguration(final String jwksUri, final JsonWebKeySet webKeySet) { + this.jwksUri = jwksUri; + jwkSet = webKeySet; + if (jwkSet != null) { + validateJwkSet(); + } + } + + public String getJwksUri() { + return this.jwksUri; + } + + public void setJwksUri(final String jwksUri) { + this.jwksUri = jwksUri; + } + + public JsonWebKeySet getJwkSet() { + return this.jwkSet; + } + + public void setJwkSet(final JsonWebKeySet jwkSet) { + this.jwkSet = jwkSet; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + if (o instanceof ClientJwtConfiguration) { + ClientJwtConfiguration that = (ClientJwtConfiguration) o; + if (!Objects.equals(jwksUri, that.jwksUri)) return false; + if (jwkSet != null && that.jwkSet != null) { + return jwkSet.getKeys().equals(that.jwkSet.getKeys()); + } else { + return Objects.equals(jwkSet, that.jwkSet); + } + } + return false; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + + result = 31 * result + (jwksUri != null ? jwksUri.hashCode() : 0); + result = 31 * result + (jwkSet != null ? jwkSet.hashCode() : 0); + return result; + } + + @Override + public Object clone() throws CloneNotSupportedException { + return super.clone(); + } + + @JsonIgnore + public String getCleanString() { + try { + if (UaaUrlUtils.isUrl(this.jwksUri)) { + return this.jwksUri; + } else if (this.jwkSet != null && !ObjectUtils.isEmpty(this.jwkSet.getKeySetMap())) { + return JWKSet.parse(this.jwkSet.getKeySetMap()).toString(true); + } + } catch (IllegalStateException | JsonUtils.JsonUtilException | ParseException e) { + throw new InvalidClientDetailsException("Client jwt configuration configuration fails ", e); + } + return null; + } + + @JsonIgnore + public static ClientJwtConfiguration parse(String privateKeyConfig) { + return UaaUrlUtils.isUrl(privateKeyConfig) ? parseJwksUri(privateKeyConfig) : parseJwkSet(privateKeyConfig); + } + + @JsonIgnore + public static ClientJwtConfiguration parse(String privateKeyUrl, String privateKeyJwt) { + ClientJwtConfiguration clientJwtConfiguration = null; + if (privateKeyUrl != null) { + clientJwtConfiguration = parseJwksUri(privateKeyUrl); + } else if (privateKeyJwt != null && privateKeyJwt.contains("{") && privateKeyJwt.contains("}")) { + clientJwtConfiguration = parseJwkSet(privateKeyJwt); + } + return clientJwtConfiguration; + } + + private static ClientJwtConfiguration parseJwkSet(String privateKeyJwt) { + ClientJwtConfiguration clientJwtConfiguration; + String cleanJwtString; + try { + HashMap jsonMap = JsonUtils.readValue(privateKeyJwt, HashMap.class); + if (jsonMap.containsKey("keys")) { + cleanJwtString = JWKSet.parse(jsonMap).toString(true); + } else { + cleanJwtString = JWK.parse(jsonMap).toPublicJWK().toString(); + } + clientJwtConfiguration = new ClientJwtConfiguration(null, JsonWebKeyHelper.deserialize(cleanJwtString)); + clientJwtConfiguration.validateJwkSet(); + } catch (ParseException | JsonUtils.JsonUtilException e) { + throw new InvalidClientDetailsException("Client jwt configuration cannot be parsed", e); + } + return clientJwtConfiguration; + } + + private static ClientJwtConfiguration parseJwksUri(String privateKeyUrl) { + String normalizedUri; + try { + normalizedUri = UaaUrlUtils.normalizeUri(privateKeyUrl); + } catch (IllegalArgumentException e) { + throw new InvalidClientDetailsException("Client jwt configuration with invalid URI", e); + } + ClientJwtConfiguration clientJwtConfiguration = new ClientJwtConfiguration(normalizedUri, null); + clientJwtConfiguration.validateJwksUri(); + return clientJwtConfiguration; + } + + private boolean validateJwkSet() { + List keyList = jwkSet.getKeys(); + if (keyList.isEmpty() || keyList.size() > MAX_KEY_SIZE) { + throw new InvalidClientDetailsException("Invalid private_key_jwt: jwk set is empty of exceeds to maximum of keys. max: + " + MAX_KEY_SIZE); + } + Set keyId = new HashSet<>(); + keyList.forEach((JsonWebKey key) -> { + if (!StringUtils.hasText(key.getKid())) { + throw new InvalidClientDetailsException("Invalid private_key_jwt: kid is required attribute"); + } + keyId.add(key.getKid()); + }); + if (keyId.size() != keyList.size()) { + throw new InvalidClientDetailsException("Invalid private_key_jwt: duplicate kid in JWKSet not allowed"); + } + return true; + } + + private boolean validateJwksUri() { + URI validateJwksUri; + try { + validateJwksUri = URI.create(this.jwksUri); + } catch (IllegalArgumentException e) { + throw new InvalidClientDetailsException("Invalid private_key_jwt: jwks_uri must be URI complaint", e); + } + if (!validateJwksUri.isAbsolute()) { + throw new InvalidClientDetailsException("Invalid private_key_jwt: jwks_uri must be an absolute URL"); + } + if (!"https".equals(validateJwksUri.getScheme()) && !"http".equals(validateJwksUri.getScheme())) { + throw new InvalidClientDetailsException("Invalid private_key_jwt: jwks_uri must be either using https or http"); + } + if ("http".equals(validateJwksUri.getScheme()) && !validateJwksUri.getHost().endsWith("localhost")) { + throw new InvalidClientDetailsException("Invalid private_key_jwt: jwks_uri with http is not on localhost"); + } + return true; + } + + /** + * Creator from ClientDetails. Should abstract the persistence. + * Use currently the client_jwt_config in UaaClientDetails + * + * @param clientDetails + * @return + */ + @JsonIgnore + public static ClientJwtConfiguration readValue(UaaClientDetails clientDetails) { + if (clientDetails == null || + clientDetails.getClientJwtConfig() == null || + !(clientDetails.getClientJwtConfig() instanceof String)) { + return null; + } + return JsonUtils.readValue(clientDetails.getClientJwtConfig(), ClientJwtConfiguration.class); + } + + /** + * Creator from ClientDetails. Should abstract the persistence. + * Use currently the client_jwt_config in UaaClientDetails + * + * @param clientDetails + * @return + */ + @JsonIgnore + public void writeValue(ClientDetails clientDetails) { + if (clientDetails instanceof UaaClientDetails) { + UaaClientDetails uaaClientDetails = (UaaClientDetails) clientDetails; + uaaClientDetails.setClientJwtConfig(JsonUtils.writeValueAsString(this)); + } + } + + /** + * Cleanup configuration in ClientDetails. Should abstract the persistence. + * Use currently the client_jwt_config in UaaClientDetails + * + * @param clientDetails + * @return + */ + @JsonIgnore + public static void resetConfiguration(ClientDetails clientDetails) { + if (clientDetails instanceof UaaClientDetails) { + UaaClientDetails uaaClientDetails = (UaaClientDetails) clientDetails; + uaaClientDetails.setClientJwtConfig(null); + } + } + + @JsonIgnore + public static ClientJwtConfiguration merge(ClientJwtConfiguration existingConfig, ClientJwtConfiguration newConfig, boolean overwrite) { + if (existingConfig == null) { + return newConfig; + } + if (newConfig == null) { + return existingConfig; + } + ClientJwtConfiguration result = null; + if (newConfig.jwksUri != null) { + if (overwrite) { + result = new ClientJwtConfiguration(newConfig.jwksUri, null); + } else { + result = existingConfig; + } + } + if (newConfig.jwkSet != null) { + if (existingConfig.jwkSet == null) { + if (overwrite) { + result = new ClientJwtConfiguration(null, newConfig.jwkSet); + } else { + result = existingConfig; + } + } else { + JsonWebKeySet existingKeySet = existingConfig.jwkSet; + List existingKeys = new ArrayList<>(existingKeySet.getKeys()); + List newKeys = new ArrayList<>(); + newConfig.getJwkSet().getKeys().forEach((JsonWebKey key) -> { + if (existingKeys.contains(key)) { + if (overwrite) { + existingKeys.remove(key); + newKeys.add(key); + } + } else { + newKeys.add(key); + } + }); + existingKeys.addAll(newKeys); + result = new ClientJwtConfiguration(null, new JsonWebKeySet<>(existingKeys)); + } + } + return result; + } + + @JsonIgnore + public static ClientJwtConfiguration delete(ClientJwtConfiguration existingConfig, ClientJwtConfiguration tobeDeleted) { + if (existingConfig == null) { + return null; + } + if (tobeDeleted == null) { + return existingConfig; + } + ClientJwtConfiguration result = null; + if (existingConfig.jwkSet != null && tobeDeleted.jwksUri != null) { + JsonWebKeySet existingKeySet = existingConfig.jwkSet; + List keys = existingKeySet.getKeys().stream().filter(k -> !tobeDeleted.jwksUri.equals(k.getKid())).collect(Collectors.toList()); + if (keys.isEmpty()) { + result = null; + } else { + result = new ClientJwtConfiguration(null, new JsonWebKeySet<>(keys)); + } + } else if (existingConfig.jwkSet != null && tobeDeleted.jwkSet != null) { + List existingKeys = new ArrayList<>(existingConfig.getJwkSet().getKeys()); + existingKeys.removeAll(tobeDeleted.jwkSet.getKeys()); + if (existingKeys.isEmpty()) { + result = null; + } else { + result = new ClientJwtConfiguration(null, new JsonWebKeySet<>(existingKeys)); + } + } else if (existingConfig.jwksUri != null && tobeDeleted.jwksUri != null) { + if ("*".equals(tobeDeleted.jwksUri) || existingConfig.jwksUri.equals(tobeDeleted.jwksUri)) { + result = null; + } else { + result = existingConfig; + } + } + return result; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientAdminEventPublisher.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientAdminEventPublisher.java index 10e7c186561..3ce02a7153f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientAdminEventPublisher.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientAdminEventPublisher.java @@ -112,6 +112,14 @@ public void secretChange(String clientId) { publish(new SecretChangeEvent(getClient(clientId), getPrincipal(), identityZoneManager.getCurrentIdentityZoneId())); } + public void clientJwtFailure(String clientId, Exception e) { + publish(new ClientJwtFailureEvent(e.getMessage(), getClient(clientId), getPrincipal(), identityZoneManager.getCurrentIdentityZoneId())); + } + + public void clientJwtChange(String clientId) { + publish(new ClientJwtChangeEvent(getClient(clientId), getPrincipal(), identityZoneManager.getCurrentIdentityZoneId())); + } + private ClientDetails getClient(String clientId) { try { return clientDetailsService.loadClientByClientId(clientId, identityZoneManager.getCurrentIdentityZoneId()); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientJwtChangeEvent.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientJwtChangeEvent.java new file mode 100644 index 00000000000..bd65441bce0 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientJwtChangeEvent.java @@ -0,0 +1,18 @@ +package org.cloudfoundry.identity.uaa.client.event; + +import org.cloudfoundry.identity.uaa.audit.AuditEventType; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.provider.ClientDetails; + +public class ClientJwtChangeEvent extends AbstractClientAdminEvent { + + public ClientJwtChangeEvent(ClientDetails client, Authentication principal, String zoneId) { + super(client, principal, zoneId); + } + + @Override + public AuditEventType getAuditEventType() { + return AuditEventType.ClientJwtChangeSuccess; + } + +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientJwtFailureEvent.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientJwtFailureEvent.java new file mode 100644 index 00000000000..659bddeee8c --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/event/ClientJwtFailureEvent.java @@ -0,0 +1,26 @@ +package org.cloudfoundry.identity.uaa.client.event; + +import org.cloudfoundry.identity.uaa.audit.AuditEventType; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.provider.ClientDetails; + +public class ClientJwtFailureEvent extends AbstractClientAdminEvent { + + private String message; + + public ClientJwtFailureEvent(String message, Authentication principal) { + this(message, null, principal, IdentityZoneHolder.getCurrentZoneId()); + } + + public ClientJwtFailureEvent(String message, ClientDetails client, Authentication principal, String zoneId) { + super(client, principal, zoneId); + this.message = message; + } + + @Override + public AuditEventType getAuditEventType() { + return AuditEventType.ClientJwtChangeFailure; + } + +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/MultitenantClientServices.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/MultitenantClientServices.java index c5fbdfa1706..b1dd9a13dff 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/MultitenantClientServices.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/MultitenantClientServices.java @@ -32,6 +32,10 @@ interface MultitenantClientSecretService { void addClientSecret(String clientId, String newSecret, String zoneId) throws NoSuchClientException; void deleteClientSecret(String clientId, String zoneId) throws NoSuchClientException; + + void addClientJwtConfig(String clientId, String keyConfig, String zoneId, boolean overwrite) throws NoSuchClientException; + + void deleteClientJwtConfig(String clientId, String keyConfig, String zoneId) throws NoSuchClientException; } public abstract class MultitenantClientServices implements diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsService.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsService.java index c5ce7ee7466..b15b8e0d01f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsService.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsService.java @@ -2,11 +2,14 @@ import org.cloudfoundry.identity.uaa.audit.event.SystemDeletable; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; +import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.client.ClientJwtConfiguration; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.resources.ResourceMonitor; import org.cloudfoundry.identity.uaa.security.ContextSensitiveOAuth2SecurityExpressionMethods; import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -289,6 +292,37 @@ public void deleteClientSecret(String clientId, String zoneId) throws NoSuchClie } } + @Override + public void addClientJwtConfig(String clientId, String keyConfig, String zoneId, boolean overwrite) throws NoSuchClientException { + ClientJwtConfiguration clientJwtConfiguration = ClientJwtConfiguration.parse(keyConfig); + if (clientJwtConfiguration != null) { + UaaClientDetails uaaClientDetails = (UaaClientDetails) loadClientByClientId(clientId, zoneId); + ClientJwtConfiguration existingConfig = ClientJwtConfiguration.readValue(uaaClientDetails); + ClientJwtConfiguration result = ClientJwtConfiguration.merge(existingConfig, clientJwtConfiguration, overwrite); + if (result != null) { + updateClientJwtConfig(clientId, JsonUtils.writeValueAsString(result), zoneId); + } + } else { + throw new InvalidClientDetailsException("Invalid jwt configuration configuration"); + } + } + + @Override + public void deleteClientJwtConfig(String clientId, String keyConfig, String zoneId) throws NoSuchClientException { + ClientJwtConfiguration clientJwtConfiguration; + if(UaaUrlUtils.isUrl(keyConfig)) { + clientJwtConfiguration = ClientJwtConfiguration.parse(keyConfig); + } else { + clientJwtConfiguration = new ClientJwtConfiguration(keyConfig, null); + } + if (clientJwtConfiguration != null) { + UaaClientDetails uaaClientDetails = (UaaClientDetails) loadClientByClientId(clientId, zoneId); + ClientJwtConfiguration result = ClientJwtConfiguration.delete(ClientJwtConfiguration.readValue(uaaClientDetails), clientJwtConfiguration); + updateClientJwtConfig(clientId, result != null ? JsonUtils.writeValueAsString(result) : null, zoneId); + } else { + throw new InvalidClientDetailsException("Invalid jwt configuration configuration"); + } + } /** * Row mapper for ClientDetails. diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java index 8336a6bace1..1264d0b3ccd 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java @@ -252,6 +252,47 @@ void simpleAddClientWithSignupSuccessRedirectUrl() throws Exception { assertTrue(clientDetails.getRegisteredRedirectUri().contains("callback_url")); } + @Test + void simpleAddClientWithJwksUri() throws Exception { + Map map = new HashMap<>(); + map.put("id", "foo-jwks-uri"); + map.put("secret", "bar"); + map.put("scope", "openid"); + map.put("authorized-grant-types", GRANT_TYPE_AUTHORIZATION_CODE); + map.put("authorities", "uaa.none"); + map.put("redirect-uri", "http://localhost/callback"); + map.put("jwks_uri", "https://localhost:8080/uaa"); + UaaClientDetails clientDetails = (UaaClientDetails) doSimpleTest(map, clientAdminBootstrap, multitenantJdbcClientDetailsService, clients); + assertNotNull(clientDetails.getClientJwtConfig()); + } + + @Test + void simpleAddClientWithJwkSet() throws Exception { + Map map = new HashMap<>(); + map.put("id", "foo-jwks"); + map.put("secret", "bar"); + map.put("scope", "openid"); + map.put("authorized-grant-types", GRANT_TYPE_AUTHORIZATION_CODE); + map.put("authorities", "uaa.none"); + map.put("redirect-uri", "http://localhost/callback"); + map.put("jwks", "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"key-1\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\"}"); + UaaClientDetails clientDetails = (UaaClientDetails) doSimpleTest(map, clientAdminBootstrap, multitenantJdbcClientDetailsService, clients); + assertNotNull(clientDetails.getClientJwtConfig()); + } + + @Test + void simpleInvalidClientWithJwkSet() throws Exception { + Map map = new HashMap<>(); + map.put("id", "foo-jwks"); + map.put("secret", "bar"); + map.put("scope", "openid"); + map.put("authorized-grant-types", GRANT_TYPE_AUTHORIZATION_CODE); + map.put("authorities", "uaa.none"); + map.put("redirect-uri", "http://localhost/callback"); + map.put("jwks", "invalid"); + assertThrows(InvalidClientDetailsException.class, () -> doSimpleTest(map, clientAdminBootstrap, multitenantJdbcClientDetailsService, clients)); + } + @Test void clientMetadata_getsBootstrapped() { Map map = new HashMap<>(); @@ -327,17 +368,17 @@ void setUp() { @Test void simpleAddClientWithAutoApprove() { Map map = createClientMap(autoApproveId); - BaseClientDetails output = new BaseClientDetails(autoApproveId, "none", "openid", "authorization_code,refresh_token", "uaa.none", "http://localhost/callback"); + UaaClientDetails output = new UaaClientDetails(autoApproveId, "none", "openid", "authorization_code,refresh_token", "uaa.none", "http://localhost/callback"); output.setClientSecret("bar"); doReturn(output).when(multitenantJdbcClientDetailsService).loadClientByClientId(eq(autoApproveId), anyString()); clients.put((String) map.get("id"), map); - BaseClientDetails expectedAdd = new BaseClientDetails(output); + UaaClientDetails expectedAdd = new UaaClientDetails(output); clientAdminBootstrap.afterPropertiesSet(); verify(multitenantJdbcClientDetailsService).addClientDetails(expectedAdd, "uaa"); - BaseClientDetails expectedUpdate = new BaseClientDetails(expectedAdd); + UaaClientDetails expectedUpdate = new UaaClientDetails(expectedAdd); expectedUpdate.setAdditionalInformation(Collections.singletonMap(ClientConstants.AUTO_APPROVE, true)); verify(multitenantJdbcClientDetailsService).updateClientDetails(expectedUpdate, "uaa"); } @@ -345,16 +386,16 @@ void simpleAddClientWithAutoApprove() { @Test void simpleAddClientWithAllowPublic() { Map map = createClientMap(allowPublicId); - BaseClientDetails output = new BaseClientDetails(allowPublicId, "none", "openid", "authorization_code,refresh_token", "uaa.none", "http://localhost/callback"); + UaaClientDetails output = new UaaClientDetails(allowPublicId, "none", "openid", "authorization_code,refresh_token", "uaa.none", "http://localhost/callback"); output.setClientSecret("bar"); doReturn(output).when(multitenantJdbcClientDetailsService).loadClientByClientId(eq(allowPublicId), anyString()); clients.put((String) map.get("id"), map); - BaseClientDetails expectedAdd = new BaseClientDetails(output); + UaaClientDetails expectedAdd = new UaaClientDetails(output); clientAdminBootstrap.afterPropertiesSet(); - BaseClientDetails expectedUpdate = new BaseClientDetails(expectedAdd); + UaaClientDetails expectedUpdate = new UaaClientDetails(expectedAdd); expectedUpdate.setAdditionalInformation(Collections.singletonMap(ClientConstants.ALLOW_PUBLIC, true)); verify(multitenantJdbcClientDetailsService, times(1)).updateClientDetails(expectedUpdate, "uaa"); } @@ -362,7 +403,7 @@ void simpleAddClientWithAllowPublic() { @Test void simpleAddClientWithAllowPublicNoClient() { Map map = createClientMap(allowPublicId); - BaseClientDetails output = new BaseClientDetails(allowPublicId, "none", "openid", "authorization_code,refresh_token", "uaa.none", "http://localhost/callback"); + UaaClientDetails output = new UaaClientDetails(allowPublicId, "none", "openid", "authorization_code,refresh_token", "uaa.none", "http://localhost/callback"); output.setClientSecret("bar"); doThrow(new NoSuchClientException(allowPublicId)).when(multitenantJdbcClientDetailsService).loadClientByClientId(eq(allowPublicId), anyString()); @@ -592,7 +633,7 @@ static ClientDetails doSimpleTest( for (String key : Arrays.asList("resource-ids", "scope", "authorized-grant-types", "authorities", "redirect-uri", "secret", "id", "override", "access-token-validity", - "refresh-token-validity")) { + "refresh-token-validity", "jwks", "jwks_uri")) { info.remove(key); } for (Map.Entry entry : info.entrySet()) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java index 415db58534a..ecbaad859f7 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java @@ -9,7 +9,9 @@ import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsCreation; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsModification; +import org.cloudfoundry.identity.uaa.oauth.client.ClientJwtChangeRequest; import org.cloudfoundry.identity.uaa.oauth.client.SecretChangeRequest; +import org.cloudfoundry.identity.uaa.resources.ActionResult; import org.cloudfoundry.identity.uaa.resources.QueryableResourceManager; import org.cloudfoundry.identity.uaa.resources.ResourceMonitor; import org.cloudfoundry.identity.uaa.resources.SearchResults; @@ -59,6 +61,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -73,6 +76,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.withSettings; @@ -212,6 +216,7 @@ void testValidateClientsTransferAutoApproveScopeSet() { void testStatistics() { assertEquals(0, endpoints.getClientDeletes()); assertEquals(0, endpoints.getClientSecretChanges()); + assertEquals(0, endpoints.getClientJwtChanges()); assertEquals(0, endpoints.getClientUpdates()); assertEquals(0, endpoints.getErrorCounts().size()); assertEquals(0, endpoints.getTotalClients()); @@ -1056,6 +1061,122 @@ void testUpdateClientWithAutoapproveScopesTrue() throws Exception { assertTrue(updated.isAutoApprove("foo.write")); } + @Test + void testCreateClientWithJsonWebKeyUri() { + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata, see jwks_uri + String jwksUri = "https://any.domain.net/openid/jwks-uri"; + when(clientDetailsService.retrieve(anyString(), anyString())).thenReturn(input); + when(mockSecurityContextAccessor.getClientId()).thenReturn(detail.getClientId()); + when(mockSecurityContextAccessor.isClient()).thenReturn(true); + + input.setClientSecret("secret"); + detail.setAuthorizedGrantTypes(input.getAuthorizedGrantTypes()); + ClientDetailsCreation createRequest = createClientDetailsCreation(input); + createRequest.setJsonWebKeyUri(jwksUri); + ClientDetails result = endpoints.createClientDetails(createRequest); + assertNull(result.getClientSecret()); + ArgumentCaptor clientCaptor = ArgumentCaptor.forClass(UaaClientDetails.class); + verify(clientDetailsService).create(clientCaptor.capture(), anyString()); + UaaClientDetails created = clientCaptor.getValue(); + assertEquals(ClientJwtConfiguration.readValue(created), ClientJwtConfiguration.parse(jwksUri)); + } + + @Test + void testCreateClientWithJsonWebKeyUriInvalid() { + when(clientDetailsService.retrieve(anyString(), anyString())).thenReturn(input); + when(mockSecurityContextAccessor.getClientId()).thenReturn(detail.getClientId()); + when(mockSecurityContextAccessor.isClient()).thenReturn(true); + + input.setClientSecret("secret"); + detail.setAuthorizedGrantTypes(input.getAuthorizedGrantTypes()); + ClientDetailsCreation createRequest = createClientDetailsCreation(input); + createRequest.setJsonWebKeySet("invalid"); + assertThrows(InvalidClientDetailsException.class, + () -> endpoints.createClientDetails(createRequest)); + } + + @Test + void testAddClientJwtConfigUri() { + when(mockSecurityContextAccessor.getClientId()).thenReturn("bar"); + when(mockSecurityContextAccessor.isClient()).thenReturn(true); + when(mockSecurityContextAccessor.isAdmin()).thenReturn(true); + + when(clientDetailsService.retrieve(detail.getClientId(), IdentityZoneHolder.get().getId())).thenReturn(detail); + + ClientJwtChangeRequest change = new ClientJwtChangeRequest(); + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata, see jwks_uri + String jwksUri = "https://any.domain.net/openid/jwks-uri"; + change.setJsonWebKeyUri(jwksUri); + change.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); + + ActionResult result = endpoints.changeClientJwt(detail.getClientId(), change); + assertEquals("Client jwt configuration is added", result.getMessage()); + verify(clientRegistrationService, times(1)).addClientJwtConfig(detail.getClientId(), jwksUri, IdentityZoneHolder.get().getId(), false); + + change.setJsonWebKeyUri(null); + result = endpoints.changeClientJwt(detail.getClientId(), change); + assertEquals("No key added", result.getMessage()); + } + + @Test + void testChangeDeleteClientJwtConfigUri() { + when(mockSecurityContextAccessor.getClientId()).thenReturn("bar"); + when(mockSecurityContextAccessor.isClient()).thenReturn(true); + when(mockSecurityContextAccessor.isAdmin()).thenReturn(true); + + when(clientDetailsService.retrieve(detail.getClientId(), IdentityZoneHolder.get().getId())).thenReturn(detail); + + ClientJwtChangeRequest change = new ClientJwtChangeRequest(); + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata, see jwks_uri + String jwksUri = "https://any.domain.net/openid/jwks-uri"; + change.setJsonWebKeyUri(jwksUri); + change.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); + + ActionResult result = endpoints.changeClientJwt(detail.getClientId(), change); + assertEquals("Client jwt configuration is added", result.getMessage()); + verify(clientRegistrationService, times(1)).addClientJwtConfig(detail.getClientId(), jwksUri, IdentityZoneHolder.get().getId(), false); + + jwksUri = "https://any.new.domain.net/openid/jwks-uri"; + change.setChangeMode(ClientJwtChangeRequest.ChangeMode.UPDATE); + change.setJsonWebKeyUri(jwksUri); + result = endpoints.changeClientJwt(detail.getClientId(), change); + assertEquals("Client jwt configuration updated", result.getMessage()); + verify(clientRegistrationService, times(1)).addClientJwtConfig(detail.getClientId(), jwksUri, IdentityZoneHolder.get().getId(), true); + + ClientJwtConfiguration.parse(jwksUri).writeValue(detail); + change.setChangeMode(ClientJwtChangeRequest.ChangeMode.DELETE); + change.setJsonWebKeyUri(jwksUri); + result = endpoints.changeClientJwt(detail.getClientId(), change); + assertEquals("Client jwt configuration is deleted", result.getMessage()); + verify(clientRegistrationService, times(1)).deleteClientJwtConfig(detail.getClientId(), jwksUri, IdentityZoneHolder.get().getId()); + } + + @Test + void testCreateClientWithJsonKeyWebSet() { + // Example JWK, a key is bound to a kid, means assumption is, a key is the same if kid is the same + String jsonJwk = "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"key-1\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\"}"; + String jsonJwk2 = "{\"kty\":\"RSA\",\"e\":\"\",\"kid\":\"key-1\",\"alg\":\"RS256\",\"n\":\"\"}"; + String jsonJwk3 = "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"key-2\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\"}"; + String jsonJwkSet = "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"key-1\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\"}]}"; + when(clientDetailsService.retrieve(anyString(), anyString())).thenReturn(input); + when(mockSecurityContextAccessor.getClientId()).thenReturn(detail.getClientId()); + when(mockSecurityContextAccessor.isClient()).thenReturn(true); + + input.setClientSecret("secret"); + detail.setAuthorizedGrantTypes(input.getAuthorizedGrantTypes()); + ClientDetailsCreation createRequest = createClientDetailsCreation(input); + createRequest.setJsonWebKeySet(jsonJwk); + ClientDetails result = endpoints.createClientDetails(createRequest); + assertNull(result.getClientSecret()); + ArgumentCaptor clientCaptor = ArgumentCaptor.forClass(UaaClientDetails.class); + verify(clientDetailsService).create(clientCaptor.capture(), anyString()); + UaaClientDetails created = clientCaptor.getValue(); + assertEquals(ClientJwtConfiguration.readValue(created), ClientJwtConfiguration.parse(jsonJwk)); + assertEquals(ClientJwtConfiguration.readValue(created), ClientJwtConfiguration.parse(jsonJwk2)); + assertEquals(ClientJwtConfiguration.readValue(created), ClientJwtConfiguration.parse(jsonJwkSet)); + assertNotEquals(ClientJwtConfiguration.readValue(created), ClientJwtConfiguration.parse(jsonJwk3)); + } + private ClientDetailsCreation createClientDetailsCreation(BaseClientDetails baseClientDetails) { final var clientDetails = new ClientDetailsCreation(); clientDetails.setClientId(baseClientDetails.getClientId()); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfigurationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfigurationTest.java new file mode 100644 index 00000000000..25612e47d72 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientJwtConfigurationTest.java @@ -0,0 +1,241 @@ +package org.cloudfoundry.identity.uaa.client; + +import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKey; +import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKeySet; +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.junit.jupiter.api.Test; + +import java.text.ParseException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ClientJwtConfigurationTest { + + private final String nValue = "u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q"; + private final String jsonWebKey = "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"key-1\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\"}"; + private final String jsonWebKeyDifferentValue = "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"key-1\",\"alg\":\"RS256\",\"n\":\"new\"}"; + private final String jsonWebKey2 = "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"key-2\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\"}"; + private final String jsonWebKeyNoId = "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\"}"; + private final String jsonJwkSet = "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"key-1\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\"}]}"; + private final String jsonJwkSetEmtpy = "{\"keys\":[]}"; + private final String defaultJsonUri = "{\"jwks_uri\":\"http://localhost:8080/uaa\"} "; + private final String defaultJsonKey = "{\"jwks\":{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"alg\":\"RS256\",\"n\":\"u_A1S-WoVAnHlNQ_1HJmOPBVxIdy1uSNsp5JUF5N4KtOjir9EgG9HhCFRwz48ykEukrgaK4ofyy_wRXSUJKW7Q\",\"kid\":\"key-1\"}]}}"; + + @Test + void testJwksValidity() { + assertNotNull(ClientJwtConfiguration.parse("https://any.domain.net/openid/jwks-uri")); + assertNotNull(ClientJwtConfiguration.parse("http://any.localhost/openid/jwks-uri")); + } + + @Test + void testJwksInvalid() { + assertThrows(InvalidClientDetailsException.class, () -> ClientJwtConfiguration.parse("custom://any.domain.net/openid/jwks-uri", null)); + assertThrows(InvalidClientDetailsException.class, () -> ClientJwtConfiguration.parse("test", null)); + assertThrows(InvalidClientDetailsException.class, () -> ClientJwtConfiguration.parse("http://any.domain.net/openid/jwks-uri")); + assertThrows(InvalidClientDetailsException.class, () -> ClientJwtConfiguration.parse("https://")); + assertThrows(InvalidClientDetailsException.class, () -> ClientJwtConfiguration.parse("ftp://any.domain.net/openid/jwks-uri")); + } + + @Test + void testJwkSetValidity() { + assertNotNull(ClientJwtConfiguration.parse(jsonWebKey)); + assertNotNull(ClientJwtConfiguration.parse(jsonJwkSet)); + } + + @Test + void testJwkSetInvalid() { + assertThrows(InvalidClientDetailsException.class, () -> ClientJwtConfiguration.parse(jsonJwkSetEmtpy)); + assertThrows(InvalidClientDetailsException.class, () -> ClientJwtConfiguration.parse(jsonWebKeyNoId)); + assertThrows(InvalidClientDetailsException.class, () -> ClientJwtConfiguration.parse("{\"keys\": \"x\"}")); + } + + @Test + void testJwkSetInvalidSize() throws ParseException { + assertThrows(InvalidClientDetailsException.class, () -> new ClientJwtConfiguration(null, new JsonWebKeySet(Collections.emptyList()))); + } + + @Test + void testGetCleanConfig() { + assertNotNull(ClientJwtConfiguration.parse("https://any.domain.net/openid/jwks-uri").getCleanString()); + assertNotNull(ClientJwtConfiguration.parse(jsonWebKey).getCleanString()); + } + + @Test + void testGetCleanConfigInvalid() { + JsonWebKeySet mockedKey = mock(JsonWebKeySet.class); + List keyList = ClientJwtConfiguration.parse(jsonJwkSet).getJwkSet().getKeys(); + when(mockedKey.getKeys()).thenReturn(keyList); + ClientJwtConfiguration privateKey = new ClientJwtConfiguration(null, mockedKey); + when(mockedKey.getKeySetMap()).thenThrow(new IllegalStateException("error")); + assertThrows(InvalidClientDetailsException.class, () -> privateKey.getCleanString()); + ClientJwtConfiguration privateKey2 = new ClientJwtConfiguration("hello", null); + assertNull(privateKey2.getCleanString()); + } + + @Test + void testJwtSetValidate() { + JsonWebKeySet mockedKey = mock(JsonWebKeySet.class); + List keyList = ClientJwtConfiguration.parse(jsonJwkSet).getJwkSet().getKeys(); + when(mockedKey.getKeys()).thenReturn(Arrays.asList(keyList.get(0), keyList.get(0))); + assertThrows(InvalidClientDetailsException.class, () -> new ClientJwtConfiguration(null, mockedKey)); + } + + @Test + void testConfigMerge() { + ClientJwtConfiguration configuration = ClientJwtConfiguration.parse(jsonJwkSet); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + ClientJwtConfiguration addKey = ClientJwtConfiguration.parse(jsonWebKey2); + configuration = ClientJwtConfiguration.merge(configuration, addKey, false); + assertEquals(2, configuration.getJwkSet().getKeys().size()); + assertEquals(nValue, configuration.getJwkSet().getKeys().get(0).getKeyProperties().get("n")); + assertEquals(nValue, configuration.getJwkSet().getKeys().get(1).getKeyProperties().get("n")); + + configuration = ClientJwtConfiguration.merge(configuration, addKey, true); + assertEquals(2, configuration.getJwkSet().getKeys().size()); + + configuration = ClientJwtConfiguration.parse(jsonJwkSet); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + assertEquals(nValue, configuration.getJwkSet().getKeys().get(0).getKeyProperties().get("n")); + + configuration = ClientJwtConfiguration.merge(ClientJwtConfiguration.parse(jsonJwkSet), ClientJwtConfiguration.parse(jsonWebKeyDifferentValue), true); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + assertEquals("new", configuration.getJwkSet().getKeys().get(0).getKeyProperties().get("n")); + + configuration = ClientJwtConfiguration.merge(ClientJwtConfiguration.parse(jsonJwkSet), ClientJwtConfiguration.parse(jsonWebKeyDifferentValue), false); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + assertEquals(nValue, configuration.getJwkSet().getKeys().get(0).getKeyProperties().get("n")); + } + + @Test + void testConfigMergeDifferentType() { + ClientJwtConfiguration configuration = ClientJwtConfiguration.parse(jsonJwkSet); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + assertNull(configuration.getJwksUri()); + configuration = ClientJwtConfiguration.merge(configuration, ClientJwtConfiguration.parse("https://any/jwks-uri"), false); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + assertNull(configuration.getJwksUri()); + + configuration = ClientJwtConfiguration.merge(configuration, ClientJwtConfiguration.parse("https://any/jwks-uri"), true); + assertNull(configuration.getJwkSet()); + assertNotNull(configuration.getJwksUri()); + + configuration = ClientJwtConfiguration.merge(ClientJwtConfiguration.parse("https://any/jwks-uri"), ClientJwtConfiguration.parse("https://new/jwks-uri"), false); + assertNull(configuration.getJwkSet()); + assertEquals("https://any/jwks-uri", configuration.getJwksUri()); + + configuration = ClientJwtConfiguration.merge(ClientJwtConfiguration.parse("https://any/jwks-uri"), ClientJwtConfiguration.parse("https://new/jwks-uri"), true); + assertNull(configuration.getJwkSet()); + assertEquals("https://new/jwks-uri", configuration.getJwksUri()); + + configuration = ClientJwtConfiguration.merge(ClientJwtConfiguration.parse("https://any/jwks-uri"), ClientJwtConfiguration.parse(jsonJwkSet), false); + assertNull(configuration.getJwkSet()); + assertEquals("https://any/jwks-uri", configuration.getJwksUri()); + + configuration = ClientJwtConfiguration.merge(ClientJwtConfiguration.parse("https://any/jwks-uri"), ClientJwtConfiguration.parse(jsonJwkSet), true); + assertNull(configuration.getJwksUri()); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + assertEquals(nValue, configuration.getJwkSet().getKeys().get(0).getKeyProperties().get("n")); + } + + @Test + void testConfigMergeNulls() { + ClientJwtConfiguration configuration = ClientJwtConfiguration.parse(jsonJwkSet); + ClientJwtConfiguration existingKeyConfig = ClientJwtConfiguration.merge(configuration, null, true); + assertTrue(configuration.equals(existingKeyConfig)); + assertEquals(configuration, existingKeyConfig); + + ClientJwtConfiguration newKeyConfig = ClientJwtConfiguration.parse("https://any/jwks-uri"); + configuration = ClientJwtConfiguration.merge(null, newKeyConfig, true); + assertTrue(configuration.equals(newKeyConfig)); + assertTrue(configuration.equals(newKeyConfig)); + } + + @Test + void testConfigDelete() { + ClientJwtConfiguration configuration = ClientJwtConfiguration.parse(jsonJwkSet); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + assertNull(configuration.getJwksUri()); + ClientJwtConfiguration addKey = ClientJwtConfiguration.parse(jsonWebKey2); + configuration = ClientJwtConfiguration.merge(configuration, addKey, false); + assertEquals(2, configuration.getJwkSet().getKeys().size()); + configuration = ClientJwtConfiguration.delete(configuration, addKey); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + configuration = ClientJwtConfiguration.delete(configuration, addKey); + configuration = ClientJwtConfiguration.delete(configuration, addKey); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + configuration = ClientJwtConfiguration.merge(configuration, addKey, false); + configuration = ClientJwtConfiguration.delete(configuration, addKey); + assertEquals(1, configuration.getJwkSet().getKeys().size()); + configuration = ClientJwtConfiguration.merge(configuration, addKey, false); + configuration = ClientJwtConfiguration.delete(configuration, new ClientJwtConfiguration("key-2", null)); + configuration = ClientJwtConfiguration.delete(configuration, new ClientJwtConfiguration("key-1", null)); + assertNull(configuration); + configuration = ClientJwtConfiguration.delete(ClientJwtConfiguration.parse(jsonJwkSet), ClientJwtConfiguration.parse(jsonWebKey)); + assertNull(configuration); + + configuration = ClientJwtConfiguration.delete(ClientJwtConfiguration.parse("https://any/jwks-uri"), ClientJwtConfiguration.parse("https://any/jwks-uri")); + assertNull(configuration); + configuration = ClientJwtConfiguration.delete(ClientJwtConfiguration.parse("https://any/jwks-uri"), ClientJwtConfiguration.parse("https://other/jwks-uri")); + assertNotNull(configuration); + } + @Test + void testConfigDeleteNull() { + assertNull(ClientJwtConfiguration.delete(null, ClientJwtConfiguration.parse("https://other/jwks-uri"))); + assertNotNull(ClientJwtConfiguration.delete(ClientJwtConfiguration.parse("https://any/jwks-uri"), null)); + } + + @Test + void testHashCode() { + ClientJwtConfiguration key1 = ClientJwtConfiguration.parse("http://localhost:8080/uaa"); + ClientJwtConfiguration key2 = ClientJwtConfiguration.parse("http://localhost:8080/uaa"); + assertNotEquals(key1.hashCode(), key2.hashCode()); + assertEquals(key1.hashCode(), key1.hashCode()); + assertEquals(key2.hashCode(), key2.hashCode()); + } + + @Test + void testEquals() throws CloneNotSupportedException { + ClientJwtConfiguration key1 = ClientJwtConfiguration.parse("http://localhost:8080/uaa"); + ClientJwtConfiguration key2 = (ClientJwtConfiguration) key1.clone(); + assertEquals(key1, key2); + } + + @Test + void testSerializableObjectCalls() throws CloneNotSupportedException { + ClientJwtConfiguration key1 = JsonUtils.readValue(defaultJsonUri, ClientJwtConfiguration.class); + ClientJwtConfiguration key2 = (ClientJwtConfiguration) key1.clone(); + assertEquals(key1, key2); + + key1 = JsonUtils.readValue(defaultJsonKey, ClientJwtConfiguration.class); + key2 = (ClientJwtConfiguration) key1.clone(); + assertEquals(key1, key2); + } + + @Test + void testConfiguration() { + ClientJwtConfiguration configUri = JsonUtils.readValue(defaultJsonUri, ClientJwtConfiguration.class); + ClientJwtConfiguration configKey = JsonUtils.readValue(defaultJsonKey, ClientJwtConfiguration.class); + UaaClientDetails uaaClientDetails = new UaaClientDetails(); + uaaClientDetails.setClientJwtConfig(JsonUtils.writeValueAsString(configUri)); + + configUri.writeValue(uaaClientDetails); + ClientJwtConfiguration readUriConfig = ClientJwtConfiguration.readValue(uaaClientDetails); + assertEquals(configUri, readUriConfig); + + ClientJwtConfiguration.resetConfiguration(uaaClientDetails); + assertNull(ClientJwtConfiguration.readValue(uaaClientDetails)); + configKey.writeValue(uaaClientDetails); + ClientJwtConfiguration readKeyConfig = ClientJwtConfiguration.readValue(uaaClientDetails); + assertEquals(configKey, readKeyConfig); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/event/ClientAdminEventPublisherTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/event/ClientAdminEventPublisherTests.java index 36f1b6577fb..2a3ee4d4b13 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/event/ClientAdminEventPublisherTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/event/ClientAdminEventPublisherTests.java @@ -1,8 +1,10 @@ package org.cloudfoundry.identity.uaa.oauth.event; import org.aspectj.lang.ProceedingJoinPoint; +import org.cloudfoundry.identity.uaa.audit.AuditEventType; import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; import org.cloudfoundry.identity.uaa.authentication.UaaAuthenticationTestFactory; +import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.client.event.*; import org.cloudfoundry.identity.uaa.zone.MultitenantClientServices; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; @@ -19,6 +21,7 @@ import java.util.Collections; +import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.*; class ClientAdminEventPublisherTests { @@ -91,4 +94,22 @@ void secretFailureMissingClient() { subject.secretFailure("foo", new RuntimeException("planned")); verify(mockApplicationEventPublisher).publishEvent(isA(SecretFailureEvent.class)); } + + @Test + void clientJwtChange() { + UaaClientDetails uaaClientDetails = new UaaClientDetails("foo", null, null, "client_credentials", "none", null); + when(mockMultitenantClientServices.loadClientByClientId("foo")).thenReturn(uaaClientDetails); + subject.clientJwtChange("foo"); + verify(mockApplicationEventPublisher).publishEvent(isA(ClientJwtChangeEvent.class)); + assertEquals(AuditEventType.ClientJwtChangeSuccess, new ClientJwtChangeEvent(uaaClientDetails, SecurityContextHolder.getContext().getAuthentication(), "uaa").getAuditEvent().getType()); + } + + @Test + void clientJwtFailure() { + UaaClientDetails uaaClientDetails = new UaaClientDetails("foo", null, null, "client_credentials", "none", null); + when(mockMultitenantClientServices.loadClientByClientId("foo")).thenReturn(uaaClientDetails); + subject.clientJwtFailure("foo", new RuntimeException("planned")); + verify(mockApplicationEventPublisher).publishEvent(isA(ClientJwtFailureEvent.class)); + assertEquals(AuditEventType.ClientJwtChangeFailure, new ClientJwtFailureEvent("", uaaClientDetails, SecurityContextHolder.getContext().getAuthentication(), "uaa").getAuditEvent().getType()); + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/InMemoryMultitenantClientServices.java b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/InMemoryMultitenantClientServices.java index a9fe483bcfd..7e5aee70d2b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/InMemoryMultitenantClientServices.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/InMemoryMultitenantClientServices.java @@ -48,6 +48,16 @@ public void deleteClientSecret(String clientId, String zoneId) throws NoSuchClie throw new UnsupportedOperationException(); } + @Override + public void addClientJwtConfig(String clientId, String keyConfig, String zoneId, boolean overwrite) throws NoSuchClientException { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteClientJwtConfig(String clientId, String keyConfig, String zoneId) throws NoSuchClientException { + throw new UnsupportedOperationException(); + } + @Override public void addClientDetails(ClientDetails clientDetails, String zoneId) throws ClientAlreadyExistsException { getInMemoryService(zoneId).put(clientDetails.getClientId(), (BaseClientDetails) clientDetails); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsServiceTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsServiceTests.java index 5bd106db6ba..ecf45d04d14 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsServiceTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/MultitenantJdbcClientDetailsServiceTests.java @@ -529,6 +529,39 @@ void deleteClientSecret() { assertEquals(clientSecretBeforeDelete.split(" ")[1], clientSecret); } + @Test + void updateClientJwt() { + BaseClientDetails clientDetails = new BaseClientDetails(); + clientDetails.setClientId("newClientIdWithNoDetails"); + service.addClientDetails(clientDetails); + service.addClientJwtConfig(clientDetails.getClientId(), "http://localhost:8080/uaa/token_keys", currentZoneId, true); + + Map map = jdbcTemplate.queryForMap(SELECT_SQL, + "newClientIdWithNoDetails"); + + assertEquals("newClientIdWithNoDetails", map.get("client_id")); + assertTrue(map.containsKey("client_jwt_config")); + assertEquals("{\"jwks_uri\":\"http://localhost:8080/uaa/token_keys\"}", (String) map.get("client_jwt_config")); + } + + @Test + void deleteClientJwt() { + String clientId = "client_id_test_delete"; + BaseClientDetails clientDetails = new BaseClientDetails(); + clientDetails.setClientId(clientId); + service.addClientDetails(clientDetails); + service.addClientJwtConfig(clientDetails.getClientId(), "http://localhost:8080/uaa/token_keys", currentZoneId, true); + + Map map = jdbcTemplate.queryForMap(SELECT_SQL, clientId); + assertTrue(map.containsKey("client_jwt_config")); + assertEquals("{\"jwks_uri\":\"http://localhost:8080/uaa/token_keys\"}", (String) map.get("client_jwt_config")); + service.deleteClientJwtConfig(clientId, "http://localhost:8080/uaa/token_keys", currentZoneId); + + map = jdbcTemplate.queryForMap(SELECT_SQL, clientId); + assertNull(map.get("client_jwt_config")); + assertFalse(map.containsValue("client_jwt_config")); + } + @Test void deleteClientSecretForInvalidClient() { assertThrowsWithMessageThat(NoSuchClientException.class, diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index 26a06bc4f23..3476e859b30 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -2472,6 +2472,29 @@ _Request Fields_ <%= render('ClientAdminEndpointDocs/changeClientSecret/request-fields.md') %> +## Change Client JWT +This configuration can be done if client authentication is performed with method private_key_jwt +instead of secret based client authentication. See details for client authentiction with [OAuth2](https://oauth.net/2/client-authentication/) and/or +[OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication). + +The client can send a client_assertion for authentication. The signature of the assertion is validated either with the jwks_uri or the internal jwks public keys. + +<%= render('ClientAdminEndpointDocs/changeClientJwt/curl-request.md') %> +<%= render('ClientAdminEndpointDocs/changeClientJwt/http-request.md') %> +<%= render('ClientAdminEndpointDocs/changeClientJwt/http-response.md') %> + +_Path Parameters_ + +<%= render('ClientAdminEndpointDocs/changeClientJwt/path-parameters.md') %> + +_Request Headers_ + +<%= render('ClientAdminEndpointDocs/changeClientJwt/request-headers.md') %> + +_Request Fields_ + +<%= render('ClientAdminEndpointDocs/changeClientJwt/request-fields.md') %> + ## List <%= render('ClientAdminEndpointDocs/listClients/curl-request.md') %> diff --git a/uaa/src/main/webapp/WEB-INF/spring/client-admin-endpoints.xml b/uaa/src/main/webapp/WEB-INF/spring/client-admin-endpoints.xml index 285f71ac09d..7b1e86f385d 100644 --- a/uaa/src/main/webapp/WEB-INF/spring/client-admin-endpoints.xml +++ b/uaa/src/main/webapp/WEB-INF/spring/client-admin-endpoints.xml @@ -19,6 +19,18 @@ + + + + + + + + + + diff --git a/uaa/src/main/webapp/WEB-INF/spring/oauth-clients.xml b/uaa/src/main/webapp/WEB-INF/spring/oauth-clients.xml index 8fa0667a055..70f0834def2 100644 --- a/uaa/src/main/webapp/WEB-INF/spring/oauth-clients.xml +++ b/uaa/src/main/webapp/WEB-INF/spring/oauth-clients.xml @@ -25,7 +25,7 @@ + value="uaa.admin,clients.read,clients.write,clients.secret,clients.trust,scim.read,scim.write,clients.admin"/> diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/ClientAdminEndpointsIntegrationTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/ClientAdminEndpointsIntegrationTests.java index 866e1cae89a..744025d25e4 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/ClientAdminEndpointsIntegrationTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/ClientAdminEndpointsIntegrationTests.java @@ -20,6 +20,7 @@ import org.cloudfoundry.identity.uaa.integration.util.IntegrationTestUtils; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsCreation; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsModification; +import org.cloudfoundry.identity.uaa.oauth.client.ClientJwtChangeRequest; import org.cloudfoundry.identity.uaa.oauth.client.SecretChangeRequest; import org.cloudfoundry.identity.uaa.resources.SearchResults; import org.cloudfoundry.identity.uaa.test.TestAccountSetup; @@ -600,6 +601,60 @@ public void testChangeSecret() throws Exception { assertEquals(HttpStatus.OK, result.getStatusCode()); } + @Test + public void testChangeJwtConfig() throws Exception { + headers = getAuthenticatedHeaders(getClientCredentialsAccessToken("clients.read,clients.write,clients.trust,uaa.admin")); + BaseClientDetails client = createClient("client_credentials"); + + client.setResourceIds(Collections.singleton("foo")); + + ClientJwtChangeRequest def = new ClientJwtChangeRequest(null, null, null); + def.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + def.setClientId("admin"); + + ResponseEntity result = serverRunning.getRestTemplate().exchange( + serverRunning.getUrl("/oauth/clients/{client}/clientjwt"), + HttpMethod.PUT, new HttpEntity(def, headers), Void.class, + client.getClientId()); + assertEquals(HttpStatus.OK, result.getStatusCode()); + } + + @Test + public void testChangeJwtConfigNoAuthorization() throws Exception { + headers = getAuthenticatedHeaders(getClientCredentialsAccessToken("clients.read,clients.write,clients.trust,uaa.admin")); + BaseClientDetails client = createClient("client_credentials"); + headers = getAuthenticatedHeaders(getClientCredentialsAccessToken("clients.read,clients.write")); + + client.setResourceIds(Collections.singleton("foo")); + + ClientJwtChangeRequest def = new ClientJwtChangeRequest(null, null, null); + def.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + def.setClientId("admin"); + + ResponseEntity result = serverRunning.getRestTemplate().exchange( + serverRunning.getUrl("/oauth/clients/{client}/clientjwt"), + HttpMethod.PUT, new HttpEntity(def, headers), Void.class, + client.getClientId()); + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + } + + @Test + public void testChangeJwtConfigInvalidTokenKey() throws Exception { + headers = getAuthenticatedHeaders(getClientCredentialsAccessToken("clients.read,clients.write,clients.secret,uaa.admin")); + BaseClientDetails client = createClient("client_credentials"); + + client.setResourceIds(Collections.singleton("foo")); + + ClientJwtChangeRequest def = new ClientJwtChangeRequest(null, null, null); + def.setJsonWebKeyUri("no uri"); + def.setClientId("admin"); + + ResponseEntity result = serverRunning.getRestTemplate().exchange( + serverRunning.getUrl("/oauth/clients/{client}/clientjwt"), + HttpMethod.PUT, new HttpEntity(def, headers), Void.class, + client.getClientId()); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + } @Test public void testCreateClientsWithStrictSecretPolicy() throws Exception { diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java index 7a0e31be0e5..28bc081a592 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java @@ -77,6 +77,13 @@ class ClientAdminEndpointDocs extends AdminClientCreator { fieldWithPath("changeMode").optional(UPDATE).type(STRING).description("If change mode is set to `"+ADD+"`, the new `secret` will be added to the existing one and if the change mode is set to `"+DELETE+"`, the old secret will be deleted to support secret rotation. Currently only two client secrets are supported at any given time.") }; + private static final FieldDescriptor[] clientJwtChangeFields = new FieldDescriptor[]{ + fieldWithPath("client_id").required().description(clientIdDescription), + fieldWithPath("kid").optional(UPDATE).type(STRING).description("If change mode is set to `"+DELETE+"`, the `id of the key` that will be deleted. The kid parameter is only possible if jwks configuration is used."), + fieldWithPath("jwks").constrained("Optional if jwks_uri is used. Required otherwise.").type(STRING).description("A valid JSON string according JSON Web Key Set standard, see [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517), e.g. content of /token_keys endpoint from UAA"), + fieldWithPath("jwks_uri").constrained("Optional if jwks is used. Required otherwise.").type(STRING).description("A valid URI to token keys endpoint. Must be compliant to jwks_uri from [OpenID Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html).") + }; + @BeforeEach void setup() throws Exception { clientAdminToken = testClient.getClientCredentialsOAuthAccessToken( @@ -255,6 +262,34 @@ void changeClientSecret() throws Exception { ); } + @Test + void changeClientJwt() throws Exception { + ClientDetails createdClientDetails = JsonUtils.readValue(createClientHelper().andReturn().getResponse().getContentAsString(), BaseClientDetails.class); + + ResultActions resultActions = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", createdClientDetails.getClientId()) + .header("Authorization", "Bearer " + clientAdminToken) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .content(writeValueAsString(map( + entry("client_id", createdClientDetails.getClientId()), + entry("jwks_uri", "http://localhost:8080/uaa/token_keys") + )))) + .andExpect(status().isOk()); + + resultActions.andDo(document("{ClassName}/{methodName}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("client_id").required().description(clientIdDescription) + ), + requestHeaders( + headerWithName("Authorization").description("Bearer token containing `clients.trust`, `clients.admin` or `zones.{zone.id}.admin`"), + IDENTITY_ZONE_ID_HEADER, + IDENTITY_ZONE_SUBDOMAIN_HEADER + ), + requestFields(clientJwtChangeFields) + ) + ); + } + @Test void deleteClient() throws Exception { ClientDetails createdClientDetails = JsonUtils.readValue(createClientHelper().andReturn().getResponse().getContentAsString(), BaseClientDetails.class); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcTests.java index 3a78d0591f2..3f265263763 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointsMockMvcTests.java @@ -16,6 +16,8 @@ import org.cloudfoundry.identity.uaa.client.event.ClientApprovalsDeletedEvent; import org.cloudfoundry.identity.uaa.client.event.ClientCreateEvent; import org.cloudfoundry.identity.uaa.client.event.ClientDeleteEvent; +import org.cloudfoundry.identity.uaa.client.event.ClientJwtChangeEvent; +import org.cloudfoundry.identity.uaa.client.event.ClientJwtFailureEvent; import org.cloudfoundry.identity.uaa.client.event.ClientUpdateEvent; import org.cloudfoundry.identity.uaa.client.event.SecretChangeEvent; import org.cloudfoundry.identity.uaa.client.event.SecretFailureEvent; @@ -23,6 +25,7 @@ import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsCreation; import org.cloudfoundry.identity.uaa.oauth.client.ClientDetailsModification; +import org.cloudfoundry.identity.uaa.oauth.client.ClientJwtChangeRequest; import org.cloudfoundry.identity.uaa.oauth.client.SecretChangeRequest; import org.cloudfoundry.identity.uaa.resources.ActionResult; import org.cloudfoundry.identity.uaa.resources.SearchResults; @@ -1943,6 +1946,107 @@ void testPutClientModifyName() throws Exception { assertThat(client.getAdditionalInformation(), hasEntry(is("name"), PredicateMatcher.is(value -> value.equals("New Client Name")))); } + @Test + void testAddNewClientJwtKeyUri() throws Exception { + String token = testClient.getClientCredentialsOAuthAccessToken( + testAccounts.getAdminClientId(), + testAccounts.getAdminClientSecret(), + "uaa.admin,clients.secret"); + String id = generator.generate(); + ClientDetails client = createClient(token, id, SECRET, Collections.singleton("client_credentials")); + ClientJwtChangeRequest request = new ClientJwtChangeRequest(null, null, null); + request.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + request.setClientId("admin"); + request.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); + MockHttpServletResponse response = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", client.getClientId()) + .header("Authorization", "Bearer " + token) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(request))) + .andExpect(status().isOk()) + .andReturn().getResponse(); + + ActionResult actionResult = JsonUtils.readValue(response.getContentAsString(), ActionResult.class); + assertEquals("ok", actionResult.getStatus()); + assertEquals("Client jwt configuration is added", actionResult.getMessage()); + + verify(mockApplicationEventPublisher, times(2)).publishEvent(abstractUaaEventCaptor.capture()); + assertEquals(ClientJwtChangeEvent.class, abstractUaaEventCaptor.getValue().getClass()); + ClientJwtChangeEvent event = (ClientJwtChangeEvent) abstractUaaEventCaptor.getValue(); + assertEquals(id, event.getAuditEvent().getPrincipalId()); + } + + @Test + void testAddNewClientJwtKeyUriButInvalidChange() throws Exception { + String token = testClient.getClientCredentialsOAuthAccessToken( + testAccounts.getAdminClientId(), + testAccounts.getAdminClientSecret(), + "uaa.admin,clients.secret"); + String id = generator.generate(); + ClientDetails client = createClient(token, id, SECRET, Collections.singleton("client_credentials")); + ClientJwtChangeRequest request = new ClientJwtChangeRequest(null, null, null); + request.setJsonWebKeyUri("http://localhost:8080/uaa/token_key"); + request.setClientId("admin"); + request.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); + MockHttpServletResponse response = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", client.getClientId()) + .header("Authorization", "Bearer " + token) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(request))) + .andExpect(status().isOk()) + .andReturn().getResponse(); + + ActionResult actionResult = JsonUtils.readValue(response.getContentAsString(), ActionResult.class); + assertEquals("ok", actionResult.getStatus()); + assertEquals("Client jwt configuration is added", actionResult.getMessage()); + + verify(mockApplicationEventPublisher, times(2)).publishEvent(abstractUaaEventCaptor.capture()); + assertEquals(ClientJwtChangeEvent.class, abstractUaaEventCaptor.getValue().getClass()); + ClientJwtChangeEvent event = (ClientJwtChangeEvent) abstractUaaEventCaptor.getValue(); + assertEquals(id, event.getAuditEvent().getPrincipalId()); + + request = new ClientJwtChangeRequest("admin", null, "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"use\":\"sig\",\"alg\":\"RS256\",\"n\":\"n\"}]}"); + request.setChangeMode(ClientJwtChangeRequest.ChangeMode.UPDATE); + response = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", client.getClientId()) + .header("Authorization", "Bearer " + token) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andReturn().getResponse(); + + verify(mockApplicationEventPublisher, times(3)).publishEvent(abstractUaaEventCaptor.capture()); + assertEquals(ClientJwtFailureEvent.class, abstractUaaEventCaptor.getValue().getClass()); + ClientJwtFailureEvent eventUpdate = (ClientJwtFailureEvent) abstractUaaEventCaptor.getValue(); + assertEquals(client.getClientId(), eventUpdate.getAuditEvent().getPrincipalId()); + } + + @Test + void testInvalidClientJwtKeyUri() throws Exception { + String token = testClient.getClientCredentialsOAuthAccessToken( + testAccounts.getAdminClientId(), + testAccounts.getAdminClientSecret(), + "uaa.admin,clients.secret"); + String id = generator.generate(); + ClientDetails client = createClient(token, id, SECRET, Collections.singleton("client_credentials")); + ClientJwtChangeRequest request = new ClientJwtChangeRequest(null, null, null); + request.setJsonWebKeyUri("no uri"); + request.setClientId("admin"); + request.setChangeMode(ClientJwtChangeRequest.ChangeMode.ADD); + MockHttpServletResponse response = mockMvc.perform(put("/oauth/clients/{client_id}/clientjwt", client.getClientId()) + .header("Authorization", "Bearer " + token) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andReturn().getResponse(); + + verify(mockApplicationEventPublisher, times(2)).publishEvent(abstractUaaEventCaptor.capture()); + assertEquals(ClientJwtFailureEvent.class, abstractUaaEventCaptor.getValue().getClass()); + ClientJwtFailureEvent event = (ClientJwtFailureEvent) abstractUaaEventCaptor.getValue(); + assertEquals(client.getClientId(), event.getAuditEvent().getPrincipalId()); + } + private BaseClientDetails createClient(List authorities) throws Exception { String clientId = generator.generate().toLowerCase(); List scopes = Arrays.asList("foo", "bar", "oauth.approvals");