8000 BACKPORT 26.6 Prevent Vault secret resolution and injection in DCR client secrets (… by jimmychakkalakal · Pull Request #54983 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions docs/documentation/server_admin/topics/vault.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ where the `key` is the name of the secret recognized by the vault.

To prevent secrets from leaking across realms, {project_name} combines the realm name with the `key` obtained from the vault expression. This method means that the `key` does not directly map to an entry in the vault but creates the final entry name according to the algorithm used to combine the `key` with the realm name. In case of the file-based vault, such combination reflects to a specific filename, for the Java KeyStore-based vault it's a specific alias name.

[IMPORTANT]
====
{project_name} isolates vault secrets between realms, but not between the components inside one realm. Any administrator who can enter a `${vault.key}` expression in one of the fields listed below can make {project_name} use the secret stored under that `key`, regardless of which client, identity provider, user federation provider, or SMTP configuration the secret was originally intended for. For example, an administrator who can manage identity providers but not clients can enter the vault expression of a client secret as the client secret of an identity provider and have {project_name} send that secret to the token URL configured for that identity provider.

To reduce this risk:

* Use a separate, unique `key` for each secret and do not reuse a `key` across components.
* Use keys that are hard to guess. Avoid predictable names such as `${vault.<client-id>.secret}` or `${vault.smtp_password}`.
* Grant roles that can edit vault-enabled fields, such as `manage-identity-providers`, `manage-clients`, and `manage-realm`, only to administrators who you trust with all secrets stored in the realm's vault.
* Do not rely on the secrecy of the vault `key` as an access control mechanism.
====

Clients that register through client registration endpoints without administrator privileges cannot set vault expressions, and the client installation (adapter configuration) output always returns the vault expression rather than the secret it resolves to.

You can obtain the secret from the vault in the following fields:

SMTP password::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,7 @@ public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
@Override
public Map<String, Object> getAdapterConfiguration(KeycloakSession session, ClientModel client) {
Map<String, Object> result = new HashMap<>();
String secret = client.getSecret();
result.put(CredentialRepresentation.SECRET, session.vault().getStringSecret(secret).get().orElse(secret));
result.put(CredentialRepresentation.SECRET, client.getSecret());
return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public Map<String, Object> getAdapterConfiguration(KeycloakSession session, Clie
// }
Map<String, Object> props = new HashMap<>();
String secret = client.getSecret();
props.put("secret", session.vault().getStringSecret(secret).get().orElse(secret));
props.put("secret", secret);
String algorithm = client.getAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG);
if (algorithm != null) {
props.put("algorithm", algorithm);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -93,6 +94,10 @@ public ClientRepresentation create(ClientRegistrationContext context) {
ClientPolicyEvent.REGISTER_NODE));
}

// Service accounts and authorization services are
// allowed during registration via grant types; escalation risk is guarded on client update.
validateUntrustedAttributes(client, null);

ClientModel clientModel = ClientManager.createClient(session, realm, client);

if (client.getDefaultRoles() != null) {
Expand Down Expand Up @@ -186,6 +191,7 @@ public ClientRepresentation update(String clientId, ClientRegistrationContext co
throw new ErrorResponseException(ErrorCodes.INVALID_CLIENT_METADATA, "Client Identifier modified", Response.Status.BAD_REQUEST);
}

validateUntrustedAttributes(rep, client);
ClientResource.updateClientServiceAccount(session, client, rep.isServiceAccountsEnabled());

try {
Expand Down Expand Up @@ -246,6 +252,40 @@ public ClientRepresentation update(String clientId, ClientRegistrationContext co
return rep;
}

private void validateUntrustedAttributes(ClientRepresentation rep, ClientModel existingClient) {
if (auth.isBearerToken()) {
// Admin callers retain authority to manage Vault expressions
return;
}

if (isVaultExpression(rep.getSecret()) && (existingClient == null
|| !Objects.equals(rep.getSecret(), existingClient.getSecret()))) {
throw new ErrorResponseException(
ErrorCodes.INVALID_CLIENT_METADATA,
"Vault expressions cannot be set via client registration",
Response.Status.BAD_REQUEST
);
}

if (rep.getAttributes() != null) {
for (Map.Entry<String, String> entry : rep.getAttributes().entrySet()) {
if (isVaultExpression(entry.getValue()) && (existingClient == null
|| !Objects.equals(entry.getValue(),
existingClient.getAttribute(entry.getKey())))) {
throw new ErrorResponseException(
ErrorCodes.INVALID_CLIENT_METADATA,
"Vault expressions cannot be set via client registration",
Response.Status.BAD_REQUEST
);
}
}
}
}

private boolean isVaultExpression(String secret) {
return secret != null && secret.startsWith("${vault.") && secret.endsWith("}");
}


public void delete(String clientId) {
event.event(EventType.CLIENT_DELETE).client(clientId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public AccessToken getJwt() {
return jwt;
}

private boolean isBearerToken() {
boolean isBearerToken() {
return jwt != null && TokenUtil.TOKEN_TYPE_BEARER.equals(jwt.getType());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;

@KeycloakIntegrationTest(config = ClientVaultTest.ClientVaultConfig.class)
class ClientVaultTest {
public class ClientVaultTest {

@InjectRealm(config = ClientVaultTest.ClientTestRealmConfig.class)
ManagedRealm managedRealm;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package org.keycloak.tests.client;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;

import org.keycloak.client.registration.Auth;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.client.registration.HttpErrorException;
import org.keycloak.models.ClientSecretConstants;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.adapters.config.AdapterConfig;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.testframework.annotations.InjectClient;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.realm.ClientConfig;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.ManagedClient;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

@KeycloakIntegrationTest(config = DcrVaultSecretDisclosureTest.ServerConfig.class)
public class DcrVaultSecretDisclosureTest extends AbstractClientRegistrationTest {

private static final String VAULT_EXPRESSION = "${vault.client_secret}";
private static final String VAULT_SECRET_VALUE = "mysecret";

@InjectRealm(config = ClientRegistrationRealmConfig.class)
ManagedRealm realm;

@InjectClient(config = DcrVaultClientConfig.class, lifecycle = LifeCycle.METHOD)
ManagedClient client;

// The registration access token is only exposed by the Admin API via regenerateRegistrationAccessToken().
// We refresh it once per test so all DCR calls in the test body use a valid token.
private ClientRepresentation clientWithRat;

@BeforeEach
public void refreshRegistrationAccessToken() {
clientWithRat = client.admin().regenerateRegistrationAccessToken();
}

@Test
public void testDcrUpdateRejectsVaultExpression() {
reg.auth(Auth.token(clientWithRat));

ClientRepresentation updateRep = new ClientRepresentation();
updateRep.setClientId(client.getClientId());
updateRep.setSecret(VAULT_EXPRESSION);

ClientRegistrationException ex = assertThrows(ClientRegistrationException.class, () -> reg.update(updateRep));
assertInstanceOf(HttpErrorException.class, ex.getCause());
assertEquals(400, ((HttpErrorException) ex.getCause()).getStatusLine().getStatusCode());
}

@ParameterizedTest
@ValueSource(strings = {"client-secret", "client-secret-jwt"})
public void testAdapterInstallationNeverResolvesVaultSecret(String authenticator) throws ClientRegistrationException {
// Configure client with vault expression directly via Admin API (simulating operator setup)
ClientRepresentation rep = client.admin().toRepresentation();
rep.setSecret(VAULT_EXPRESSION);
rep.setClientAuthenticatorType(authenticator);
if ("client-secret-jwt".equals(authenticator)) {
rep.setAttributes(Map.of(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, "HS256"));
}
client.admin().update(rep);

reg.auth(Auth.token(clientWithRat));

// Layer 2 assertion: Adapter config must return raw expression, never resolved plaintext vault secret
AdapterConfig config = reg.getAdapterConfig(client.getClientId());
assertNotNull(config);

if ("client-secret-jwt".equals(authenticator)) {
Map<?, ?> jwtCredentials = assertInstanceOf(Map.class, config.getCredentials().get("secret-jwt"));
assertEquals(VAULT_EXPRESSION, jwtCredentials.get("secret"));
} else {
assertEquals(VAULT_EXPRESSION, config.getCredentials().get("secret"));
}
}

@Test
public void testDcrUpdateRejectsVaultExpressionInRotatedSecretAttribute() {
reg.auth(Auth.token(clientWithRat));

ClientRepresentation updateRep = new ClientRepresentation();
updateRep.setClientId(client.getClientId());
updateRep.setAttributes(Map.of(ClientSecretConstants.CLIENT_ROTATED_SECRET, VAULT_EXPRESSION));

ClientRegistrationException ex = assertThrows(ClientRegistrationException.class, () -> reg.update(updateRep));
assertInstanceOf(HttpErrorException.class, ex.getCause());
assertEquals(400, ((HttpErrorException) ex.getCause()).getStatusLine().getStatusCode());
}

public static class ServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
try {
Path vaultDir = Files.createTempDirectory("kc-dcr-vault-test");
vaultDir.toFile().deleteOnExit();
// Keycloak REALM_UNDERSCORE_KEY format: <realmName>_<secretNameEscaped>
// Using default realm name that matches the test framework's realm creation
Files.writeString(vaultDir.resolve("master_client__secret"), VAULT_SECRET_VALUE);
return config.option("vault", "file").option("vault-dir", vaultDir.toAbsolutePath().toString());
} catch (Exception e) {
throw new RuntimeException("Failed to prepare ephemeral vault fixture directory", e);
}
}
}

public static class DcrVaultClientConfig implements ClientConfig {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
return client
.enabled(true)
.clientId("dcr-vault-test-client")
.secret("initial-literal-secret")
.publicClient(false)
.rootUrl("http://localhost");
}
}
}
Loading
0