mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-28 04:13:22 -04:00
Merge branch 'main' into issues/46204-update-db-schema-and-admin-REST-Api
This commit is contained in:
commit
91aca4b36d
56 changed files with 1311 additions and 567 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> wouldn't work for existing Liquibase scripts.</li>
|
||||
* <li>adding quotes to <code>@Column(name="VALUE")</code> annotations -> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -> wouldn't work for existing Liquibase scripts.</li>
|
||||
* <li>adding quotes to <code>@Column(name="VALUE")</code> annotations -> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue