Skip to content

Commit

Permalink
feature: add persistence support for private_key_jwt client authentic…
Browse files Browse the repository at this point in the history
…ation (#2449)

* refactor: prepare for private_key_jwt in oauth_client_details

BaseClientDetails from spring security oauth2 cannot be changed, therefore more to UaaClientDetails for client details load

* feature: add persistence support for private_key_jwt

Allow to setup jwks_uri and jwks, similar to OIDC proxy mode with tokenKeyUrl and tokenKey.
The private_key_jwt metadata is stored in additional_information (could be switched to own column)

The setup can be done from REST and yaml.

* more tests

* refactorings

* add tests

* Renamed

* Renamed

* Renamed

* Add column client_jwt_config and do some refactoring for UaaClientDetails usage

* Sonar findings

* cleanup

* Refactoring because of usage of client_jwt_config now from oauth_client_details

additional_information is not used anymore

* remove not needed method.

Even if UaaClientDetails is used the addClientDetails method can be used and therefore it does not make sense to have 2 add methods

* review

* own events for jwt client configuration

* review
 * throw exceptions
 *

* doc: Add documentation

* Add new scope clients.trust

This can be used for JWT client trust configuration calls
Similar to clients.secret

* review

* more tests

* sonar findings

* fix sonar issues
  • Loading branch information
strehle authored Sep 26, 2023
1 parent fe50161 commit 77d5ea5
Show file tree
Hide file tree
Showing 27 changed files with 1,387 additions and 15 deletions.
21 changes: 20 additions & 1 deletion docs/UAA-APIs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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``
-----------------------------------------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -202,11 +204,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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")
Expand All @@ -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<String, AtomicInteger> getErrorCounts() {
return errorCounts;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 77d5ea5

Please sign in to comment.