Stop storing client private keys in the database - #51612
Conversation
There was a problem hiding this comment.
Pull request overview
Stops storing client private keys server-side while deprecating server-managed key generation.
Changes:
- Persists only public certificates/keys and sanitizes key-info responses.
- Deprecates key generation and download APIs.
- Updates admin UI, tests, installation templates, and documentation.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
tests/base/.../CredentialsTest.java |
Updates certificate persistence tests. |
services/.../CertificateInfoHelper.java |
Omits private keys from responses. |
services/.../ClientAttributeCertificateResource.java |
Sanitizes storage and deprecates endpoints. |
services/.../SamlProtocolFactory.java |
Stops storing generated SAML private keys. |
services/.../KeycloakSamlSubsystemCliInstallation.java |
Updates missing-key guidance. |
services/.../KeycloakSamlClientInstallation.java |
Updates installation placeholders. |
js/libs/.../clients.ts |
Deprecates admin-client methods. |
js/apps/.../SamlKeysDialog.tsx |
Downloads generated keys once. |
js/apps/.../SamlKeys.tsx |
Refreshes public key information. |
js/apps/.../messages_en.properties |
Clarifies private-key handling. |
integration/.../ClientAttributeCertificateResource.java |
Deprecates Java admin APIs. |
docs/.../proc-creating-saml-client.adoc |
Documents SAML key ownership. |
docs/.../con-confidential-client-credentials.adoc |
Adds external generation and JWKS guidance. |
Suppressed comments (4)
services/src/main/java/org/keycloak/services/resources/admin/ClientAttributeCertificateResource.java:186
- Although the client attributes receive
infoForStorage, the original upload representation (including an extracted private key) is still written to the admin event at line 188. Use the sanitized representation for the event as well so keystore uploads do not persist private keys in event storage.
CertificateRepresentation infoForStorage = new CertificateRepresentation();
infoForStorage.setCertificate(info.getCertificate());
infoForStorage.setPublicKey(info.getPublicKey());
infoForStorage.setKid(info.getKid());
CertificateInfoHelper.updateClientModelCertificateInfo(client, infoForStorage, attributePrefix);
js/libs/keycloak-admin-client/src/resources/clients.ts:1015
- The newly deprecated method is still called without suppression in
ExportSamlKeyDialog.tsx:36andclients.spec.ts:980. With the workspace'sno-deprecatedrule, those callers cause lint errors.
/**
* @deprecated Private keys are no longer stored on the server. The downloaded
* keystore will only contain the certificate.
*/
js/libs/keycloak-admin-client/src/resources/clients.ts:1032
Keys.tsx:87andclients.spec.ts:955still call this newly deprecated method without suppressing@typescript-eslint/no-deprecated, so the strict TypeScript lint configuration will reject them.
/**
* @deprecated Clients should generate their own key pairs and upload only the
* public key/certificate.
*/
tests/base/src/test/java/org/keycloak/tests/admin/client/CredentialsTest.java:147
getKeyInfo()now strips private keys independently of storage, so this assertion would pass even ifgenerate()still persisted the private-key attribute. Verify the underlying client model/database attribute directly to cover the security requirement.
// But server should not have stored it
CertificateRepresentation certFromGet = certRsc.getKeyInfo();
assertNull(certFromGet.getPrivateKey(), "private key should not be stored on server");
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Unreported flaky test detectedIf the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR. org.keycloak.testsuite.webauthn.WebAuthnPropertyTest#timeout |
5af0811 to
755617e
Compare
755617e to
9237ba5
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (4)
services/src/main/java/org/keycloak/services/resources/admin/ClientAttributeCertificateResource.java:204
- This Javadoc still says the download contains a private key, contradicting the newly updated operation description and the implementation, which now obtains a public-only representation. Update it to state that the keystore contains only the public certificate.
* @deprecated Clients should manage their own private keys.
js/apps/admin-ui/src/clients/keys/ExportSamlKeyDialog.tsx:37
- This action is now misleading because
downloadKeyreturns a certificate-only keystore, while the dialog is still titled “Export SAML Keys” andKeyFormrequires a key password described as the private-key password. Operators may mistake this export for a private-key backup; rename it as certificate export and remove the key-password field, or remove the deprecated action.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const keyStore = await adminClient.clients.downloadKey(
integration/admin-client-core/src/main/java/org/keycloak/admin/client/resource/ClientAttributeCertificateResource.java:90
- The new deprecation text conflicts with the method description above, which still promises a keystore containing the private key. Update the public admin-client Javadoc to state that only the certificate is returned so callers do not treat this endpoint as private-key recovery.
* @deprecated Clients should manage their own private keys.
services/src/main/java/org/keycloak/services/util/CertificateInfoHelper.java:173
- The updated JWKS upload test uses a public-only fixture, so it does not exercise the security behavior added here. Add coverage with private RSA/EC (and ideally symmetric/OKP) parameters that verifies both the stored JWKS and
getKeyInfo()output omit every entry in this set.
private static final Set<String> JWK_PRIVATE_KEY_PARAMS = Set.of("d", "p", "q", "dp", "dq", "qi", "oth", "k");
public static String stripPrivateKeyParams(String jwks) {
60009a1 to
36fc63c
Compare
The generate endpoint stored client private keys as clear-text client attributes. The server never uses these keys for any cryptographic operation — it only needs the public key/certificate to verify signature. This change:
- Stops persisting private keys in generate, upload,
and SAML client creation endpoints
- Strips private keys from getKeyInfo responses
- Deprecates the generate, download, and
generate-and-download endpoints
- Updates admin UI to reflect that private keys are
downloaded once and not stored
- Updates documentation with guidance to generate keys
externally and examples for converting keystores to
JWK format for JWKS_URL usage
Closes keycloak#49239
Signed-off-by: Jimmy Chakkalakal <jimmy.chakkalakal@ibm.com>
36fc63c to
180ce8c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 3 comments.
Suppressed comments (2)
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java:557
- This assertion verifies equivalence after re-serialization, but it doesn’t validate the new security behavior (that private JWK parameters are removed before storing). Add an assertion here that the stored
JWKS_STRING(or the parsedactualkeys) does not contain private parameters liked/p/q/dp/dq/qi/oth/k, so the test directly covers the private-key stripping requirement.
JSONWebKeySet expected = JsonSerialization.readValue(jwks, JSONWebKeySet.class);
JSONWebKeySet actual = JsonSerialization.readValue(client.getAttributes().get(OIDCConfigAttributes.JWKS_STRING), JSONWebKeySet.class);
Assertions.assertEquals(JsonSerialization.writeValueAsString(expected), JsonSerialization.writeValueAsString(actual));
tests/base/src/test/java/org/keycloak/tests/admin/client/CredentialsTest.java:156
adminEvents.poll()can return null, which would cause a NullPointerException atevent.getRepresentation()and produce a less actionable test failure. Add an explicitassertNotNull(event, ...)(and potentiallyassertNotNull(event.getRepresentation(), ...)) before accessing it so failures clearly indicate the missing admin event.
AdminEventRepresentation event = adminEvents.poll();
assertFalse(event.getRepresentation().contains(cert.getPrivateKey()), "admin event should not contain private key");
CertificateRepresentation expectedEventRep = new CertificateRepresentation();
expectedEventRep.setCertificate(cert.getCertificate());
AdminEventAssertion.assertEvent(event, OperationType.ACTION, AdminEventPaths.clientCertificateGenerateSecretPath(accountClient.toRepresentation().getId(), "jwt.credential"), expectedEventRep, ResourceType.CLIENT);
| if (OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol()) && info.getJwks() != null) { | ||
| CertificateInfoHelper.updateClientModelJwksString(client, attributePrefix, info.getJwks()); | ||
| } else { | ||
| CertificateInfoHelper.updateClientModelCertificateInfo(client, info, attributePrefix); | ||
| CertificateRepresentation infoForStorage = new CertificateRepresentation(); | ||
| infoForStorage.setCertificate(info.getCertificate()); | ||
| infoForStorage.setPublicKey(info.getPublicKey()); | ||
| infoForStorage.setKid(info.getKid()); | ||
| CertificateInfoHelper.updateClientModelCertificateInfo(client, infoForStorage, attributePrefix); | ||
| } | ||
| adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(info).success(); | ||
| CertificateRepresentation sanitized = new CertificateRepresentation(); | ||
| sanitized.setCertificate(info.getCertificate()); | ||
| sanitized.setPublicKey(info.getPublicKey()); | ||
| sanitized.setKid(info.getKid()); | ||
| sanitized.setJwks(info.getJwks()); | ||
| adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(sanitized).success(); |
| private static final Set<String> JWK_PRIVATE_KEY_PARAMS = Set.of("d", "p", "q", "dp", "dq", "qi", "oth", "k"); | ||
|
|
||
| public static String stripPrivateKeyParams(String jwks) { | ||
| try { | ||
| JSONWebKeySet keySet = JsonSerialization.readValue(jwks, JSONWebKeySet.class); | ||
| if (keySet != null && keySet.getKeys() != null) { | ||
| for (JWK key : keySet.getKeys()) { | ||
| key.getOtherClaims().keySet().removeAll(JWK_PRIVATE_KEY_PARAMS); | ||
| } | ||
| return JsonSerialization.writeValueAsPrettyString(keySet); | ||
| } | ||
| return jwks; | ||
| } catch (IOException e) { | ||
| logger.warn("Failed to parse JWKS for private key stripping, storing as-is", e); | ||
| return jwks; | ||
| } | ||
| } |
| } catch (IOException e) { | ||
| logger.warn("Failed to parse JWKS for private key stripping, storing as-is", e); | ||
| return jwks; | ||
| } |
The generate endpoint stored client private keys as clear-text client attributes. The server never uses these keys for any cryptographic operation — it only needs the public key/certificate to verify signature. This change:
Closes #49239