Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add persistence support for private_key_jwt client authentication #2449

Merged
merged 33 commits into from
Sep 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5f72461
refactor: prepare for private_key_jwt in oauth_client_details
strehle Aug 7, 2023
bf61c5d
feature: add persistence support for private_key_jwt
strehle Aug 22, 2023
8530edc
Merge branch 'develop' of github.com:cloudfoundry/uaa into feature/is…
strehle Aug 23, 2023
e72194a
more tests
strehle Aug 23, 2023
154589c
refactorings
strehle Aug 24, 2023
5376079
add tests
strehle Aug 24, 2023
a9cba87
Renamed
strehle Aug 24, 2023
c69b748
Renamed
strehle Aug 24, 2023
97c06aa
Renamed
strehle Aug 24, 2023
ea97594
Merge branch 'develop' of github.com:cloudfoundry/uaa into feature/is…
strehle Aug 24, 2023
187dbb9
Add column client_jwt_config and do some refactoring for UaaClientDet…
strehle Aug 24, 2023
73599e8
Merge branch 'develop' of github.com:cloudfoundry/uaa into feature/is…
strehle Aug 24, 2023
82a5857
Sonar findings
strehle Aug 24, 2023
bf6f7b2
cleanup
strehle Aug 24, 2023
127d570
Merge branch 'develop' of github.com:cloudfoundry/uaa into feature/is…
strehle Aug 24, 2023
e4ea431
Merge
strehle Aug 24, 2023
415983b
Refactoring because of usage of client_jwt_config now from oauth_clie…
strehle Aug 24, 2023
62025d8
Merge branch 'develop' of github.com:cloudfoundry/uaa into feature/is…
strehle Aug 25, 2023
aace8e0
remove not needed method.
strehle Aug 25, 2023
a7d62ac
Merge branch 'develop' of github.com:cloudfoundry/uaa into feature/is…
strehle Sep 8, 2023
aecb645
review
strehle Sep 8, 2023
6a6403b
Merge branch 'feature/issue/2235/refactorClient' of github.com:cloudf…
strehle Sep 8, 2023
3dc5760
Merge branches 'feature/issue/2235/jwtTrustConfig' and 'develop' of g…
strehle Sep 14, 2023
4310cfd
Merge branch 'develop' of github.com:cloudfoundry/uaa into feature/is…
strehle Sep 15, 2023
e7c0bc2
own events for jwt client configuration
strehle Sep 18, 2023
fe955c9
review
strehle Sep 18, 2023
4c1bf39
doc: Add documentation
strehle Sep 18, 2023
6650eb3
Add new scope clients.trust
strehle Sep 18, 2023
ae0654d
Merge branch 'develop' of github.com:cloudfoundry/uaa into feature/is…
strehle Sep 18, 2023
b3c3ea5
review
strehle Sep 18, 2023
2782dfa
more tests
strehle Sep 18, 2023
379fe73
sonar findings
strehle Sep 18, 2023
5a85e54
fix sonar issues
strehle Sep 19, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
strehle marked this conversation as resolved.
Show resolved Hide resolved
}
}
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 @@ -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());
}
}
hsinn0 marked this conversation as resolved.
Show resolved Hide resolved

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) {
strehle marked this conversation as resolved.
Show resolved Hide resolved

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