Merge branch 'main' into issues/46204-update-db-schema-and-admin-REST-Api

This commit is contained in:
Marek Posolda 2026-05-25 08:34:57 +02:00 committed by GitHub
commit 91aca4b36d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1311 additions and 567 deletions

View file

@ -42,7 +42,7 @@
},
"devDependencies": {
"@keycloak/keycloak-admin-client": "workspace:*",
"@playwright/test": "^1.57.0",
"@playwright/test": "^1.60.0",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",

View file

@ -113,7 +113,7 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.11.0",
"@playwright/test": "^1.57.0",
"@playwright/test": "^1.60.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",

View file

@ -181,8 +181,7 @@ paths:
- Clients (v2)
parameters:
- description: "Set of fields to include in the response. Must be top-level\
\ fields on one of the client types. If omitted or empty, all fields will\
\ be populated."
\ fields. If omitted or empty, all fields will be populated."
name: fields
in: query
schema:

View file

@ -125,8 +125,8 @@ importers:
specifier: workspace:*
version: link:../../libs/keycloak-admin-client
'@playwright/test':
specifier: ^1.57.0
version: 1.57.0
specifier: ^1.60.0
version: 1.60.0
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
@ -232,10 +232,10 @@ importers:
devDependencies:
'@axe-core/playwright':
specifier: ^4.11.0
version: 4.11.0(playwright-core@1.57.0)
version: 4.11.0(playwright-core@1.60.0)
'@playwright/test':
specifier: ^1.57.0
version: 1.57.0
specifier: ^1.60.0
version: 1.60.0
'@testing-library/dom':
specifier: ^10.4.1
version: 10.4.1
@ -1206,8 +1206,8 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.57.0':
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
hasBin: true
@ -2930,8 +2930,8 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
@ -3763,13 +3763,13 @@ packages:
pkg-types@2.2.0:
resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==}
playwright-core@1.57.0:
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.57.0:
resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
@ -4841,10 +4841,10 @@ snapshots:
'@ast-grep/napi-win32-ia32-msvc': 0.36.3
'@ast-grep/napi-win32-x64-msvc': 0.36.3
'@axe-core/playwright@4.11.0(playwright-core@1.57.0)':
'@axe-core/playwright@4.11.0(playwright-core@1.60.0)':
dependencies:
axe-core: 4.11.0
playwright-core: 1.57.0
playwright-core: 1.60.0
'@babel/code-frame@7.27.1':
dependencies:
@ -5538,9 +5538,9 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@playwright/test@1.57.0':
'@playwright/test@1.60.0':
dependencies:
playwright: 1.57.0
playwright: 1.60.0
'@reactflow/background@11.3.14(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -7379,7 +7379,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
get-tsconfig@4.13.7:
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
optional: true
@ -8262,11 +8262,11 @@ snapshots:
exsolve: 1.0.7
pathe: 2.0.3
playwright-core@1.57.0: {}
playwright-core@1.60.0: {}
playwright@1.57.0:
playwright@1.60.0:
dependencies:
playwright-core: 1.57.0
playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
@ -9000,7 +9000,7 @@ snapshots:
tsx@4.21.0:
dependencies:
esbuild: 0.27.7
get-tsconfig: 4.13.7
get-tsconfig: 4.14.0
optionalDependencies:
fsevents: 2.3.3
optional: true

View file

