From 6ce6e514ade15d31742d36b35dfb1076e35671ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Thu, 12 Feb 2026 12:50:33 +0100 Subject: [PATCH] feat(admin api v2): add client secret generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New behavior follows what is described in the linked issue. That is: - adds endpoint that supports generation of secret for authentication method 'client-secret' - if confidential client is created with method 'client-secret' without secret, we generate it and return it - if confidential client is updated (PUT) with method 'client-secret' without secret, we generate it - if confidential client is patched with method 'client-secret' without secret, we generate it - if confidential client with 'client-secret' auth method is patched without auth config, we keep the previous (existing) authentication configuration including the secret - if confidential client with 'client-secret' auth method is updated or patched with auth object that has method 'client-secret', we only generate the secret if it wasn't previously present on the client model Closes: https://github.com/keycloak/keycloak/issues/46136 Signed-off-by: Michal Vavřík --- .../keycloak/it/cli/dist/OpenApiDistTest.java | 16 ++ .../models/mapper/BaseClientModelMapper.java | 19 ++ .../models/mapper/OIDCClientModelMapper.java | 25 +- .../services/client/DefaultClientService.java | 7 +- .../keycloak/admin/api/client/ClientApi.java | 16 ++ .../admin/api/client/DefaultClientApi.java | 16 ++ .../admin/client/v2/ClientApiV2Test.java | 241 ++++++++++++++++++ 7 files changed, 335 insertions(+), 5 deletions(-) diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java index e71093218aa9..88067b1150cb 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java @@ -32,6 +32,7 @@ import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.jupiter.api.Assertions.assertThrows; @DistributionTest(keepAlive = true, requestPort = 9000, containerExposedPorts = {8080, 9000}) @@ -106,6 +107,21 @@ void testOpenApiFilter(KeycloakDistribution distribution) { response .body("components.schemas.OIDCClientRepresentation.properties.protocol.type", equalTo("string")) // the generated discriminator field .body("paths.'/admin/api/{realmName}/clients/{version}'.get.responses.'200'.content.'application/json'.schema.type", equalTo("array")); + + assertGenerateSecretEndpointPath(response); + } + + private void assertGenerateSecretEndpointPath(ValidatableResponse response) { + String generateSecretPath = "paths.'/admin/api/{realmName}/clients/{version}/{id}/generate-secret'"; + + response + .body(generateSecretPath, notNullValue()) + .body(generateSecretPath + ".post", notNullValue()) + .body(generateSecretPath + ".post.summary", equalTo("Generates a new client secret")) + .body(generateSecretPath + ".post.responses.'200'", notNullValue()) + .body(generateSecretPath + ".post.responses.'400'", notNullValue()) + .body(generateSecretPath + ".post.responses.'403'", notNullValue()) + .body(generateSecretPath + ".post.responses.'404'", notNullValue()); } private void assertOpenAPISpecPolymorphicPaths(ValidatableResponse response, String schemaPath) { diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java index 7541efc518a4..b12754ac9d5b 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java @@ -8,6 +8,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; import org.keycloak.representations.admin.v2.BaseClientRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; /** * @author Vaclav Muzikar @@ -76,4 +77,22 @@ protected ClientModel createClientModel(BaseClientRepresentation rep) { @Override public void close() { } + + @SuppressWarnings("unchecked") + public ClientRepresentation createOldClientRepresentation(BaseClientRepresentation client) { + var basicRep = new ClientRepresentation(); + basicRep.setClientId(client.getClientId()); + basicRep.setProtocol(client.getProtocol()); + updateOldClientRepresentation((T) client, basicRep); + return basicRep; + } + + /** + * Allows to populate the old client representation with some values. + * This should be kept to minimum as we only want to maintain relationship between + * the model and the new representation. + */ + protected void updateOldClientRepresentation(T client, ClientRepresentation oldRepresentation) { + // implemented by subclasses when needed + } } diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapper.java index 4cf555dee06a..82476a2958a6 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapper.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapper.java @@ -5,10 +5,13 @@ import java.util.Set; import java.util.stream.Collectors; +import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RoleModel; import org.keycloak.representations.admin.v2.OIDCClientRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.utils.StringUtil; /** * @author Vaclav Muzikar @@ -44,7 +47,12 @@ protected void toModelSpecific(OIDCClientRepresentation rep, ClientModel model) if (rep.getAuth() != null) { model.setPublicClient(false); model.setClientAuthenticatorType(rep.getAuth().getMethod()); - model.setSecret(rep.getAuth().getSecret()); + + // if the current model has client secret and the new representation doesn't, do not regenerate the secret + boolean canSetSecret = StringUtil.isNotBlank(rep.getAuth().getSecret()) || !isClientSecret(rep.getAuth().getMethod()); + if (canSetSecret) { + model.setSecret(rep.getAuth().getSecret()); + } } else { model.setPublicClient(true); } @@ -91,4 +99,19 @@ private Set getServiceAccountRoles(ClientModel client) { } return Collections.emptySet(); } + + @Override + protected void updateOldClientRepresentation(OIDCClientRepresentation client, ClientRepresentation oldRepresentation) { + var auth = client.getAuth(); + if (auth != null && isClientSecret(auth.getMethod())) { + // this makes sure that client secret is generated for "create" methods if necessary + oldRepresentation.setPublicClient(false); + oldRepresentation.setClientAuthenticatorType(auth.getMethod()); + oldRepresentation.setSecret(auth.getSecret()); + } + } + + private static boolean isClientSecret(String method) { + return ClientIdAndSecretAuthenticator.PROVIDER_ID.equals(method); + } } diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java index 514b56e5a422..c0506215d020 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java @@ -14,12 +14,12 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; +import org.keycloak.models.mapper.BaseClientModelMapper; import org.keycloak.models.mapper.ClientModelMapper; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.admin.v2.BaseClientRepresentation; import org.keycloak.representations.admin.v2.OIDCClientRepresentation; import org.keycloak.representations.admin.v2.validation.CreateClientDefault; -import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.services.ServiceException; import org.keycloak.services.resources.admin.ClientResource; @@ -89,9 +89,8 @@ public CreateOrUpdateResult createOrUpdate(RealmModel realm, BaseClientRepresent // First, create a basic v1 representation to persist the client in the database. // We can't use mapper.toModel(client) directly for creation because the "detached model" - var basicRep = new ClientRepresentation(); - basicRep.setClientId(client.getClientId()); - basicRep.setProtocol(client.getProtocol()); + // TODO: avoid casting of the ClientModelMapper to BaseClientModelMapper + var basicRep = ((BaseClientModelMapper) mapper).createOldClientRepresentation(client); // Create the client in the database model = clientsResource.createClientModel(basicRep); diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java index bf284ee429ca..cc047a893673 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java @@ -5,7 +5,9 @@ import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -13,6 +15,9 @@ import org.keycloak.representations.admin.v2.BaseClientRepresentation; import com.fasterxml.jackson.databind.JsonNode; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import static org.keycloak.admin.api.AdminApi.CONTENT_TYPE_MERGE_PATCH; @@ -40,4 +45,15 @@ public interface ClientApi { @Produces(MediaType.APPLICATION_JSON) void deleteClient(); + @Path("generate-secret") + @POST + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Generates a new client secret", description = "Generates a new client secret for clients using client-secret authentication method. Updates the client with this new secret and returns it.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Success - returns the newly generated secret"), + @APIResponse(responseCode = "400", description = "Bad Request - client authentication method is not 'client-secret'"), + @APIResponse(responseCode = "403", description = "Forbidden"), + @APIResponse(responseCode = "404", description = "Not Found - client does not exist") + }) + String generateSecret(); } diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java index 42972055846f..94e78b20f7e6 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java @@ -14,6 +14,7 @@ import jakarta.ws.rs.core.Response; import org.keycloak.admin.api.AdminApi; +import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.representations.admin.v2.BaseClientRepresentation; @@ -110,6 +111,21 @@ public void deleteClient() { clientResource.deleteClient(); } + @Override + public String generateSecret() { + if (clientResource == null) { + throw new NotFoundException("Cannot find the specified client"); + } + var client = clientResource.getClient(); + if (client.isPublicClient()) { + throw new WebApplicationException("Secret generation is not supported for public clients", Response.Status.BAD_REQUEST); + } + if (!ClientIdAndSecretAuthenticator.PROVIDER_ID.equals(client.getClientAuthenticatorType())) { + throw new WebApplicationException("Secret generation is only supported for authentication method '%s', but got '%s'".formatted(ClientIdAndSecretAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType()), Response.Status.BAD_REQUEST); + } + return clientResource.regenerateSecret().getValue(); + } + static void validateUnknownFields(BaseClientRepresentation rep) { if (!rep.getAdditionalFields().keySet().isEmpty()) { throw new WebApplicationException("Payload contains unknown fields: " + rep.getAdditionalFields().keySet(), Response.Status.BAD_REQUEST); diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java index a0f3013823aa..8be554cb5789 100644 --- a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java @@ -17,6 +17,8 @@ package org.keycloak.tests.admin.client.v2; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Set; @@ -25,6 +27,8 @@ import org.keycloak.admin.api.AdminApi; import org.keycloak.admin.client.Keycloak; +import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; +import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator; import org.keycloak.common.Profile; import org.keycloak.representations.admin.v2.BaseClientRepresentation; import org.keycloak.representations.admin.v2.OIDCClientRepresentation; @@ -43,6 +47,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.apache.http.HttpMessage; import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpOptions; import org.apache.http.client.methods.HttpPatch; @@ -59,7 +64,11 @@ import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.emptyOrNullString; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; @KeycloakIntegrationTest(config = ClientApiV2Test.AdminV2Config.class) @@ -241,6 +250,10 @@ public void getClientsMixedProtocols() throws Exception { try (var response = client.execute(oidcRequest)) { assertEquals(201, response.getStatusLine().getStatusCode()); + + OIDCClientRepresentation representation = mapper.readValue(response.getEntity().getContent(), OIDCClientRepresentation.class); + assertThat(representation, notNullValue()); + assertThat("Public client must not have authentication configuration", representation.getAuth(), nullValue()); } // Create a SAML client with SAML-specific fields @@ -288,6 +301,7 @@ public void getClientsMixedProtocols() throws Exception { assertThat("OIDC client should be in the list", foundOidc, is(notNullValue())); assertThat(foundOidc.getLoginFlows(), is(Set.of(OIDCClientRepresentation.Flow.STANDARD, OIDCClientRepresentation.Flow.DIRECT_GRANT))); assertThat(foundOidc.getWebOrigins(), is(Set.of("http://localhost:3000", "http://localhost:4000"))); + assertThat("Public client must not have authentication configuration", foundOidc.getAuth(), nullValue()); // Verify SAML client with protocol-specific fields SAMLClientRepresentation foundSaml = clients.stream() @@ -617,6 +631,233 @@ public void preflight() throws Exception { } } + @Test + public void clientSecretGenerationInPostRequest() throws IOException { + String clientId = "secret-generation-post"; + HttpPost request = new HttpPost(getClientsApiUrl()); + setAuthHeader(request); + request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + OIDCClientRepresentation.Auth auth = new OIDCClientRepresentation.Auth(); + auth.setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID); + auth.setSecret(null); + + OIDCClientRepresentation.Auth createdAuth = getResultingAuthConfig(auth, request, clientId); + assertThat(createdAuth, notNullValue()); + assertThat(createdAuth.getSecret(), not(emptyOrNullString())); + + // make sure that the created model was persisted and GET method returns the newly generated secret + HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/" + clientId); + setAuthHeader(getRequest); + try (var response = client.execute(getRequest)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + OIDCClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class); + assertEquals(clientId, client.getClientId()); + assertThat(client.getAuth().getSecret(), not(emptyOrNullString())); + } + } + + @Test + public void clientSecretCanBeRegenerated() throws IOException { + String clientId = "client-secret-generation"; + + // anonymous user => 401 + HttpPost generateRequest = new HttpPost(getClientsApiUrl() + "/" + clientId + "/generate-secret"); + try (var response = client.execute(generateRequest)) { + assertEquals(401, response.getStatusLine().getStatusCode()); + } + + // insufficient permissions => 403 + setAuthHeader(generateRequest, noAccessAdminClient); + try (var response = client.execute(generateRequest)) { + assertEquals(403, response.getStatusLine().getStatusCode()); + } + + // client does not exist => 404 + setAuthHeader(generateRequest); + try (var response = client.execute(generateRequest)) { + assertEquals(404, response.getStatusLine().getStatusCode()); + } + + // create a public client + HttpPost createRequest = new HttpPost(getClientsApiUrl()); + createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + OIDCClientRepresentation.Auth createdAuth = getResultingAuthConfig(null, createRequest, clientId); + assertThat(createdAuth, nullValue()); + + // client exists, but it is a public client => 400 + try (var response = client.execute(generateRequest)) { + assertEquals(400, response.getStatusLine().getStatusCode()); + } + + // switch authentication method to jwt + OIDCClientRepresentation.Auth authWithSecret = new OIDCClientRepresentation.Auth(); + authWithSecret.setMethod(JWTClientAuthenticator.PROVIDER_ID); + authWithSecret.setSecret("m-n-o-p-q-r-9-8-7-6-5"); + HttpEntityEnclosingRequestBase request = new HttpPut(getClientsApiUrl() + "/" + clientId); + request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + OIDCClientRepresentation.Auth putAuth = getResultingAuthConfig(authWithSecret, request, clientId); + assertThat(putAuth, notNullValue()); + assertThat(putAuth.getMethod(), is(authWithSecret.getMethod())); + assertThat(putAuth.getSecret(), is(authWithSecret.getSecret())); + + // client exists, but it uses other method than the client secret => 400 + try (var response = client.execute(generateRequest)) { + assertEquals(400, response.getStatusLine().getStatusCode()); + } + + // switch authentication method to client secret + authWithSecret.setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID); + authWithSecret.setSecret("s-t-u-v-w-9-8-7-6-5"); + putAuth = getResultingAuthConfig(authWithSecret, request, clientId); + assertThat(putAuth, notNullValue()); + assertThat(putAuth.getMethod(), is(authWithSecret.getMethod())); + assertThat(putAuth.getSecret(), is(authWithSecret.getSecret())); + + String newSecret = null; + // client exists and is using the client secret => regenerate + try (var response = client.execute(generateRequest)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + newSecret = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + assertThat(newSecret, not(emptyOrNullString())); + assertThat(newSecret, not(is(authWithSecret.getSecret()))); + } + + // the generated secret should be stored on the client, verify it using the GET client operation + HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/" + clientId); + setAuthHeader(getRequest); + try (var response = client.execute(getRequest)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + OIDCClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class); + assertEquals(clientId, client.getClientId()); + assertThat(client.getAuth().getSecret(), is(newSecret)); + } + } + + @Test + public void clientSecretGenerationInPatchRequest() throws IOException { + String clientId = "secret-generation-patch"; + + // create public client + HttpEntityEnclosingRequestBase request = new HttpPost(getClientsApiUrl()); + request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + OIDCClientRepresentation.Auth createdAuth = getResultingAuthConfig(null, request, clientId); + assertThat(createdAuth, nullValue()); + + // patch to client secret authentication method and expect that client secret is generated + request = new HttpPatch(getClientsApiUrl() + "/" + clientId); + request.setHeader(HttpHeaders.CONTENT_TYPE, AdminApi.CONTENT_TYPE_MERGE_PATCH); + OIDCClientRepresentation.Auth authWithoutSecret = new OIDCClientRepresentation.Auth(); + authWithoutSecret.setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID); + authWithoutSecret.setSecret(null); + OIDCClientRepresentation.Auth patchedAuth = getResultingAuthConfig(authWithoutSecret, request, clientId); + assertThat(patchedAuth, notNullValue()); + String newlyGeneratedSecret = patchedAuth.getSecret(); + assertThat(newlyGeneratedSecret, not(emptyOrNullString())); + + // if we don't specify any auth, we still should keep the generated secret - that is behavior of the API v1; + // however, it also means that using patch to turn confidential client into public is not possible + patchedAuth = getResultingAuthConfig(null, request, clientId); + assertThat(patchedAuth, notNullValue()); + assertThat(patchedAuth.getMethod(), is(ClientIdAndSecretAuthenticator.PROVIDER_ID)); + assertThat(patchedAuth.getSecret(), is(newlyGeneratedSecret)); + + // if we specify client-secret auth method without secret, we should keep the secret + // instead of regenerating the secret because there is a dedicated endpoint used to re-generated the secret + patchedAuth = getResultingAuthConfig(authWithoutSecret, request, clientId); + assertThat(patchedAuth, notNullValue()); + assertThat(patchedAuth.getMethod(), is(ClientIdAndSecretAuthenticator.PROVIDER_ID)); + assertThat(patchedAuth.getSecret(), is(newlyGeneratedSecret)); + + // but it is still possible to patch a new secret value explicitly + OIDCClientRepresentation.Auth authWithSecret = new OIDCClientRepresentation.Auth(); + authWithSecret.setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID); + authWithSecret.setSecret("a-b-c-d-1-2-4-5"); + patchedAuth = getResultingAuthConfig(authWithSecret, request, clientId); + assertThat(patchedAuth, notNullValue()); + assertThat(patchedAuth.getMethod(), is(ClientIdAndSecretAuthenticator.PROVIDER_ID)); + assertThat(patchedAuth.getSecret(), is(authWithSecret.getSecret())); + } + + @Test + public void clientSecretGenerationInPutRequest() throws IOException { + // first create public client with PUT and then update it with client-secret method and expected generated secret + String clientId = "secret-generation-put-1"; + + // create public client + HttpEntityEnclosingRequestBase request = new HttpPut(getClientsApiUrl() + "/" + clientId); + request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + OIDCClientRepresentation.Auth putAuth = getResultingAuthConfig(null, request, clientId); + assertThat(putAuth, nullValue()); + + // now update it to confidential client with the client-secret method, but without any value, + // hence expect generated secret + OIDCClientRepresentation.Auth authWithoutSecret = new OIDCClientRepresentation.Auth(); + authWithoutSecret.setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID); + authWithoutSecret.setSecret(null); + putAuth = getResultingAuthConfig(authWithoutSecret, request, clientId); + assertThat(putAuth, notNullValue()); + assertThat(putAuth.getMethod(), is(ClientIdAndSecretAuthenticator.PROVIDER_ID)); + assertThat(putAuth.getSecret(), not(emptyOrNullString())); + + // now try to update secret with PUT method + OIDCClientRepresentation.Auth authWithSecret = new OIDCClientRepresentation.Auth(); + authWithSecret.setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID); + authWithSecret.setSecret("z-y-x-w-v-9-8-7-6-5"); + putAuth = getResultingAuthConfig(authWithSecret, request, clientId); + assertThat(putAuth, notNullValue()); + assertThat(putAuth.getMethod(), is(authWithSecret.getMethod())); + assertThat(putAuth.getSecret(), is(authWithSecret.getSecret())); + + // now try to update other fields, add the authentication configuration with the same method, hence expect + // that secret is still same; we don't want to "remove" the secret or regenerate it + putAuth = getResultingAuthConfig(authWithoutSecret, request, clientId); + assertThat(putAuth, notNullValue()); + assertThat(putAuth.getMethod(), is(authWithSecret.getMethod())); + // expect the previous secret value is still there + assertThat(putAuth.getSecret(), is(authWithSecret.getSecret())); + + // now try to update other fields, but do not specify the authentication configuration + // in which case we expect to turn the client into public one + putAuth = getResultingAuthConfig(null, request, clientId); + assertThat(putAuth, nullValue()); + + // create confidential client directly with PUT and expect that the secret was generated + clientId = "secret-generation-put-2"; + request = new HttpPut(getClientsApiUrl() + "/" + clientId); + request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + putAuth = getResultingAuthConfig(authWithoutSecret, request, clientId); + assertThat(putAuth, notNullValue()); + assertThat(putAuth.getMethod(), is(ClientIdAndSecretAuthenticator.PROVIDER_ID)); + assertThat(putAuth.getSecret(), not(emptyOrNullString())); + } + + private OIDCClientRepresentation.Auth getResultingAuthConfig(OIDCClientRepresentation.Auth auth, HttpEntityEnclosingRequestBase request, String clientId) throws IOException { + setAuthHeader(request); + + OIDCClientRepresentation rep = new OIDCClientRepresentation(); + rep.setEnabled(true); + rep.setClientId(clientId); + rep.setDescription("I'm OIDC Client"); + rep.setAuth(auth); + + request.setEntity(new StringEntity(mapper.writeValueAsString(rep))); + + try (var response = client.execute(request)) { + assertThat(response.getStatusLine().getStatusCode(), anyOf(is(201), is(200))); + OIDCClientRepresentation createdClient = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class); + assertThat(createdClient.getEnabled(), is(rep.getEnabled())); + assertThat(createdClient.getClientId(), is(rep.getClientId())); + assertThat(createdClient.getDescription(), is(rep.getDescription())); + + if (auth != null) { + assertThat(createdClient.getAuth(), notNullValue()); + assertThat(createdClient.getAuth().getMethod(), is(auth.getMethod())); + } + + return createdClient.getAuth(); + } + } + private OIDCClientRepresentation getTestingFullClientRep() { var rep = new OIDCClientRepresentation(); rep.setClientId("my-client");