@ -215,7 +215,7 @@ public class KeycloakController implements Reconciler<Keycloak> {
public void updateStatus(Keycloak keycloakCR, StatefulSet existingDeployment, KeycloakStatusAggregator status, Context<Keycloak> context) {
status.apply(b -> b.withSelector(Utils.toSelectorString(Utils.allInstanceLabels(keycloakCR))));
validatePodTemplate(keycloakCR, status);
validatePodTemplate(keycloakCR, status, context);
if (existingDeployment == null) {
status.addNotReadyMessage("No existing StatefulSet found, waiting for creating a new one");
return;
@ -251,6 +251,11 @@ public class KeycloakController implements Reconciler<Keycloak> {
.ifPresent(status::addWarningMessage);
}
static boolean isMultiNamespace(Context<?> context) {
var config = context.getControllerConfiguration().getInformerConfig();
return config.watchAllNamespaces() || config.getNamespaces().size() > 1;
}
public static boolean isRolling(StatefulSet existingDeployment) {
return existingDeployment.getStatus() != null
&& existingDeployment.getStatus().getCurrentRevision() != null
@ -258,7 +263,7 @@ public class KeycloakController implements Reconciler<Keycloak> {
&& !existingDeployment.getStatus().getCurrentRevision().equals(existingDeployment.getStatus().getUpdateRevision());
}
public void validatePodTemplate(Keycloak keycloakCR, KeycloakStatusAggregator status) {
public void validatePodTemplate(Keycloak keycloakCR, KeycloakStatusAggregator status, Context<Keycloak> context) {
var spec = KeycloakDeploymentDependentResource.getPodTemplateSpec(keycloakCR);
if (spec.isEmpty()) {
return;
@ -274,7 +279,8 @@ public class KeycloakController implements Reconciler<Keycloak> {
}
}
Optional.ofNullable(overlayTemplate.getSpec()).map(PodSpec::getContainers).flatMap(l -> l.stream().findFirst())
Optional<PodSpec> templateSpec = Optional.ofNullable(overlayTemplate.getSpec());
templateSpec.map(PodSpec::getContainers).flatMap(l -> l.stream().findFirst())
.ifPresent(container -> {
if (container.getName() != null) {
status.addWarningMessage("The name of the keycloak container cannot be modified");
@ -288,10 +294,14 @@ public class KeycloakController implements Reconciler<Keycloak> {
}
});
if (overlayTemplate.getSpec() != null &&
CollectionUtil.isNotEmpty(overlayTemplate.getSpec().getImagePullSecrets())) {
status.addWarningMessage("The imagePullSecrets of the keycloak container cannot be modified using podTemplate");
}
templateSpec.ifPresent(ts -> {
if (CollectionUtil.isNotEmpty(ts.getImagePullSecrets())) {
status.addWarningMessage("The imagePullSecrets of the keycloak container cannot be modified using podTemplate");
}
if (isMultiNamespace(context) && Optional.ofNullable(ts.getServiceAccount()).orElse(ts.getServiceAccountName()) != null) {
status.addWarningMessage("The serviceAccountName cannot be set in a multi-namespace install mode");
}
});
}
private void checkForPodErrors(KeycloakStatusAggregator status, Keycloak keycloak, StatefulSet existingDeployment, Context<Keycloak> context) {

View file

@ -313,6 +313,11 @@ public class KeycloakDeploymentDependentResource extends VersionTolerantCRUDKube
.endTemplate()
.withReplicas(keycloakCR.getSpec().getInstances())
.endSpec();
if (KeycloakController.isMultiNamespace(context)) {
baseDeploymentBuilder = baseDeploymentBuilder.editSpec().editTemplate().editSpec().withServiceAccount(null)
.withServiceAccountName(null).endSpec().endTemplate().endSpec();
}
var specBuilder = baseDeploymentBuilder.editSpec().editTemplate().editOrNewSpec();

View file

@ -19,7 +19,10 @@ package org.keycloak.operator.testsuite.unit;
import org.keycloak.operator.controllers.KeycloakController;
import org.keycloak.operator.crds.v2beta1.deployment.Keycloak;
import org.keycloak.operator.crds.v2beta1.deployment.KeycloakBuilder;
import org.keycloak.operator.crds.v2beta1.deployment.KeycloakStatusAggregator;
import org.keycloak.operator.crds.v2beta1.deployment.spec.IngressSpecBuilder;
import org.keycloak.operator.testsuite.utils.CRAssert;
import org.keycloak.operator.testsuite.utils.K8sUtils;
import io.fabric8.kubernetes.client.dsl.Resource;
@ -67,5 +70,25 @@ class KeycloakControllerTest {
assertEquals(1, update.getResource().orElseThrow().getSpec().getInstances());
assertNull(update.getResource().orElseThrow().getSpec().getHostnameSpec().getHostname());
}
@Test
void testUpdateStatus() {
KeycloakController controller = new KeycloakController();
Keycloak kc = K8sUtils.getDefaultKeycloakDeployment();
kc = new KeycloakBuilder(kc).editSpec().withNewUnsupported().withNewPodTemplate().withNewSpec()
.withServiceAccountName("foo").endSpec().endPodTemplate().endUnsupported().endSpec().build();
Context<Keycloak> mockContext = Mockito.mock(Context.class, Mockito.RETURNS_DEEP_STUBS);
KeycloakStatusAggregator agg = new KeycloakStatusAggregator(null, 1L);
controller.updateStatus(kc, null, agg, mockContext);
CRAssert.assertKeycloakStatusCondition(agg.build(), "HasErrors", false, null, 1L).extracting("message").isEqualTo("");
Mockito.when(mockContext.getControllerConfiguration().getInformerConfig().watchAllNamespaces()).thenReturn(true);
agg = new KeycloakStatusAggregator(null, 1L);
controller.updateStatus(kc, null, agg, mockContext);
CRAssert.assertKeycloakStatusCondition(agg.build(), "HasErrors", false, "The serviceAccountName cannot be set in a multi-namespace install mode");
}
}

View file

@ -20,6 +20,7 @@ package org.keycloak.operator.testsuite.unit;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
@ -71,6 +72,7 @@ import io.fabric8.kubernetes.api.model.apps.StatefulSetSpec;
import io.fabric8.kubernetes.api.model.batch.v1.Job;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.utils.Serialization;
import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext;
import io.quarkus.test.InjectMock;
@ -111,10 +113,15 @@ public class PodTemplateTest {
@Inject
KeycloakRealmImportJobDependentResource importJobResource;
Context context;
@BeforeEach
protected void setup() {
this.deployment = new KeycloakDeploymentDependentResource();
context = Mockito.mock(Context.class, Mockito.RETURNS_DEEP_STUBS);
Mockito.when(context.getClient()).thenReturn(Mockito.mock(KubernetesClient.class));
Mockito.when(context.getControllerConfiguration().getInformerConfig()).thenReturn(Mockito.mock(InformerConfiguration.class));
}
private StatefulSet getDeployment(PodTemplateSpec podTemplate, StatefulSet existingDeployment, Consumer<KeycloakSpecBuilder> additionalSpec) {
@ -125,7 +132,7 @@ public class PodTemplateTest {
.endSelector().endSpec().build();
//noinspection unchecked
Context context = mockContext(null);
mockContext(null);
return deployment.initialDesired(kc, context);
}
@ -147,16 +154,13 @@ public class PodTemplateTest {
return kc;
}
private Context<Keycloak> mockContext(StatefulSet existingDeployment) {
Context<Keycloak> context = Mockito.mock(Context.class);
private void mockContext(StatefulSet existingDeployment) {
ManagedWorkflowAndDependentResourceContext managedWorkflowAndDependentResourceContext = Mockito.mock(ManagedWorkflowAndDependentResourceContext.class);
Mockito.when(context.managedWorkflowAndDependentResourceContext()).thenReturn(managedWorkflowAndDependentResourceContext);
Mockito.when(managedWorkflowAndDependentResourceContext.get(OLD_DEPLOYMENT_KEY, StatefulSet.class)).thenReturn(Optional.ofNullable(existingDeployment));
Mockito.when(managedWorkflowAndDependentResourceContext.getMandatory(OPERATOR_CONFIG_KEY, Config.class)).thenReturn(operatorConfig);
Mockito.when(managedWorkflowAndDependentResourceContext.getMandatory(WATCHED_RESOURCES_KEY, WatchedResources.class)).thenReturn(watchedResources);
Mockito.when(managedWorkflowAndDependentResourceContext.getMandatory(DIST_CONFIGURATOR_KEY, KeycloakDistConfigurator.class)).thenReturn(distConfigurator);
Mockito.when(context.getClient()).thenReturn(Mockito.mock(KubernetesClient.class));
return context;
}
private StatefulSet getDeployment(PodTemplateSpec podTemplate, StatefulSet existingDeployment) {
@ -404,6 +408,51 @@ public class PodTemplateTest {
// Assert
assertThat(podTemplate.getMetadata().getAnnotations()).containsEntry("two", "2");
}
@Test
public void testServiceAccountName() {
// in a single namespace, we'll still allow setting via the template
// Arrange
var additionalPodTemplate = new PodTemplateSpecBuilder()
.withNewSpec()
.withServiceAccount("foo")
.endSpec()
.build();
// Act
var podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate();
// Assert
assertThat(podTemplate.getSpec().getServiceAccount()).isEqualTo("foo");
// in multinamespace we won't
Mockito.when(context.getControllerConfiguration().getInformerConfig().watchAllNamespaces()).thenReturn(true);
// Act
podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate();
// Assert
assertThat(podTemplate.getSpec().getServiceAccount()).isNull();
// test again with serviceAccountName
additionalPodTemplate = new PodTemplateSpecBuilder()
.withNewSpec()
.withServiceAccountName("bar")
.endSpec()
.build();
Mockito.when(context.getControllerConfiguration().getInformerConfig().watchAllNamespaces()).thenReturn(false);
Mockito.when(context.getControllerConfiguration().getInformerConfig().getNamespaces()).thenReturn(Set.of("one", "two"));
// Act
podTemplate = getDeployment(additionalPodTemplate).getSpec().getTemplate();
// Assert
assertThat(podTemplate.getSpec().getServiceAccountName()).isNull();
}
@Test
public void testHttpManagment() {
@ -789,7 +838,7 @@ public class PodTemplateTest {
StatefulSetBuilder desired = getDeployment(null, existingStatefulSet, newSpec).toBuilder();
// setup the mock context
Context<Keycloak> context = mockContext(null);
mockContext(null);
var managedWorkflowAndDependentResourceContext = context.managedWorkflowAndDependentResourceContext();
Mockito.when(managedWorkflowAndDependentResourceContext.get(OLD_DEPLOYMENT_KEY, StatefulSet.class)).thenReturn(Optional.of(existingStatefulSet));
Mockito.when(managedWorkflowAndDependentResourceContext.getMandatory(NEW_DEPLOYMENT_KEY, StatefulSet.class)).thenReturn(desired.build());
@ -802,7 +851,7 @@ public class PodTemplateTest {
existingModifier.accept(existingBuilder);
StatefulSet existingStatefulSet = existingBuilder.build();
Context context = mockContext(existingStatefulSet);
mockContext(existingStatefulSet);
var kc = createKeycloak(null, keycloakSpec);
Mockito.when(context.managedWorkflowAndDependentResourceContext().getMandatory(ContextUtils.KEYCLOAK, Keycloak.class)).thenReturn(kc);
@ -893,7 +942,7 @@ public class PodTemplateTest {
assertNull(job.getSpec().getTemplate().getSpec().getInitContainers().get(0).getLifecycle());
assertNull(job.getSpec().getTemplate().getSpec().getInitContainers().get(0).getRestartPolicy());
}
@Test
public void testEnvVars() {
var statefulSet = getDeployment(null, null, builder -> builder.addNewEnv("JAVA_OPTS", "my opts")

View file

@ -2,17 +2,10 @@ package org.keycloak.config;
import java.io.File;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Stream;
import org.keycloak.config.database.Database;
import static org.keycloak.config.OptionsUtil.DURATION_DESCRIPTION;
import static org.keycloak.config.WildcardOptionsUtil.getWildcardNamedKey;
public class DatabaseOptions {
@ -27,12 +20,21 @@ public class DatabaseOptions {
.description("The fully qualified class name of the JDBC driver. If not set, a default driver is set accordingly to the chosen database.")
.buildTime(true)
.build();
public static final Option<String> DB_KIND = new OptionBuilder<>("db-kind-<datasource>", String.class)
.category(OptionCategory.DATABASE_DATASOURCES)
.description("Used for named <datasource>. The database vendor.")
.expectedValues(Database.getDatabaseAliases())
.connectedOptions(TransactionOptions.TRANSACTION_XA_ENABLED_DATASOURCE)
.buildTime(true)
.build();
public static final Option<String> DB = new OptionBuilder<>("db", String.class)
.category(OptionCategory.DATABASE)
.description("The database vendor. In production mode the default value of 'dev-file' is deprecated, you should explicitly specify the db instead.")
.defaultValue("dev-file")
.expectedValues(Database.getDatabaseAliases())
.wildcardKey(DB_KIND.getKey())
.buildTime(true)
.build();
@ -40,6 +42,7 @@ public class DatabaseOptions {
.category(OptionCategory.DATABASE)
.description("The full database JDBC URL. If not provided, a default URL is set based on the selected database vendor. " +
"For instance, if using 'postgres', the default JDBC URL would be 'jdbc:postgresql://localhost/keycloak'. ")
.wildcardKey("db-url-full-<datasource>")
.build();
public static final Option<String> DB_URL_HOST = new OptionBuilder<>("db-url-host", String.class)
@ -165,57 +168,7 @@ public class DatabaseOptions {
.description("The type of the keystore file. Common values include 'JKS' (Java KeyStore) and 'PKCS12'. If not specified, it uses the driver's default.")
.build();
public static final class Datasources {
/**
* Options that have their sibling for a named datasource
* Example: for `db-dialect`, `db-dialect-<datasource>` is created
*/
public static final List<String> OPTIONS_DATASOURCES = Stream.of(
DB_CONNECT_TIMEOUT,
DB_DIALECT,
DB_DRIVER,
DB,
DB_URL,
DB_URL_HOST,
DB_URL_DATABASE,
DB_URL_PORT,
DB_URL_PROPERTIES,
DB_USERNAME,
DB_PASSWORD,
DB_SCHEMA,
DB_POOL_INITIAL_SIZE,
DB_POOL_MIN_SIZE,
DB_POOL_MAX_SIZE,
DB_SQL_JPA_DEBUG,
DB_SQL_LOG_SLOW_QUERIES,
DB_TLS_MODE,
DB_TLS_TRUST_STORE_FILE,
DB_TLS_TRUST_STORE_PASSWORD,
DB_TLS_TRUST_STORE_TYPE,
DB_MTLS_KEY_STORE_FILE,
DB_MTLS_KEY_STORE_PASSWORD,
DB_MTLS_KEY_STORE_TYPE
).map(Option::getKey).toList();
/**
* In order to avoid ambiguity, we need to have unique option names for wildcard options.
* This map controls overriding option name to be unique for wildcard option.
*/
private static final Map<String, String> DATASOURCES_OVERRIDES_SUFFIX = Map.of(
DatabaseOptions.DB.getKey(), "-kind", // db-kind
DatabaseOptions.DB_URL.getKey(), "-full" // db-url-full
);
/**
* You can override some {@link OptionBuilder} methods for additional datasources in this map
*/
private static final Map<Option<?>, Consumer<OptionBuilder<?>>> DATASOURCES_OVERRIDES_OPTIONS = Map.of(
DatabaseOptions.DB, builder -> builder
.defaultValue(Optional.empty()) // no default value for DB kind for datasources
.connectedOptions(TransactionOptions.TRANSACTION_XA_ENABLED_DATASOURCE)
);
private static final Map<String, Option<?>> cachedDatasourceOptions = new HashMap<>();
public static class Datasources {
/**
* Get datasource option containing named datasource mapped to parent DB options.
@ -224,72 +177,22 @@ public class DatabaseOptions {
* <ul>
* <li>{@code db-url-host --> db-url-host-<datasource>}</li>
* <li>{@code db-username --> db-username-<datasource>}</li>
* <li>{@code db --> db-kind-<datasource>}</li>
* </ul>
*/
@SuppressWarnings("unchecked")
public static <T> Optional<Option<T>> getDatasourceOption(Option<T> parentOption) {
if (!OPTIONS_DATASOURCES.contains(parentOption.getKey())) {
return Optional.empty();
protected static <T> Option<T> getDatasourceOption(Option<T> parentOption) {
var key = parentOption.getWildcardKey().orElse(parentOption.getKey().concat("-<datasource>"));
var builder = parentOption.toBuilder()
.key(key)
.category(OptionCategory.DATABASE_DATASOURCES);
if (!parentOption.isHidden()) {
builder.description("Used for named <datasource>. " + parentOption.getDescription());
}
var key = getKeyForDatasource(parentOption);
if (key.isEmpty()) {
return Optional.empty();
}
// check if we already created the same option and return it from the cache
Option<?> option = cachedDatasourceOptions.get(key.get());
if (option == null) {
var builder = parentOption.toBuilder()
.key(key.get())
.category(OptionCategory.DATABASE_DATASOURCES);
if (!parentOption.isHidden()) {
builder.description("Used for named <datasource>. " + parentOption.getDescription());
}
// override some settings for options
var override = DATASOURCES_OVERRIDES_OPTIONS.get(parentOption);
if (override != null) {
override.accept(builder);
}
option = builder.build();
parentOption.setWildcardKey(option.getKey());
cachedDatasourceOptions.put(key.get(), option);
}
return Optional.of((Option<T>) option);
}
/**
* Get mapped datasource key based on DB option {@param option}
*/
public static Optional<String> getKeyForDatasource(Option<?> option) {
return getKeyForDatasource(option.getKey());
}
/**
* Get mapped datasource key based on DB option {@param option}
*/
public static Optional<String> getKeyForDatasource(String option) {
return Optional.of(option)
.filter(OPTIONS_DATASOURCES::contains)
.map(key -> key.concat(DATASOURCES_OVERRIDES_SUFFIX.getOrDefault(key, "")))
.map(key -> key.concat("-<datasource>"));
}
/**
* Returns datasource option based on DB option {@code option} with actual wildcard value.
* It replaces the {@code <datasource>} with actual value in {@code namedProperty}.
* <p>
* f.e. Consider {@code option}={@link DatabaseOptions#DB_DRIVER}, and {@code namedProperty}=my-store.
* <p>
* Result: {@code db-driver-my-store}
*/
public static Optional<String> getNamedKey(Option<?> option, String namedProperty) {
return getKeyForDatasource(option).map(key -> getWildcardNamedKey(key, namedProperty));
Option<?> option = builder.build();
parentOption.setWildcardKey(option.getKey());
return (Option<T>)option;
}
}

View file

@ -75,27 +75,4 @@ public class WildcardOptionsUtil {
return prefix != null ? prefix.concat(value) : null;
}
/**
* Extracts the name that replaces the wildcard placeholder from a fully qualified configuration key.
* <p>
* Examples:
* <pre>{@code
* getWildcardValue(TracingOptions.TRACING_HEADER, "tracing-header-Authorization") "Authorization"
* getWildcardValue(DatabaseOptions.DB_ENABLED_DATASOURCE, "db-enabled-my-store") "my-store"
* getWildcardValue(DatabaseOptions.DB_ENABLED_DATASOURCE, "kc.db-enabled-my-store") "my-store"
* }</pre>
*
* @param option the option containing a wildcard key
* @param namedKey the fully qualified (resolved) configuration key
* @return the part of {@code namedKey} that replaces the wildcard in {@code option.getKey()}, otherwise {@code null}
*/
public static String getWildcardValue(Option<?> option, String namedKey) {
if (option == null || namedKey == null) {
return null;
}
String key = namedKey.startsWith("kc.") ? namedKey.substring("kc.".length()) : namedKey;
String prefix = getWildcardPrefix(option.getKey());
return prefix != null && key.startsWith(prefix) ? key.substring(prefix.length()) : null;
}
}

View file

@ -24,11 +24,12 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.keycloak.common.util.TriFunction;
import org.keycloak.config.DatabaseOptions;
import org.keycloak.config.DatabaseOptions.DatabaseTlsMode;
import org.keycloak.config.Option;
import io.quarkus.runtime.util.StringUtil;
@ -37,8 +38,6 @@ import static java.util.Arrays.asList;
public final class Database {
public final static String ORACLE_URL_PREFIX = "jdbc:oracle:thin:@";
private static final Map<String, Vendor> DATABASES = new HashMap<>();
static {
@ -72,8 +71,8 @@ public final class Database {
/**
* The {@param namedProperty} represents name of the named datasource if we need to set the URL for additional datasource
*/
public static Optional<String> getDefaultUrl(String namedProperty, String alias) {
return getVendor(alias).map(f -> f.defaultUrl.apply(namedProperty, alias));
public static Optional<String> getDefaultUrl(Function<Option<?>, String> getter, String namedProperty, String alias) {
return getVendor(alias).map(f -> f.defaultUrl.apply(getter, namedProperty, alias));
}
public static Optional<String> getDriver(String alias, boolean isXaEnabled) {
@ -88,7 +87,6 @@ public final class Database {
return getVendor(alias).map(mapper);
}
/**
* @return List of aliases of databases
*/
@ -104,12 +102,13 @@ public final class Database {
"org.h2.jdbcx.JdbcDataSource",
"org.h2.Driver",
"org.hibernate.dialect.H2Dialect",
new BiFunction<>() {
new TriFunction<>() {
@Override
public String apply(String namedProperty, String alias) {
public String apply(Function<Option<?>, String> getter, String namedProperty, String alias) {
String url;
if ("dev-file".equalsIgnoreCase(alias)) {
var separator = escapeReplacements(File.separator);
return new StringBuilder()
url = new StringBuilder()
.append("jdbc:h2:file:")
.append("${kc.db-url-path:${kc.home.dir:%s}}".formatted(escapeReplacements(System.getProperty("user.home"))))
.append(separator)
@ -119,8 +118,14 @@ public final class Database {
.append(separator)
.append(getDbName(namedProperty))
.toString();
} else {
url = "jdbc:h2:mem:%s".formatted(getDbName(namedProperty));
}
return "jdbc:h2:mem:%s".formatted(getDbName(namedProperty));
String urlProps = getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter);
if (!urlProps.isEmpty()) {
url += urlProps;
}
return amendH2(url);
}
private String getFolder(String namedProperty) {
@ -150,11 +155,11 @@ public final class Database {
"com.mysql.cj.jdbc.Driver",
"org.hibernate.dialect.MySQLDialect",
// default URL looks like this: "jdbc:mysql://${kc.db-url-host:localhost}:${kc.db-url-port:3306}/${kc.db-url-database:keycloak}${kc.db-url-properties:}"
(namedProperty, alias) -> "jdbc:mysql://%s:%s/%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "3306"),
getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)),
(getter, namedProperty, alias) -> "jdbc:mysql://%s:%s/%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, getter, "3306"),
getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)),
"org.keycloak.connections.jpa.updater.liquibase.UpdatedMySqlDatabase"
),
TIDB("tidb",
@ -162,11 +167,11 @@ public final class Database {
"com.mysql.cj.jdbc.Driver",
"org.hibernate.community.dialect.TiDBDialect",
// default URL looks like this: "jdbc:mysql://${kc.db-url-host:localhost}:${kc.db-url-port:3306}/${kc.db-url-database:keycloak}${kc.db-url-properties:}"
(namedProperty, alias) -> "jdbc:mysql://%s:%s/%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "3306"),
getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)),
(getter, namedProperty, alias) -> "jdbc:mysql://%s:%s/%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, getter, "3306"),
getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)),
"org.keycloak.connections.jpa.updater.liquibase.UpdatedMySqlDatabase"
),
MARIADB("mariadb",
@ -174,11 +179,11 @@ public final class Database {
"org.mariadb.jdbc.Driver",
"org.hibernate.dialect.MariaDBDialect",
// default URL looks like this: "jdbc:mariadb://${kc.db-url-host:localhost}:${kc.db-url-port:3306}/${kc.db-url-database:keycloak}${kc.db-url-properties:}"
(namedProperty, alias) -> "jdbc:mariadb://%s:%s/%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "3306"),
getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)),
(getter, namedProperty, alias) -> "jdbc:mariadb://%s:%s/%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, getter, "3306"),
getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)),
"org.keycloak.connections.jpa.updater.liquibase.UpdatedMariaDBDatabase"
),
POSTGRES("postgresql",
@ -186,11 +191,11 @@ public final class Database {
"org.postgresql.Driver",
"org.hibernate.dialect.PostgreSQLDialect",
// default URL looks like this: "jdbc:postgresql://${kc.db-url-host:localhost}:${kc.db-url-port:5432}/${kc.db-url-database:keycloak}${kc.db-url-properties:}"
(namedProperty, alias) -> "jdbc:postgresql://%s:%s/%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "5432"),
getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)),
(getter, namedProperty, alias) -> "jdbc:postgresql://%s:%s/%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, getter, "5432"),
getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)),
"liquibase.database.core.PostgresDatabase",
"postgres"
),
@ -199,11 +204,11 @@ public final class Database {
"com.microsoft.sqlserver.jdbc.SQLServerDriver",
"org.hibernate.dialect.SQLServerDialect",
// default URL looks like this: "jdbc:sqlserver://${kc.db-url-host:localhost}:${kc.db-url-port:1433};databaseName=${kc.db-url-database:keycloak}${kc.db-url-properties:}"
(namedProperty, alias) -> "jdbc:sqlserver://%s:%s;databaseName=%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "1433"),
getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, namedProperty)),
(getter, namedProperty, alias) -> "jdbc:sqlserver://%s:%s;databaseName=%s%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, getter, "1433"),
getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak"),
getProperty(DatabaseOptions.DB_URL_PROPERTIES, getter)),
"org.keycloak.quarkus.runtime.storage.database.liquibase.database.CustomMSSQLDatabase",
"mssql"
),
@ -212,10 +217,12 @@ public final class Database {
"oracle.jdbc.driver.OracleDriver",
"org.hibernate.dialect.OracleDialect",
// default URL looks like this: "jdbc:oracle:thin:@//${kc.db-url-host:localhost}:${kc.db-url-port:1521}/${kc.db-url-database:keycloak}"
(namedProperty, alias) -> ORACLE_URL_PREFIX + "//%s:%s/%s".formatted(
getProperty(DatabaseOptions.DB_URL_HOST, namedProperty, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, namedProperty, "1521"),
getProperty(DatabaseOptions.DB_URL_DATABASE, namedProperty, "keycloak")),
(getter, namedProperty, alias) -> "jdbc:oracle:thin:%s//%s:%s/%s".formatted(
DatabaseOptions.DatabaseTlsMode.fromCliValue(getProperty(DatabaseOptions.DB_TLS_MODE, getter,
DatabaseOptions.DatabaseTlsMode.DISABLED.toCliValue())) == DatabaseTlsMode.DISABLED ? "@" : "@tcps:",
getProperty(DatabaseOptions.DB_URL_HOST, getter, "localhost"),
getProperty(DatabaseOptions.DB_URL_PORT, getter, "1521"),
getProperty(DatabaseOptions.DB_URL_DATABASE, getter, "keycloak")),
"liquibase.database.core.OracleDatabase"
);
@ -223,16 +230,16 @@ public final class Database {
final String xaDriver;
final String nonXaDriver;
final Function<String, String> dialect;
final BiFunction<String, String, String> defaultUrl;
final TriFunction<Function<Option<?>, String>, String, String, String> defaultUrl;
final String liquibaseType;
final String[] aliases;
Vendor(String databaseKind, String xaDriver, String nonXaDriver, String dialect, BiFunction<String, String, String> defaultUrl,
Vendor(String databaseKind, String xaDriver, String nonXaDriver, String dialect, TriFunction<Function<Option<?>, String>, String, String, String> defaultUrl,
String liquibaseType, String... aliases) {
this(databaseKind, xaDriver, nonXaDriver, alias -> dialect, defaultUrl, liquibaseType, aliases);
}
Vendor(String databaseKind, String xaDriver, String nonXaDriver, Function<String, String> dialect, BiFunction<String, String, String> defaultUrl,
Vendor(String databaseKind, String xaDriver, String nonXaDriver, Function<String, String> dialect, TriFunction<Function<Option<?>, String>, String, String, String> defaultUrl,
String liquibaseType,
String... aliases) {
this.databaseKind = databaseKind;
@ -248,14 +255,12 @@ public final class Database {
return databaseKind.equals(dbKind);
}
private static String getProperty(Option<?> option, String namedProperty) {
return getProperty(option, namedProperty, "");
private static String getProperty(Option<?> option, Function<Option<?>, String> getter) {
return getProperty(option, getter, "");
}
private static String getProperty(Option<?> option, String namedProperty, String defaultValue) {
return "${kc.%s:%s}".formatted(StringUtil.isNullOrEmpty(namedProperty) ? option.getKey() :
DatabaseOptions.Datasources.getNamedKey(option, namedProperty).orElseThrow(() -> new IllegalArgumentException("Cannot find the named property")),
defaultValue);
private static String getProperty(Option<?> option, Function<Option<?>, String> getter, String defaultValue) {
return Optional.ofNullable(getter.apply(option)).orElse(defaultValue);
}
public String getLiquibaseType() {
@ -266,5 +271,43 @@ public final class Database {
public String toString() {
return databaseKind.toLowerCase(Locale.ROOT);
}
/**
* Starting with H2 version 2.x, marking "VALUE" as a non-keyword is necessary as some columns are named "VALUE" in the Keycloak schema.
* <p />
* Alternatives considered and rejected:
* <ul>
* <li>customizing H2 Database dialect -&gt; wouldn't work for existing Liquibase scripts.</li>
* <li>adding quotes to <code>@Column(name="VALUE")</code> annotations -&gt; would require testing for all DBs, wouldn't work for existing Liquibase scripts.</li>
* </ul>
* Downsides of this solution: Release notes needed to point out that any H2 JDBC URL parameter with <code>NON_KEYWORDS</code> needs to add the keyword <code>VALUE</code> manually.
* @return JDBC URL with <code>NON_KEYWORDS=VALUE</code> appended if the URL doesn't contain <code>NON_KEYWORDS=</code> yet
*/
private static String addH2NonKeywords(String jdbcUrl) {
if (!jdbcUrl.contains("NON_KEYWORDS=")) {
jdbcUrl = jdbcUrl + ";NON_KEYWORDS=VALUE";
}
return jdbcUrl;
}
/**
* Required so that the H2 db instance is closed only when the Agroal connection pool is closed during
* Keycloak shutdown. We cannot rely on the default H2 ShutdownHook as this can result in the DB being
* closed before dependent resources, e.g. JDBC_PING2, are shutdown gracefully. This solution also
* requires the Agroal min-pool connection size to be at least 1.
*/
private static String addH2CloseOnExit(String jdbcUrl) {
if (!jdbcUrl.contains("DB_CLOSE_ON_EXIT=")) {
jdbcUrl = jdbcUrl + ";DB_CLOSE_ON_EXIT=FALSE";
}
if (!jdbcUrl.contains("DB_CLOSE_DELAY=")) {
jdbcUrl = jdbcUrl + ";DB_CLOSE_DELAY=0";
}
return jdbcUrl;
}
private static String amendH2(String jdbcUrl) {
return addH2CloseOnExit(addH2NonKeywords(jdbcUrl));
}
}
}

View file

@ -92,6 +92,7 @@ import org.keycloak.quarkus.runtime.configuration.KeycloakConfigSourceProvider;
import org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider;
import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource;
import org.keycloak.quarkus.runtime.configuration.PropertyMappingInterceptor;
import org.keycloak.quarkus.runtime.configuration.mappers.DatabasePropertyMappers;
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers;
import org.keycloak.quarkus.runtime.configuration.mappers.WildcardPropertyMapper;
import org.keycloak.quarkus.runtime.integration.QuarkusKeycloakSessionFactory;
@ -417,7 +418,7 @@ class KeycloakProcessor {
.filter(descriptor -> !descriptor.getName().equals(DEFAULT_PERSISTENCE_UNIT)) // not default persistence unit
.map(KeycloakProcessor::getDatasourceNameFromPersistenceXml)
.filter(this::missingDbKind)
.map(datasourceName -> DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB, datasourceName).orElseThrow()).toList();
.map(datasourceName -> PropertyMappers.getWildcardPropertyMapper(DatabaseOptions.DB_KIND).orElseThrow().getFrom(datasourceName)).toList();
if (!notSetPersistenceUnitsDBKinds.isEmpty()) {
throwConfigError("Detected additional named datasources without a DB kind set, please specify: %s".formatted(String.join(",", notSetPersistenceUnitsDBKinds)));
@ -435,16 +436,15 @@ class KeycloakProcessor {
* </ol>
*/
private boolean missingDbKind(String datasourceName) {
String key = NS_KEYCLOAK_PREFIX.concat(DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB, datasourceName).orElseThrow());
PropertyMappingInterceptor.disable();
try {
var from = Configuration.getConfigValue(key);
var from = DatabasePropertyMappers.getDatasourceOptionValue(DB, datasourceName);
if (from.getValue() != null) {
if (from.isPresent()) {
return false; // user has directly specified
}
WildcardPropertyMapper<?> mapper = (WildcardPropertyMapper<?>)PropertyMappers.getMapper(key);
WildcardPropertyMapper<?> mapper = PropertyMappers.getWildcardPropertyMapper(DatabaseOptions.DB_KIND).orElseThrow();
// quarkus properties
boolean missing = Configuration.getOptionalValue(mapper.getTo(datasourceName))
@ -578,13 +578,11 @@ class KeycloakProcessor {
}
// db-dialect
DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_DIALECT, datasourceName)
.flatMap(Configuration::getOptionalKcValue)
DatabasePropertyMappers.getDatasourceOptionValue(DatabaseOptions.DB_DIALECT, datasourceName)
.ifPresent(dialect -> unitProperties.setProperty(AvailableSettings.DIALECT, dialect));
// db-schema
DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_SCHEMA, datasourceName)
.flatMap(Configuration::getOptionalKcValue)
DatabasePropertyMappers.getDatasourceOptionValue(DatabaseOptions.DB_SCHEMA, datasourceName)
.ifPresent(schema -> unitProperties.setProperty(AvailableSettings.DEFAULT_SCHEMA, schema));
unitProperties.setProperty(AvailableSettings.JAKARTA_TRANSACTION_TYPE, PersistenceUnitTransactionType.JTA.name());
@ -595,13 +593,11 @@ class KeycloakProcessor {
unitProperties.setProperty(AvailableSettings.DATASOURCE, datasourceName); // for backward compatibility
// db-debug-jpql
DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_SQL_JPA_DEBUG, datasourceName)
.filter(Configuration::isKcPropertyTrue)
.ifPresent(f -> unitProperties.put(AvailableSettings.USE_SQL_COMMENTS, "true"));
DatabasePropertyMappers.getDatasourceOptionValue(DatabaseOptions.DB_SQL_JPA_DEBUG, datasourceName)
.ifPresent(f -> unitProperties.put(AvailableSettings.USE_SQL_COMMENTS, f));
// db-log-slow-queries-threshold
DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_SQL_LOG_SLOW_QUERIES, datasourceName)
.flatMap(Configuration::getOptionalKcValue)
DatabasePropertyMappers.getDatasourceOptionValue(DatabaseOptions.DB_SQL_LOG_SLOW_QUERIES, datasourceName)
.ifPresent(threshold -> unitProperties.put(AvailableSettings.LOG_SLOW_QUERY, threshold));
}

View file

@ -5,9 +5,9 @@ import java.util.Map;
import org.keycloak.common.profile.SingleProfileConfigResolver;
import org.keycloak.config.FeatureOptions;
import org.keycloak.config.WildcardOptionsUtil;
import org.keycloak.quarkus.runtime.cli.PropertyException;
import org.keycloak.quarkus.runtime.configuration.Configuration;
import org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers;
import static org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX;
@ -19,23 +19,24 @@ public class QuarkusSingleProfileConfigResolver extends SingleProfileConfigResol
protected static Map<String, Boolean> getQuarkusFeatureState() {
var map = new HashMap<String, Boolean>();
var featureEnabledOptionPrefix = NS_KEYCLOAK_PREFIX + WildcardOptionsUtil.getWildcardPrefix(FeatureOptions.FEATURE.getKey());
var wildcard = PropertyMappers.getWildcardPropertyMapper(FeatureOptions.FEATURE).orElseThrow();
Configuration.getPropertyNames().forEach(property -> {
if (property.startsWith(NS_KEYCLOAK_PREFIX) && property.startsWith(featureEnabledOptionPrefix)) {
var feature = WildcardOptionsUtil.getWildcardValue(FeatureOptions.FEATURE, property);
var value = Configuration.getOptionalValue(property).orElseThrow(
() -> new PropertyException("Missing value for feature '%s'".formatted(feature)));
if (property.startsWith(NS_KEYCLOAK_PREFIX)) {
wildcard.extractWildcardValue(property).ifPresent(feature -> {
var value = Configuration.getOptionalValue(property).orElseThrow(
() -> new PropertyException("Missing value for feature '%s'".formatted(feature)));
if (value.startsWith("v")) {
map.put(feature + ":" + value, true);
} else {
map.put(feature, switch (value) {
case "enabled" -> Boolean.TRUE;
case "disabled" -> Boolean.FALSE;
default -> null;
});
}
if (value.startsWith("v")) {
map.put(feature + ":" + value, true);
} else {
map.put(feature, switch (value) {
case "enabled" -> Boolean.TRUE;
case "disabled" -> Boolean.FALSE;
default -> null;
});
}
});
}
});

View file

@ -4,6 +4,7 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -18,6 +19,7 @@ import org.keycloak.config.DatabaseOptions;
import org.keycloak.config.Option;
import org.keycloak.config.OptionsUtil;
import org.keycloak.config.TransactionOptions;
import org.keycloak.config.WildcardOptionsUtil;
import org.keycloak.config.database.Database;
import org.keycloak.config.database.Database.Vendor;
import org.keycloak.quarkus.runtime.cli.Picocli;
@ -32,6 +34,7 @@ import io.smallrye.config.ConfigValue;
import org.jboss.logging.Logger;
import static org.keycloak.config.DatabaseOptions.DB;
import static org.keycloak.config.DatabaseOptions.DB_KIND;
import static org.keycloak.config.DatabaseOptions.DB_MTLS_KEY_STORE_FILE;
import static org.keycloak.config.DatabaseOptions.DB_MTLS_KEY_STORE_PASSWORD;
import static org.keycloak.config.DatabaseOptions.DB_MTLS_KEY_STORE_TYPE;
@ -42,10 +45,6 @@ import static org.keycloak.config.DatabaseOptions.DB_TLS_TRUST_STORE_FILE;
import static org.keycloak.config.DatabaseOptions.DB_TLS_TRUST_STORE_PASSWORD;
import static org.keycloak.config.DatabaseOptions.DB_TLS_TRUST_STORE_TYPE;
import static org.keycloak.config.DatabaseOptions.DB_URL;
import static org.keycloak.config.DatabaseOptions.Datasources.OPTIONS_DATASOURCES;
import static org.keycloak.config.DatabaseOptions.Datasources.getDatasourceOption;
import static org.keycloak.config.DatabaseOptions.Datasources.getKeyForDatasource;
import static org.keycloak.config.DatabaseOptions.Datasources.getNamedKey;
import static org.keycloak.quarkus.runtime.configuration.Configuration.getOptionalKcValue;
import static org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX;
import static org.keycloak.quarkus.runtime.configuration.mappers.DatabasePropertyMappers.Datasources.appendDatasourceMappers;
@ -73,7 +72,7 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
@Override
public List<PropertyMapper<?>> getPropertyMappers() {
List<PropertyMapper<?>> mappers = List.of(
List<PropertyMapper<?>> allSourceMappers = List.of(
fromOption(DatabaseOptions.DB_DIALECT)
.mapFrom(DB, DatabasePropertyMappers::transformDialect)
.build(),
@ -82,11 +81,6 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
.to("quarkus.datasource.jdbc.driver")
.paramLabel("driver")
.build(),
fromOption(DB)
.to("quarkus.datasource.db-kind")
.transformer(DatabasePropertyMappers::toDatabaseKind)
.paramLabel("vendor")
.build(),
fromOption(DatabaseOptions.DB_URL)
.to("quarkus.datasource.jdbc.url")
.mapFrom(DB, DatabasePropertyMappers::getDatabaseUrl)
@ -159,19 +153,11 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
.to("quarkus.datasource.jdbc.max-size")
.paramLabel("size")
.build(),
fromOption(DatabaseOptions.DB_POOL_MAX_LIFETIME)
.to("quarkus.datasource.jdbc.max-lifetime")
.mapFrom(DB, DatabasePropertyMappers::transformPoolMaxLifetime)
.paramLabel("duration")
.build(),
fromOption(DatabaseOptions.DB_SQL_JPA_DEBUG)
.build(),
fromOption(DatabaseOptions.DB_SQL_LOG_SLOW_QUERIES)
.paramLabel("milliseconds")
.build(),
fromOption(DatabaseOptions.DB_ENABLED_DATASOURCE)
.to("quarkus.datasource.\"<datasource>\".active")
.build(),
// Database TLS configuration
fromOption(DB_TLS_MODE)
.paramLabel("mode")
@ -250,16 +236,33 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
setInputTlsJdbcProperty(DB_MTLS_KEY_STORE_PASSWORD, "sslpassword", EnumSet.of(Database.Vendor.POSTGRES))
);
List<PropertyMapper<?>> result = appendDatasourceMappers(mappers, Map.of(
List<PropertyMapper<?>> result = appendDatasourceMappers(allSourceMappers, Map.of(
// Inherit options from the DB mappers
DB, PropertyMapper.Builder::removeMapFrom,
DB_POOL_INITIAL_SIZE, mapper -> mapper.mapFrom(DB_POOL_INITIAL_SIZE),
DB_POOL_MAX_SIZE, mapper -> mapper.mapFrom(DB_POOL_MAX_SIZE)
));
// finally add mappers that aren't intended to work with other datasources
// finally add mappers that aren't intended to work with all datasources
// - also this usage of isEnabled won't work correctly with wildcard mappers
result.addAll(List.of(
fromOption(DB)
.to("quarkus.datasource.db-kind")
.transformer(DatabasePropertyMappers::toDatabaseKind)
.paramLabel("vendor")
.build(),
fromOption(DB_KIND)
.to("quarkus.datasource.\"<datasource>\".db-kind")
.transformer(DatabasePropertyMappers::toDatabaseKind)
.paramLabel("vendor")
.build(),
fromOption(DatabaseOptions.DB_POOL_MAX_LIFETIME)
.to("quarkus.datasource.jdbc.max-lifetime")
.mapFrom(DB, DatabasePropertyMappers::transformPoolMaxLifetime)
.paramLabel("duration")
.build(),
fromOption(DatabaseOptions.DB_ENABLED_DATASOURCE)
.to("quarkus.datasource.\"<datasource>\".active")
.build(),
fromOption(SYNTHETIC_RUNTIME_DB_OPTION).mapFrom(DB, (name, value, context) -> "primary")
.to(PG_TARGET_SERVER_TYPE)
.isEnabled(DatabasePropertyMappers::isPostgresqlTargetServerTypeEnabled)
@ -402,62 +405,8 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
return String.valueOf(DurationConverter.parseDuration(value).toSeconds());
}
/**
* Starting with H2 version 2.x, marking "VALUE" as a non-keyword is necessary as some columns are named "VALUE" in the Keycloak schema.
* <p />
* Alternatives considered and rejected:
* <ul>
* <li>customizing H2 Database dialect -&gt; wouldn't work for existing Liquibase scripts.</li>
* <li>adding quotes to <code>@Column(name="VALUE")</code> annotations -&gt; would require testing for all DBs, wouldn't work for existing Liquibase scripts.</li>
* </ul>
* Downsides of this solution: Release notes needed to point out that any H2 JDBC URL parameter with <code>NON_KEYWORDS</code> needs to add the keyword <code>VALUE</code> manually.
* @return JDBC URL with <code>NON_KEYWORDS=VALUE</code> appended if the URL doesn't contain <code>NON_KEYWORDS=</code> yet
*/
private static String addH2NonKeywords(String jdbcUrl) {
if (!jdbcUrl.contains("NON_KEYWORDS=")) {
jdbcUrl = jdbcUrl + ";NON_KEYWORDS=VALUE";
}
return jdbcUrl;
}
/**
* Required so that the H2 db instance is closed only when the Agroal connection pool is closed during
* Keycloak shutdown. We cannot rely on the default H2 ShutdownHook as this can result in the DB being
* closed before dependent resources, e.g. JDBC_PING2, are shutdown gracefully. This solution also
* requires the Agroal min-pool connection size to be at least 1.
*/
private static String addH2CloseOnExit(String jdbcUrl) {
if (!jdbcUrl.contains("DB_CLOSE_ON_EXIT=")) {
jdbcUrl = jdbcUrl + ";DB_CLOSE_ON_EXIT=FALSE";
}
if (!jdbcUrl.contains("DB_CLOSE_DELAY=")) {
jdbcUrl = jdbcUrl + ";DB_CLOSE_DELAY=0";
}
return jdbcUrl;
}
private static String amendH2(String jdbcUrl) {
return addH2CloseOnExit(addH2NonKeywords(jdbcUrl));
}
private static String getDatabaseUrl(String name, String value, ConfigSourceInterceptorContext c) {
String url = Database.getDefaultUrl(name, value).orElse(null);
if (isDevModeDatabase(value)) {
String key = Optional.ofNullable(name).map(
n -> DatabaseOptions.Datasources.getNamedKey(DatabaseOptions.DB_URL_PROPERTIES, n).orElseThrow())
.orElse(DatabaseOptions.DB_URL_PROPERTIES.getKey());
String urlProps = Configuration.getKcConfigValue(key).getValue();
if (urlProps != null) {
url += urlProps;
}
url = amendH2(url);
} else if (Database.getVendor(value).filter(Vendor.ORACLE::equals).isPresent()) {
var tlsMode = getDatabaseTlsMode(name);
if (tlsMode != DatabaseOptions.DatabaseTlsMode.DISABLED) {
url = Database.ORACLE_URL_PREFIX + "tcps:" + url.substring(Database.ORACLE_URL_PREFIX.length());
}
}
return url;
private static String getDatabaseUrl(String name, String value, ConfigSourceInterceptorContext c) {
return Database.getDefaultUrl(option -> getDatasourceOptionValue(option, name).orElse(null), name, value).orElse(null);
}
private static String getXaOrNonXaDriver(String name, String value, ConfigSourceInterceptorContext context) {
@ -498,31 +447,33 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
};
}
public static final class Datasources {
public static final class Datasources extends org.keycloak.config.DatabaseOptions.Datasources {
/**
* Automatically create mappers for datasource options
*/
static List<PropertyMapper<?>> appendDatasourceMappers(List<PropertyMapper<?>> mappers, Map<Option<?>, Consumer<PropertyMapper.Builder<?>>> transformDatasourceMappers) {
List<PropertyMapper<?>> datasourceMappers = new ArrayList<>(OPTIONS_DATASOURCES.size() + mappers.size());
List<PropertyMapper<?>> datasourceMappers = new ArrayList<>(mappers.size() * 2);
Map<String, Option<?>> cachedDatasourceOptions = new HashMap<>();
cachedDatasourceOptions.put(DB.getKey(), DB_KIND);
mappers.stream().map(PropertyMapper::getOption).forEach(o -> cachedDatasourceOptions.computeIfAbsent(o.getKey(), k -> getDatasourceOption(o)));
for (var parent : mappers) {
var parentOption = parent.getOption();
var datasourceOption = getDatasourceOption(parentOption);
if (datasourceOption.isEmpty()) {
log.debugf("No datasource option found for '%s'", parentOption.getKey());
continue;
}
var datasourceOption = cachedDatasourceOptions.get(parentOption.getKey());
var created = fromOption(datasourceOption.get())
var created = fromOption(datasourceOption)
.isMasked(parent.isMask())
.transformer(parent.getMapper());
if (parent.getMapFrom() != null) {
var wildcardMapFromOption = getKeyForDatasource(parent.getMapFrom())
.orElseThrow(() -> new IllegalArgumentException("Option '%s' in mapFrom() method for mapper '%s' does not have any associated wildcard option".formatted(parent.getMapFrom(), datasourceOption.get().getKey())));
created.wildcardMapFrom(wildcardMapFromOption, parent.getParentMapper() != null ? (name, value, context) -> parent.getParentMapper().map(name, value, context) : null);
Option<?> mapFrom = cachedDatasourceOptions.get(parent.getMapFrom());
if (mapFrom == null) {
throw new IllegalArgumentException("Option '%s' in mapFrom() method for mapper '%s' does not have any associated wildcard option".formatted(parent.getMapFrom(), datasourceOption.getKey()));
}
created.wildcardMapFrom(mapFrom, parent.getParentMapper() != null ? (name, value, context) -> parent.getParentMapper().map(name, value, context) : null);
}
if (parent.getParamLabel() != null) {
@ -539,7 +490,7 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
customTransformer.accept(created);
}
Option<String> primaryOption = getDatasourceOption(DB).orElseThrow();
Option<String> primaryOption = DB_KIND;
PropertyMapper<?> mapper = created.build();
// if we're not the DB option, nor mapped directly from the DB option, then
@ -653,12 +604,11 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
return findTlsTrustStoreFile(datasource).isEmpty() ? value : null;
}
private static Optional<String> getDatasourceOptionValue(Option<?> opt, String datasource) {
var option = datasource == null ?
Optional.of(opt.getKey()) :
getNamedKey(opt, datasource);
return option.map(Configuration::getKcConfigValue)
.map(ConfigValue::getValue);
public static Optional<String> getDatasourceOptionValue(Option<?> opt, String datasource) {
if (datasource == null) {
return Configuration.getOptionalKcValue(opt);
}
return opt.getWildcardKey().map(k -> WildcardOptionsUtil.getWildcardNamedKey(k, datasource)).flatMap(Configuration::getOptionalKcValue);
}
private static Optional<String> findDatabaseUrl(String datasource) {
@ -671,7 +621,6 @@ public final class DatabasePropertyMappers implements PropertyMapperGrouping {
private static DatabaseOptions.DatabaseTlsMode getDatabaseTlsMode(String datasource) {
return getDatasourceOptionValue(DB_TLS_MODE, datasource)
.map(String::toUpperCase)
.map(DatabaseOptions.DatabaseTlsMode::fromCliValue)
.orElse(DatabaseOptions.DatabaseTlsMode.DISABLED);
}

View file

@ -32,7 +32,6 @@ import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource;
import io.smallrye.config.ConfigSourceInterceptorContext;
import io.smallrye.config.ConfigValue;
import io.smallrye.config.Expressions;
import org.jboss.logging.Logger;
import static org.keycloak.quarkus.runtime.Environment.isRebuildCheck;
import static org.keycloak.quarkus.runtime.configuration.KeycloakConfigSourceProvider.isKeyStoreConfigSource;
@ -42,7 +41,6 @@ public final class PropertyMappers {
public static final String KC_SPI_PREFIX = "kc.spi";
public static String VALUE_MASK = "*******";
private static MappersConfig MAPPERS;
private static final Logger log = Logger.getLogger(PropertyMappers.class);
private final static List<PropertyMapperGrouping> GROUPINGS;
static {
GROUPINGS = List.of(new CachingPropertyMappers(), new DatabasePropertyMappers(),
@ -166,12 +164,17 @@ public final class PropertyMappers {
return switch (mappers.size()) {
case 0 -> null;
case 1 -> mappers.get(0);
default -> {
log.tracef("Duplicated mappers for key '%s'. Used the first found.", property);
yield mappers.get(0);
}
default -> mappers.stream().filter(mapper -> !mapper.getOption().isSynthetic()).findFirst().orElse(mappers.get(0));
};
}
/**
* Get the first non-synthetic wildcard matching the given option.
*/
public static Optional<WildcardPropertyMapper<?>> getWildcardPropertyMapper(Option<?> option) {
return MAPPERS.getWildcardMappers().stream()
.filter(mapper -> mapper.getOption().getKey().equals(option.getKey()) && !mapper.getOption().isSynthetic()).findFirst();
}
public static PropertyMapper<?> getMapper(String property) {
return getMapper(property, null);

View file

@ -3,6 +3,7 @@ package org.keycloak.quarkus.runtime.configuration.mappers;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -40,8 +41,6 @@ import static org.keycloak.config.TelemetryOptions.TELEMETRY_PROTOCOL;
import static org.keycloak.config.TelemetryOptions.TELEMETRY_RESOURCE_ATTRIBUTES;
import static org.keycloak.config.TelemetryOptions.TELEMETRY_SERVICE_NAME;
import static org.keycloak.config.TracingOptions.TRACING_HEADER;
import static org.keycloak.config.WildcardOptionsUtil.getWildcardPrefix;
import static org.keycloak.config.WildcardOptionsUtil.getWildcardValue;
import static org.keycloak.quarkus.runtime.configuration.MicroProfileConfigProvider.NS_KEYCLOAK_PREFIX;
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromFeature;
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;
@ -250,16 +249,20 @@ public class TelemetryPropertyMappers implements PropertyMapperGrouping{
if (TELEMETRY_HEADERS_CACHE == null) {
TELEMETRY_HEADERS_CACHE = new HashMap<>();
List<WildcardPropertyMapper<?>> wildcards = new ArrayList<>();
Stream.of(TELEMETRY_HEADER, TELEMETRY_LOGS_HEADER, TELEMETRY_METRICS_HEADER, TRACING_HEADER)
.forEach(opt -> PropertyMappers.getWildcardPropertyMapper(opt).ifPresent(wildcards::add));
Configuration.getPropertyNames().forEach(key -> {
if (key.startsWith(NS_KEYCLOAK_PREFIX)) {
Stream.of(TELEMETRY_HEADER, TELEMETRY_LOGS_HEADER, TELEMETRY_METRICS_HEADER, TRACING_HEADER)
.filter(option -> key.startsWith(NS_KEYCLOAK_PREFIX + getWildcardPrefix(option.getKey())))
.forEach(option -> {
String header = getWildcardValue(option, key);
String headerValue = Configuration.getOptionalValue(key)
.orElseThrow(() -> new PropertyException("Wrong value for the property '%s'".formatted(key)));
TELEMETRY_HEADERS_CACHE.computeIfAbsent(option, o -> new HashMap<>()).put(header, headerValue);
});
wildcards.forEach(wildcard -> {
wildcard.extractWildcardValue(key).ifPresent(header -> {
String headerValue = Configuration.getOptionalValue(key).orElseThrow(
() -> new PropertyException("Wrong value for the property '%s'".formatted(key)));
TELEMETRY_HEADERS_CACHE.computeIfAbsent(wildcard.getOption(), o -> new HashMap<>())
.put(header, headerValue);
});
});
}
});
}

View file

@ -111,7 +111,7 @@ public class WildcardPropertyMapper<T> extends PropertyMapper<T> {
public Optional<String> extractWildcardValue(String key) {
String result = null;
if (!this.option.isSynthetic() && key.startsWith(fromPrefix)) {
if (key.startsWith(fromPrefix)) {
result = key.substring(fromPrefix.length());
} else if (key.startsWith(toPrefix) && key.endsWith(toSuffix)) {
// TODO: this presumes that the quarkus value is quoted

View file

@ -4,7 +4,6 @@ import org.junit.Test;
import static org.keycloak.config.WildcardOptionsUtil.getWildcardNamedKey;
import static org.keycloak.config.WildcardOptionsUtil.getWildcardPrefix;
import static org.keycloak.config.WildcardOptionsUtil.getWildcardValue;
import static org.keycloak.config.WildcardOptionsUtil.isWildcardOption;
import static org.hamcrest.CoreMatchers.is;
@ -45,19 +44,4 @@ public class WildcardOptionsUtilTest {
assertNull(getWildcardNamedKey("", "null"));
}
@Test
public void getWildcardValueTest() {
assertThat(getWildcardValue(TracingOptions.TRACING_HEADER, "tracing-header-Authorization"), is("Authorization"));
assertThat(getWildcardValue(DatabaseOptions.DB_ENABLED_DATASOURCE, "db-enabled-my-store"), is("my-store"));
assertThat(getWildcardValue(DatabaseOptions.DB_ENABLED_DATASOURCE, "kc.db-enabled-my-store"), is("my-store"));
assertNull(getWildcardValue(TracingOptions.TRACING_HEADER, "something-wrong"));
var datasourceKindOption = DatabaseOptions.Datasources.getDatasourceOption(DatabaseOptions.DB).orElseThrow();
assertThat(getWildcardValue(datasourceKindOption, "db-kind-user"), is("user"));
assertThat(getWildcardValue(datasourceKindOption, "db-kind-"), is(""));
assertNull(getWildcardValue(null, "db-kind-"));
assertNull(getWildcardValue(null, null));
assertNull(getWildcardValue(TracingOptions.TRACING_HEADER, null));
assertNull(getWildcardValue(TracingOptions.TRACING_HEADER, ""));
assertNull(getWildcardValue(TracingOptions.TRACING_HEADER, "null"));
}
}

View file

@ -57,7 +57,7 @@ public class CustomJpaEntityProviderDistTest {
void notSpecifiedDbKind(CLIResult cliResult) {
// it is printed at build time and the check done at runtime
cliResult.assertNoMessage(MULTIPLE_DATASOURCES_MSG);
cliResult.assertError("Detected additional named datasources without a DB kind set, please specify: db-kind-new-user-store");
cliResult.assertError("Detected additional named datasources without a DB kind set, please specify: kc.db-kind-new-user-store");
}
@Test

View file

@ -119,10 +119,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -121,10 +121,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -33,10 +33,8 @@ Database - additional datasources:
driver. If not set, a default driver is set accordingly to the chosen
database.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
Transaction:
@ -144,4 +142,4 @@ Examples:
Change the relative path:
$ kc.sh build --http-relative-path=/auth
$ kc.sh build --http-relative-path=/auth

View file

@ -114,10 +114,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -114,10 +114,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -114,10 +114,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -114,10 +114,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -162,10 +162,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -232,10 +232,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -210,10 +210,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -233,10 +233,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -209,10 +209,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -232,10 +232,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -207,10 +207,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -230,10 +230,8 @@ Database - additional datasources:
If the named datasource <datasource> should be enabled at runtime. Default:
true.
--db-kind-<datasource> <vendor>
Used for named <datasource>. The database vendor. In production mode the
default value of 'dev-file' is deprecated, you should explicitly specify the
db instead. Possible values are: dev-file, dev-mem, mariadb, mssql, mysql,
oracle, postgres, tidb.
Used for named <datasource>. The database vendor. Possible values are:
dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres, tidb.
--db-log-slow-queries-threshold-<datasource> <milliseconds>
Used for named <datasource>. Log SQL statements slower than the configured
threshold with logger org.hibernate.SQL_SLOW and log-level info. Default:

View file

@ -0,0 +1,28 @@
package org.keycloak.admin.api;
import java.util.Set;
import jakarta.ws.rs.QueryParam;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
public class ListOptions {
@Parameter(description = "Set of fields to include in the response. Must be top-level fields. If omitted or empty, all fields will be populated.")
@QueryParam("fields")
protected Set<String> fields;
public ListOptions fields(Set<String> fields) {
this.setFields(fields);
return this;
}
public Set<String> getFields() {
return fields;
}
public void setFields(Set<String> fields) {
this.fields = fields;
}
}

View file

@ -1,19 +1,19 @@
package org.keycloak.admin.api.client;
import java.util.Set;
import java.util.stream.Stream;
import jakarta.validation.Valid;
import jakarta.ws.rs.BeanParam;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.admin.api.ListOptions;
import org.keycloak.common.constants.KeycloakOpenAPI;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
@ -22,7 +22,6 @@ import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@ -38,10 +37,10 @@ public interface ClientsApi {
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = BaseClientRepresentation.class)))
})
Stream<BaseClientRepresentation> getClients(@Parameter(description = "Set of fields to include in the response. Must be top-level fields on one of the client types. If omitted or empty, all fields will be populated.") @QueryParam("fields") Set<String> fields);
Stream<BaseClientRepresentation> getClients(@BeanParam ListOptions params);
default Stream<BaseClientRepresentation> getClients() {
return getClients(Set.of());
return getClients(new ListOptions());
}
@POST

View file

@ -1,6 +1,5 @@
package org.keycloak.rest.admin.api.client;
import java.util.Set;
import java.util.stream.Stream;
import jakarta.annotation.Nonnull;
@ -11,6 +10,7 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import org.keycloak.admin.api.ListOptions;
import org.keycloak.admin.api.client.ClientApi;
import org.keycloak.admin.api.client.ClientsApi;
import org.keycloak.models.KeycloakSession;
@ -38,8 +38,8 @@ public class DefaultClientsApi implements ClientsApi {
}
@Override
public Stream<BaseClientRepresentation> getClients(Set<String> fields) {
return clientService.getClients(realm, new ClientProjectionOptions(fields), null, null);
public Stream<BaseClientRepresentation> getClients(ListOptions params) {
return clientService.getClients(realm, new ClientProjectionOptions(params.getFields()), null, null);
}
@POST

View file

@ -30,6 +30,7 @@ import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.admin.api.ListOptions;
import org.keycloak.admin.api.PatchTypeNames;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator;
@ -347,7 +348,7 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{
assertThat(samlClient.getFrontChannelLogout(), is(false));
// test projecting only id and protocol
try (Stream<BaseClientRepresentation> baseClientRepresentationStream = getClientsApi().getClients(Set.of("clientId", "protocol"))) {
try (Stream<BaseClientRepresentation> baseClientRepresentationStream = getClientsApi().getClients(new ListOptions().fields(Set.of("clientId", "protocol")))) {
List<BaseClientRepresentation> clients = baseClientRepresentationStream.toList();
for (BaseClientRepresentation client : clients) {
BaseClientRepresentation toCompare = null;
@ -364,7 +365,7 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{
@Test
public void invalidFieldProjection() {
BadRequestException e = assertThrows(BadRequestException.class, () -> getClientsApi().getClients(Set.of("unknown!")));
BadRequestException e = assertThrows(BadRequestException.class, () -> getClientsApi().getClients(new ListOptions().fields(Set.of("unknown!"))));
assertEquals("{\"error\":\"unknown! is an unknown field\"}", e.getResponse().readEntity(String.class));
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.broker.provider;
import java.util.stream.Stream;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.models.IdentityProviderModel;
/**
* Identity providers that expose reusable trust material for flows such as
* client attestation or OID4VCI key attestation.
*/
public interface TrustMaterialIdentityProvider<C extends IdentityProviderModel> extends IdentityProvider<C> {
Stream<JWK> resolveKeys(TrustMaterialRequest request);
}

View file

@ -0,0 +1,73 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.broker.provider;
public class TrustMaterialRequest {
private final String kid;
private final String algorithm;
private final String issuer;
private TrustMaterialRequest(Builder builder) {
this.kid = builder.kid;
this.algorithm = builder.algorithm;
this.issuer = builder.issuer;
}
public String getKid() {
return kid;
}
public String getAlgorithm() {
return algorithm;
}
public String getIssuer() {
return issuer;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String kid;
private String algorithm;
private String issuer;
public Builder kid(String kid) {
this.kid = kid;
return this;
}
public Builder algorithm(String algorithm) {
this.algorithm = algorithm;
return this;
}
public Builder issuer(String issuer) {
this.issuer = issuer;
return this;
}
public TrustMaterialRequest build() {
return new TrustMaterialRequest(this);
}
}
}

View file

@ -10,6 +10,7 @@ import org.keycloak.broker.provider.ClientAssertionIdentityProvider;
import org.keycloak.broker.provider.ExchangeExternalToken;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.provider.JWTAuthorizationGrantProvider;
import org.keycloak.broker.provider.TrustMaterialIdentityProvider;
import org.keycloak.broker.provider.UserAuthenticationIdentityProvider;
import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.models.IdentityProviderCapability;
@ -80,6 +81,7 @@ public class IdentityProviderTypeUtil {
return switch (type) {
case USER_AUTHENTICATION -> UserAuthenticationIdentityProvider.class;
case CLIENT_ASSERTION -> ClientAssertionIdentityProvider.class;
case TRUST_MATERIAL -> TrustMaterialIdentityProvider.class;
case EXCHANGE_EXTERNAL_TOKEN -> ExchangeExternalToken.class;
case JWT_AUTHORIZATION_GRANT -> JWTAuthorizationGrantProvider.class;
case ANY -> IdentityProvider.class;

View file

@ -12,6 +12,7 @@ public enum IdentityProviderType {
ANY,
USER_AUTHENTICATION(USER_LINKING),
CLIENT_ASSERTION,
TRUST_MATERIAL,
EXCHANGE_EXTERNAL_TOKEN(USER_LINKING),
JWT_AUTHORIZATION_GRANT(USER_LINKING);

View file

@ -34,6 +34,8 @@ import org.keycloak.Config;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.broker.provider.TrustMaterialRequest;
import org.keycloak.broker.provider.TrustMaterialResolver;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Base64Url;
import org.keycloak.crypto.KeyUse;
@ -46,7 +48,6 @@ import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -58,7 +59,6 @@ import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.saml.RandomSecret;
import org.keycloak.services.ServicesLogger;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.Strings;
import org.keycloak.wellknown.WellKnownProvider;
@ -92,20 +92,9 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic
public static final String OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE = "oauth-client-attestation-pop+jwt";
/**
* The ClientAuthenticator needs to be aware of the public keys from the various Attesters it can trust.
*
* [
* {
* "kty": "RSA",
* "kid": "openid-abca-attester-key",
* "use": "sig",
* "alg": "PS256",
* "n": "uVd8mEqXMp...aaVZNQ",
* "e": "AQAB"
* }
* ]
* Comma-separated aliases of trust-material identity providers that expose the trusted attester keys.
*/
public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS = "attester_jwks";
public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS = "attester_trust_idps";
@Override
public String getId() {
@ -172,22 +161,23 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic
@Override
public boolean isConfigurable() {
return true;
return false;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
ProviderConfigProperty jwks = new ProviderConfigProperty();
jwks.setName(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS);
jwks.setLabel("Attester JWKS");
jwks.setType(ProviderConfigProperty.TEXT_TYPE);
jwks.setHelpText("JWKS containing trusted attester public keys");
return List.of(jwks);
return List.of();
}
@Override
public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
return List.of();
ProviderConfigProperty trustIdps = new ProviderConfigProperty();
trustIdps.setName(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS);
trustIdps.setLabel("Attester trust identity providers");
trustIdps.setType(ProviderConfigProperty.STRING_TYPE);
trustIdps.setRequired(true);
trustIdps.setHelpText("Comma-separated aliases of trust-material identity providers containing trusted attester public keys");
return List.of(trustIdps);
}
@Override
@ -314,24 +304,23 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic
// Private ---------------------------------------------------------------------------------------------------------
private KeyWrapper findAttesterKey(ClientAuthenticationFlowContext context, String kid) {
private KeyWrapper findAttesterKey(ClientAuthenticationFlowContext context, String kid, String algorithm, String issuer) {
if (Strings.isEmpty(kid))
throw new IllegalArgumentException("Invalid attester kid: " + kid);
AuthenticatorConfigModel configModel = context.getRealm().getAuthenticatorConfigByAlias(PROVIDER_ID);
if (configModel == null)
throw new IllegalStateException("No config for client authenticator: " + PROVIDER_ID);
String configValue = Optional.ofNullable(context.getClient())
.map(client -> client.getAttribute(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS))
.orElse(null);
if (Strings.isEmpty(configValue))
throw new IllegalStateException("Cannot load attester keys: " + OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS);
String configValue = Optional.ofNullable(configModel.getConfig()).orElse(Map.of())
.get(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS);
if (configValue == null)
throw new IllegalStateException("Cannot load attester keys: " + OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS);
ABCAConfig attesterKeys = JsonSerialization.valueFromString(configValue, ABCAConfig.class);
JWK jwk = attesterKeys.getKeys().stream()
.filter(k -> kid.equals(k.getKeyId()))
.findAny()
TrustMaterialRequest request = TrustMaterialRequest.builder()
.kid(kid)
.algorithm(algorithm)
.issuer(issuer)
.build();
JWK jwk = new TrustMaterialResolver().resolveKey(context.getSession(), configValue, request)
.orElseThrow(() -> new IllegalStateException("No matching key found for kid: " + kid));
return toPublicKeyWrapper(jwk);
@ -401,7 +390,7 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic
// The signature of the Client Attestation JWT verifies with the public key of a known and trusted Attester
//
KeyWrapper attesterKey = findAttesterKey(context, jws.getHeader().getKeyId());
KeyWrapper attesterKey = findAttesterKey(context, jws.getHeader().getKeyId(), jws.getHeader().getRawAlgorithm(), attestationJwt.getIssuer());
// Client Attestation JWT verification without signature check
//
@ -507,24 +496,6 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic
// [TODO] Additional checks to guarantee replay protection for the Client Attestation PoP JWT might need to be applied
}
/**
* The AttestationBasedClientAuthenticator config
*/
public static class ABCAConfig {
@JsonProperty
private List<JWK> keys;
public List<JWK> getKeys() {
return keys;
}
public ABCAConfig setKeys(List<JWK> keys) {
this.keys = keys;
return this;
}
}
public static class ABCAResult {
/**

View file

@ -24,6 +24,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@ -47,6 +48,9 @@ import org.keycloak.broker.provider.ClientAssertionIdentityProvider;
import org.keycloak.broker.provider.ExchangeExternalToken;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.JWTAuthorizationGrantProvider;
import org.keycloak.broker.provider.TrustMaterialIdentityProvider;
import org.keycloak.broker.provider.TrustMaterialRequest;
import org.keycloak.broker.trust.TrustKeyUtil;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.SecretGenerator;
@ -65,6 +69,7 @@ import org.keycloak.jose.JOSE;
import org.keycloak.jose.JOSEParser;
import org.keycloak.jose.jwe.JWE;
import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.keys.PublicKeyStorageProvider;
import org.keycloak.keys.PublicKeyStorageUtils;
@ -80,6 +85,7 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.protocol.oidc.utils.JWKSServerUtils;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
@ -92,6 +98,7 @@ import org.keycloak.services.resources.RealmsResource;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.Booleans;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.Strings;
import org.keycloak.util.TokenUtil;
import org.keycloak.vault.VaultStringSecret;
@ -101,7 +108,7 @@ import org.jboss.logging.Logger;
/**
* @author Pedro Igor
*/
public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider<OIDCIdentityProviderConfig>, JWTAuthorizationGrantProvider<OIDCIdentityProviderConfig> {
public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider<OIDCIdentityProviderConfig>, JWTAuthorizationGrantProvider<OIDCIdentityProviderConfig>, TrustMaterialIdentityProvider<OIDCIdentityProviderConfig> {
protected static final Logger logger = Logger.getLogger(OIDCIdentityProvider.class);
public static final String SCOPE_OPENID = "openid";
@ -1086,6 +1093,18 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
return false;
}
@Override
public Stream<JWK> resolveKeys(TrustMaterialRequest request) {
if (!matchesTrustMaterialIssuer(request)) {
return Stream.empty();
}
Stream<KeyWrapper> keys = Stream.ofNullable(PublicKeyStorageManager.getIdentityProviderKeyWrapper(session,
session.getContext().getRealm(), getConfig(), request.getKid(), request.getAlgorithm()));
return TrustKeyUtil.filterKeys(keys.map(JWKSServerUtils::toJwk), request);
}
@Override
protected void setEmailVerified(UserModel user, BrokeredIdentityContext context) {
OIDCIdentityProviderConfig config = getConfig();
@ -1103,6 +1122,14 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
user.setEmailVerified(emailVerified);
}
private boolean matchesTrustMaterialIssuer(TrustMaterialRequest request) {
if (Strings.isEmpty(request.getIssuer()) || Strings.isEmpty(getConfig().getIssuer())) {
return true;
}
return Objects.equals(request.getIssuer(), getConfig().getIssuer());
}
private Boolean getEmailVerifiedClaim(JsonWebToken token) {
if (token == null) {
return null;

View file

@ -0,0 +1,77 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.broker.provider;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.util.Strings;
public class TrustMaterialResolver {
public Stream<JWK> resolveKeys(KeycloakSession session, String aliases, TrustMaterialRequest request) {
if (Strings.isEmpty(aliases)) {
return Stream.empty();
}
return resolveKeys(session, splitAliases(aliases), request);
}
public Stream<JWK> resolveKeys(KeycloakSession session, Collection<String> aliases, TrustMaterialRequest request) {
if (aliases == null || aliases.isEmpty()) {
return Stream.empty();
}
return aliases.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(alias -> !alias.isEmpty())
.map(alias -> resolveProvider(session, alias))
.flatMap(Optional::stream)
.flatMap(provider -> provider.resolveKeys(request));
}
public Optional<JWK> resolveKey(KeycloakSession session, String aliases, TrustMaterialRequest request) {
return resolveKeys(session, aliases, request).findFirst();
}
private Optional<TrustMaterialIdentityProvider<?>> resolveProvider(KeycloakSession session, String alias) {
IdentityProviderModel model = session.identityProviders().getByAlias(alias);
if (model == null || !model.isEnabled()) {
return Optional.empty();
}
TrustMaterialIdentityProvider<?> provider = IdentityBrokerService.getIdentityProvider(session, model, TrustMaterialIdentityProvider.class);
return Optional.ofNullable(provider);
}
private List<String> splitAliases(String aliases) {
return Arrays.stream(aliases.split(","))
.filter(Objects::nonNull)
.map(String::trim)
.filter(alias -> !alias.isEmpty())
.toList();
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.broker.trust;
import java.util.stream.Stream;
import org.keycloak.broker.provider.TrustMaterialIdentityProvider;
import org.keycloak.broker.provider.TrustMaterialRequest;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.keys.PublicKeyLoader;
import org.keycloak.keys.PublicKeyStorageProvider;
import org.keycloak.keys.PublicKeyStorageUtils;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.utils.JWKSServerUtils;
import org.keycloak.util.Strings;
import org.keycloak.utils.StringUtil;
public class DefaultTrustIdentityProvider implements TrustMaterialIdentityProvider<DefaultTrustIdentityProviderConfig> {
private final KeycloakSession session;
private final DefaultTrustIdentityProviderConfig config;
public DefaultTrustIdentityProvider(KeycloakSession session, DefaultTrustIdentityProviderConfig config) {
this.session = session;
this.config = config;
}
@Override
public DefaultTrustIdentityProviderConfig getConfig() {
return config;
}
@Override
public Stream<JWK> resolveKeys(TrustMaterialRequest request) {
PublicKeyLoader loader = new DefaultTrustMaterialPublicKeyLoader(session, config);
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId());
Stream<KeyWrapper> keys = Strings.isEmpty(request.getKid())
? keyStorage.getKeys(modelKey, loader).stream()
: Stream.of(keyStorage.getPublicKey(modelKey, request.getKid(), request.getAlgorithm(), loader));
return TrustKeyUtil.filterKeys(keys.map(JWKSServerUtils::toJwk), request);
}
@Override
public boolean reloadKeys() {
if (!config.isEnabled() || StringUtil.isBlank(config.getTrustedJwksUrl())) {
return false;
}
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId());
return keyStorage.reloadKeys(modelKey, new DefaultTrustMaterialPublicKeyLoader(session, config));
}
@Override
public void close() {
}
}

View file

@ -0,0 +1,79 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.broker.trust;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
import org.keycloak.util.Strings;
import static org.keycloak.common.util.UriUtils.checkUrl;
public class DefaultTrustIdentityProviderConfig extends IdentityProviderModel {
public static final String TRUSTED_JWKS_URL = "trustedJwksUrl";
public static final String TRUSTED_JWKS = "trustedJwks";
public DefaultTrustIdentityProviderConfig() {
}
public DefaultTrustIdentityProviderConfig(IdentityProviderModel model) {
super(model);
}
@Override
public Boolean isHideOnLogin() {
return true;
}
@Override
public void validate(RealmModel realm) {
super.validate(realm);
boolean hasTrustedJwksUrl = !Strings.isEmpty(getTrustedJwksUrl());
boolean hasTrustedJwks = !Strings.isEmpty(getTrustedJwks());
if (hasTrustedJwksUrl == hasTrustedJwks) {
throw new IllegalArgumentException("Configure exactly one of trusted JWKS URL or trusted JWKS");
}
if (hasTrustedJwksUrl) {
checkUrl(realm.getSslRequired(), getTrustedJwksUrl(), TRUSTED_JWKS_URL);
}
}
public String getTrustedJwksUrl() {
return getConfig().get(TRUSTED_JWKS_URL);
}
public void setTrustedJwksUrl(String trustedJwksUrl) {
if (trustedJwksUrl == null) {
getConfig().remove(TRUSTED_JWKS_URL);
} else {
getConfig().put(TRUSTED_JWKS_URL, trustedJwksUrl);
}
}
public String getTrustedJwks() {
return getConfig().get(TRUSTED_JWKS);
}
public void setTrustedJwks(String trustedJwks) {
if (trustedJwks == null) {
getConfig().remove(TRUSTED_JWKS);
} else {
getConfig().put(TRUSTED_JWKS, trustedJwks);
}
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.broker.trust;
import java.util.List;
import java.util.Map;
import org.keycloak.Config;
import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
public class DefaultTrustIdentityProviderFactory extends AbstractIdentityProviderFactory<DefaultTrustIdentityProvider> implements EnvironmentDependentProviderFactory {
public static final String PROVIDER_ID = "default-trust";
@Override
public String getName() {
return "Default Trust";
}
@Override
public DefaultTrustIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
return new DefaultTrustIdentityProvider(session, new DefaultTrustIdentityProviderConfig(model));
}
@Override
public Map<String, String> parseConfig(KeycloakSession session, String config) {
throw new UnsupportedOperationException();
}
@Override
public IdentityProviderModel createConfig() {
return new DefaultTrustIdentityProviderConfig();
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
ProviderConfigProperty trustedJwksUrl = new ProviderConfigProperty();
trustedJwksUrl.setName(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS_URL);
trustedJwksUrl.setLabel("Trusted JWKS URL");
trustedJwksUrl.setHelpText("External JWKS URL containing trusted signing keys.");
trustedJwksUrl.setType(ProviderConfigProperty.STRING_TYPE);
ProviderConfigProperty trustedJwks = new ProviderConfigProperty();
trustedJwks.setName(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS);
trustedJwks.setLabel("Trusted JWKS");
trustedJwks.setHelpText("Hardcoded JWKS containing trusted signing keys.");
trustedJwks.setType(ProviderConfigProperty.TEXT_TYPE);
return List.of(trustedJwksUrl, trustedJwks);
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI) || Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_ABCA);
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.broker.trust;
import org.keycloak.crypto.PublicKeysWrapper;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.keys.PublicKeyLoader;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.utils.JWKSHttpUtils;
import org.keycloak.util.JWKSUtils;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
public class DefaultTrustMaterialPublicKeyLoader implements PublicKeyLoader {
private final KeycloakSession session;
private final DefaultTrustIdentityProviderConfig config;
public DefaultTrustMaterialPublicKeyLoader(KeycloakSession session, DefaultTrustIdentityProviderConfig config) {
this.session = session;
this.config = config;
}
@Override
public PublicKeysWrapper loadKeys() throws Exception {
if (StringUtil.isNotBlank(config.getTrustedJwksUrl())) {
JSONWebKeySet jwks = JWKSHttpUtils.sendJwksRequest(session, config.getTrustedJwksUrl());
return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true);
}
if (StringUtil.isNotBlank(config.getTrustedJwks())) {
JSONWebKeySet jwks = JsonSerialization.readValue(config.getTrustedJwks(), JSONWebKeySet.class);
return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true);
}
return PublicKeysWrapper.EMPTY;
}
}

View file

@ -0,0 +1,23 @@
package org.keycloak.broker.trust;
import java.util.Objects;
import java.util.stream.Stream;
import org.keycloak.broker.provider.TrustMaterialRequest;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.util.Strings;
public class TrustKeyUtil {
private TrustKeyUtil() {
}
public static Stream<JWK> filterKeys(Stream<JWK> keys, TrustMaterialRequest request) {
return keys
.filter(Objects::nonNull)
.filter(key -> Strings.isEmpty(request.getKid()) || Objects.equals(request.getKid(), key.getKeyId()))
.filter(key -> Strings.isEmpty(request.getAlgorithm()) || Strings.isEmpty(key.getAlgorithm())
|| Objects.equals(request.getAlgorithm(), key.getAlgorithm()))
.filter(key -> Strings.isEmpty(key.getPublicKeyUse()) || Objects.equals(JWK.Use.SIG.asString(), key.getPublicKeyUse()));
}
}

View file

@ -68,7 +68,10 @@ public class PublicKeyStorageManager {
public static KeyWrapper getIdentityProviderKeyWrapper(KeycloakSession session, RealmModel realm, JWTAuthorizationGrantConfig idpConfig, JWSInput input) {
String kid = input.getHeader().getKeyId();
String alg = input.getHeader().getRawAlgorithm();
return getIdentityProviderKeyWrapper(session, realm, idpConfig, kid, alg);
}
public static KeyWrapper getIdentityProviderKeyWrapper(KeycloakSession session, RealmModel realm, JWTAuthorizationGrantConfig idpConfig, String kid, String alg) {
PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class);
String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(realm.getId(), idpConfig.getInternalId());

View file

@ -1,23 +1,18 @@
package org.keycloak.protocol.oid4vc.issuance.keybinding;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.KeyType;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.utils.JWKSServerUtils;
@ -132,22 +127,7 @@ public final class TrustedAttestationKeysLoader {
.filter(key -> keyIds.contains(key.getKid()) && key.getPublicKey() != null)
.forEach(key -> {
try {
JWKBuilder builder = JWKBuilder.create()
.kid(key.getKid())
.algorithm(key.getAlgorithmOrDefault());
List<X509Certificate> certificates = Optional.ofNullable(key.getCertificateChain())
.filter(certs -> !certs.isEmpty())
.orElseGet(() -> Optional.ofNullable(key.getCertificate())
.map(Collections::singletonList)
.orElseGet(Collections::emptyList));
JWK jwk = null;
if (Objects.equals(key.getType(), KeyType.RSA)) {
jwk = builder.rsa(key.getPublicKey(), certificates, key.getUse());
} else if (Objects.equals(key.getType(), KeyType.EC)) {
jwk = builder.ec(key.getPublicKey(), certificates, key.getUse());
} else if (Objects.equals(key.getType(), KeyType.OKP)) {
jwk = builder.okp(key.getPublicKey(), key.getUse());
}
JWK jwk = JWKSServerUtils.toJwk(key);
if (jwk != null) {
keyMap.put(key.getKid(), jwk);
} else {

View file

@ -23,6 +23,7 @@ import java.util.Objects;
import java.util.Optional;
import org.keycloak.crypto.KeyType;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
@ -36,21 +37,7 @@ import org.keycloak.models.RealmModel;
public static JSONWebKeySet getRealmJwks(KeycloakSession session, RealmModel realm){
JWK[] jwks = session.keys().getKeysStream(realm)
.filter(k -> k.getStatus().isEnabled() && k.getPublicKey() != null)
.map(k -> {
JWKBuilder b = JWKBuilder.create().kid(k.getKid()).algorithm(k.getAlgorithmOrDefault());
List<X509Certificate> certificates = Optional.ofNullable(k.getCertificateChain())
.filter(certs -> !certs.isEmpty())
.orElseGet(() -> Optional.ofNullable(k.getCertificate()).map(Collections::singletonList)
.orElseGet(Collections::emptyList));
if (k.getType().equals(KeyType.RSA)) {
return b.rsa(k.getPublicKey(), certificates, k.getUse());
} else if (k.getType().equals(KeyType.EC)) {
return b.ec(k.getPublicKey(), certificates, k.getUse());
} else if (k.getType().equals(KeyType.OKP)) {
return b.okp(k.getPublicKey(), k.getUse());
}
return null;
})
.map(JWKSServerUtils::toJwk)
.filter(Objects::nonNull)
.toArray(JWK[]::new);
@ -58,4 +45,24 @@ import org.keycloak.models.RealmModel;
keySet.setKeys(jwks);
return keySet;
}
public static JWK toJwk(KeyWrapper key) {
JWKBuilder b = JWKBuilder.create()
.kid(key.getKid())
.algorithm(key.getAlgorithmOrDefault());
List<X509Certificate> certificates = Optional.ofNullable(key.getCertificateChain())
.filter(certs -> !certs.isEmpty())
.orElseGet(() -> Optional.ofNullable(key.getCertificate())
.map(Collections::singletonList)
.orElseGet(Collections::emptyList));
if (key.getType().equals(KeyType.RSA)) {
return b.rsa(key.getPublicKey(), certificates, key.getUse());
} else if (key.getType().equals(KeyType.EC)) {
return b.ec(key.getPublicKey(), certificates, key.getUse());
} else if (key.getType().equals(KeyType.OKP)) {
return b.okp(key.getPublicKey(), key.getUse());
}
return null;
}
}

View file

@ -21,4 +21,5 @@ org.keycloak.broker.saml.SAMLIdentityProviderFactory
org.keycloak.broker.oauth.OAuth2IdentityProviderFactory
org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory
org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory
org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory
org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory
org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory

View file

@ -0,0 +1,280 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.broker.trust;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantConfig;
import org.keycloak.broker.oidc.OIDCIdentityProvider;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.broker.provider.TrustMaterialIdentityProvider;
import org.keycloak.broker.provider.TrustMaterialRequest;
import org.keycloak.broker.provider.TrustMaterialResolver;
import org.keycloak.broker.trust.DefaultTrustIdentityProvider;
import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig;
import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.util.PemUtils;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.testframework.annotations.InjectHttpServer;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.annotations.TestSetup;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.testframework.util.HttpServerUtil;
import org.keycloak.util.JsonSerialization;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = TrustMaterialIdentityProviderTest.TrustMaterialServerConfig.class)
public class TrustMaterialIdentityProviderTest {
private static final String DEFAULT_INLINE_ALIAS = "trust-material-default-inline";
private static final String DEFAULT_URL_ALIAS = "trust-material-default-url";
private static final String DEFAULT_DISABLED_ALIAS = "trust-material-default-disabled";
private static final String OIDC_ALIAS = "trust-material-oidc";
private static final String KEY_ID = "trust-material-key";
private static final String ALGORITHM = "PS256";
private static final String ISSUER = "https://issuer.example.test";
private static String trustedJwks;
private static String trustedPublicKey;
@InjectRunOnServer
RunOnServerClient runOnServer;
@InjectHttpServer
HttpServer httpServer;
@TestSetup
public void setup() throws Exception {
KeyPair key = createRsaKeyPair();
JWK jwk = JWKBuilder.create()
.kid(KEY_ID)
.algorithm(ALGORITHM)
.rsa(key.getPublic());
JSONWebKeySet jwks = new JSONWebKeySet();
jwks.setKeys(new JWK[] { jwk });
trustedJwks = JsonSerialization.writeValueAsString(jwks);
trustedPublicKey = PemUtils.encodeKey(key.getPublic());
}
@BeforeEach
public void configureIdentityProviders() {
String jwks = trustedJwks;
String publicKey = trustedPublicKey;
runOnServer.run(session -> {
RealmModel realm = session.getContext().getRealm();
configureTrustIdentityProvider(realm, DEFAULT_INLINE_ALIAS, DefaultTrustIdentityProviderFactory.PROVIDER_ID, true,
Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks));
configureTrustIdentityProvider(realm, DEFAULT_DISABLED_ALIAS, DefaultTrustIdentityProviderFactory.PROVIDER_ID, false,
Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks));
configureTrustIdentityProvider(realm, OIDC_ALIAS, OIDCIdentityProviderFactory.PROVIDER_ID, true,
Map.of(
OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.FALSE.toString(),
JWTAuthorizationGrantConfig.PUBLIC_KEY_SIGNATURE_VERIFIER, publicKey,
JWTAuthorizationGrantConfig.PUBLIC_KEY_SIGNATURE_VERIFIER_KEY_ID, KEY_ID,
IdentityProviderModel.ISSUER, ISSUER));
});
}
@Test
public void defaultTrustIdentityProviderResolvesInlineTrustedJwks() {
runOnServer.run(session -> {
TrustMaterialIdentityProvider<?> provider = getTrustMaterialProvider(session.getContext().getRealm(), session, DEFAULT_INLINE_ALIAS);
assertInstanceOf(DefaultTrustIdentityProvider.class, provider);
JWK jwk = provider.resolveKeys(matchingRequest()).findFirst().orElseThrow();
assertEquals(KEY_ID, jwk.getKeyId());
assertEquals(ALGORITHM, jwk.getAlgorithm());
});
}
@Test
public void defaultTrustIdentityProviderResolvesTrustedJwksUrl() {
String path = "/trust-material-jwks";
httpServer.createContext(path, exchange -> HttpServerUtil.sendResponse(exchange, 200,
Map.of("Content-Type", List.of("application/json")), trustedJwks));
try {
String jwksUrl = "http://" + httpServer.getAddress().getHostString() + ":" + httpServer.getAddress().getPort() + path;
runOnServer.run(session -> configureTrustIdentityProvider(session.getContext().getRealm(), DEFAULT_URL_ALIAS,
DefaultTrustIdentityProviderFactory.PROVIDER_ID, true,
Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS_URL, jwksUrl)));
runOnServer.run(session -> {
TrustMaterialIdentityProvider<?> provider = getTrustMaterialProvider(session.getContext().getRealm(), session, DEFAULT_URL_ALIAS);
assertInstanceOf(DefaultTrustIdentityProvider.class, provider);
JWK jwk = provider.resolveKeys(matchingRequest()).findFirst().orElseThrow();
assertEquals(KEY_ID, jwk.getKeyId());
assertEquals(ALGORITHM, jwk.getAlgorithm());
});
} finally {
httpServer.removeContext(path);
}
}
@Test
public void trustMaterialResolverUsesEnabledProviderFromAliasList() {
runOnServer.run(session -> {
Optional<JWK> jwk = new TrustMaterialResolver().resolveKey(session,
"missing-alias, " + DEFAULT_DISABLED_ALIAS + ", " + DEFAULT_INLINE_ALIAS, matchingRequest());
assertTrue(jwk.isPresent());
assertEquals(KEY_ID, jwk.get().getKeyId());
assertEquals(ALGORITHM, jwk.get().getAlgorithm());
});
}
@Test
public void trustMaterialResolverReturnsEmptyForDisabledProvider() {
runOnServer.run(session -> assertTrue(new TrustMaterialResolver()
.resolveKey(session, DEFAULT_DISABLED_ALIAS, matchingRequest()).isEmpty()));
}
@Test
public void oidcIdentityProviderResolvesConfiguredPublicKey() {
runOnServer.run(session -> {
TrustMaterialIdentityProvider<?> provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS);
assertInstanceOf(OIDCIdentityProvider.class, provider);
JWK jwk = provider.resolveKeys(TrustMaterialRequest.builder()
.kid(KEY_ID)
.algorithm(ALGORITHM)
.issuer(ISSUER)
.build()).findFirst().orElseThrow();
assertEquals(KEY_ID, jwk.getKeyId());
assertEquals(ALGORITHM, jwk.getAlgorithm());
});
}
@Test
public void oidcIdentityProviderResolvesConfiguredPublicKeyWithoutKid() {
runOnServer.run(session -> {
TrustMaterialIdentityProvider<?> provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS);
JWK jwk = provider.resolveKeys(TrustMaterialRequest.builder()
.algorithm(ALGORITHM)
.issuer(ISSUER)
.build()).findFirst().orElseThrow();
assertEquals(KEY_ID, jwk.getKeyId());
assertEquals(ALGORITHM, jwk.getAlgorithm());
});
}
@Test
public void oidcIdentityProviderRejectsIssuerMismatch() {
runOnServer.run(session -> {
TrustMaterialIdentityProvider<?> provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS);
assertTrue(provider.resolveKeys(TrustMaterialRequest.builder()
.kid(KEY_ID)
.algorithm(ALGORITHM)
.issuer("https://issuer.invalid")
.build()).findAny().isEmpty());
});
}
@Test
public void oidcIdentityProviderDoesNotSplitConfiguredIssuer() {
runOnServer.run(session -> {
RealmModel realm = session.getContext().getRealm();
IdentityProviderModel model = realm.getIdentityProviderByAlias(OIDC_ALIAS);
Map<String, String> config = new HashMap<>(model.getConfig());
config.put(IdentityProviderModel.ISSUER, ISSUER + ", https://issuer2.example.test");
model.setConfig(config);
realm.updateIdentityProvider(model);
TrustMaterialIdentityProvider<?> provider = getTrustMaterialProvider(realm, session, OIDC_ALIAS);
assertTrue(provider.resolveKeys(TrustMaterialRequest.builder()
.kid(KEY_ID)
.algorithm(ALGORITHM)
.issuer(ISSUER)
.build()).findAny().isEmpty());
});
}
private static void configureTrustIdentityProvider(RealmModel realm, String alias, String providerId, boolean enabled,
Map<String, String> config) {
IdentityProviderModel trustIdp = realm.getIdentityProviderByAlias(alias);
if (trustIdp == null) {
trustIdp = new IdentityProviderModel();
trustIdp.setAlias(alias);
trustIdp.setProviderId(providerId);
trustIdp.setEnabled(enabled);
trustIdp.setConfig(config);
realm.addIdentityProvider(trustIdp);
} else {
trustIdp.setProviderId(providerId);
trustIdp.setEnabled(enabled);
trustIdp.setConfig(config);
realm.updateIdentityProvider(trustIdp);
}
}
private static TrustMaterialIdentityProvider<?> getTrustMaterialProvider(RealmModel realm, KeycloakSession session, String alias) {
IdentityProviderModel model = realm.getIdentityProviderByAlias(alias);
return IdentityBrokerService.getIdentityProvider(session, model, TrustMaterialIdentityProvider.class);
}
private static TrustMaterialRequest matchingRequest() {
return TrustMaterialRequest.builder()
.kid(KEY_ID)
.algorithm(ALGORITHM)
.build();
}
private static KeyPair createRsaKeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
public static class TrustMaterialServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.CLIENT_AUTH_ABCA);
}
}
}

View file

@ -17,19 +17,22 @@
package org.keycloak.tests.oid4vc.abca;
import java.security.PublicKey;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator;
import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ABCAConfig;
import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationJwt;
import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationPoPJwt;
import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig;
import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.Proofs;
@ -47,7 +50,7 @@ import org.keycloak.util.JsonSerialization;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS;
import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS;
import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_HEADER;
import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_POP_HEADER;
import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH;
@ -63,33 +66,63 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithABCAEnabled.class)
public class OIDCAttestationBasedClientAuthenticationTest extends OID4VCIssuerTestBase {
private static final String ATTESTER_DEFAULT_TRUST_IDP_ALIAS = "abca-attester-default-trust";
private static OIDCClientAttester attester;
private static ABCAConfig abcaConfig;
private static String attesterJwks;
@TestSetup
public void configure() {
public void configure() throws Exception {
var kw = createRsaKeyPair("openid-abca-attester-key");
JWK jwk = JWKBuilder.create()
.kid(kw.getKid())
.algorithm(kw.getAlgorithm())
.rsa(kw.getPublicKey(), kw.getCertificate());
abcaConfig = new ABCAConfig().setKeys(List.of(jwk));
.rsa(kw.getPublicKey());
JSONWebKeySet jwks = new JSONWebKeySet();
jwks.setKeys(new JWK[] { jwk });
attesterJwks = JsonSerialization.writeValueAsString(jwks);
attester = new OIDCMockClientAttester(kw);
}
@BeforeEach
void beforeEach() {
String abcaConfigValue = JsonSerialization.valueAsString(abcaConfig);
String jwks = attesterJwks;
runOnServer.run(session -> {
RealmModel realm = session.getContext().getRealm();
AuthenticatorConfigModel configModel = new AuthenticatorConfigModel();
configModel.setAlias(AttestationBasedClientAuthenticator.PROVIDER_ID);
configModel.setConfig(Map.of(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS, abcaConfigValue));
realm.addAuthenticatorConfig(configModel);
configureTrustIdentityProvider(realm, ATTESTER_DEFAULT_TRUST_IDP_ALIAS,
DefaultTrustIdentityProviderFactory.PROVIDER_ID,
Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks));
});
setClientTrustSource(ATTESTER_DEFAULT_TRUST_IDP_ALIAS);
oauth.client(abcaClient.getClientId(), null);
}
private static void configureTrustIdentityProvider(RealmModel realm, String alias, String providerId, Map<String, String> config) {
IdentityProviderModel trustIdp = realm.getIdentityProviderByAlias(alias);
if (trustIdp == null) {
trustIdp = new IdentityProviderModel();
trustIdp.setAlias(alias);
trustIdp.setProviderId(providerId);
trustIdp.setEnabled(true);
trustIdp.setConfig(config);
realm.addIdentityProvider(trustIdp);
} else {
trustIdp.setProviderId(providerId);
trustIdp.setEnabled(true);
trustIdp.setConfig(config);
realm.updateIdentityProvider(trustIdp);
}
}
private void setClientTrustSource(String alias) {
Map<String, String> attributes = new HashMap<>(Optional.ofNullable(abcaClient.getAttributes()).orElse(Map.of()));
attributes.put(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS, alias);
abcaClient.setAttributes(attributes);
testRealm.admin().clients().get(abcaClient.getId()).update(abcaClient);
abcaClient = testRealm.admin().clients().get(abcaClient.getId()).toRepresentation();
}
@Test
public void testTokenEndpointAuthMethods() {
OIDCConfigurationRepresentation oidcConfiguration = oauth.doWellKnownRequest();
@ -137,6 +170,7 @@ public class OIDCAttestationBasedClientAuthenticationTest extends OID4VCIssuerTe
@Test
public void testClientAttestationHappyFlow() {
setClientTrustSource(ATTESTER_DEFAULT_TRUST_IDP_ALIAS);
var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope);
ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester);