diff --git a/.attach_pid308475 b/.attach_pid308475
new file mode 100644
index 0000000..e69de29
diff --git a/.github/changelog.yaml b/.github/changelog.yaml
new file mode 100644
index 0000000..0a5526e
--- /dev/null
+++ b/.github/changelog.yaml
@@ -0,0 +1,13 @@
+sections:
+ - title: Major changes
+ labels:
+ - "release/super-feature"
+ - title: Complete changelog
+ labels:
+ - "bug"
+ - "enhancement"
+ - "dependencies"
+template: |
+ {{ range $section := .Sections }}{{ if $section.Items }}### {{ $section.GetTitle }}{{ range $item := $section.Items }}
+ * [#{{ $item.GetID }}]({{ $item.GetURL }}) - {{ $item.GetTitle }}{{ end }}{{ end }}
+ {{ end }}
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..543ce22
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,14 @@
+version: 2
+updates:
+ - package-ecosystem: maven
+ directory: "/"
+ schedule:
+ interval: daily
+ labels:
+ - dependencies
+ - package-ecosystem: "docker"
+ directory: "/src/main/docker"
+ schedule:
+ interval: daily
+ labels:
+ - docker-image
\ No newline at end of file
diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml
new file mode 100644
index 0000000..7270213
--- /dev/null
+++ b/.github/workflows/build-branch.yml
@@ -0,0 +1,13 @@
+name: Build Feature Branch
+
+on:
+ push:
+ branches:
+ - '**'
+ - '!main'
+ - '!fix/[0-9]+.[0-9]+.x'
+
+jobs:
+ branch:
+ uses: onecx/ci-quarkus/.github/workflows/build-branch.yml@v1
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml
new file mode 100644
index 0000000..acff155
--- /dev/null
+++ b/.github/workflows/build-pr.yml
@@ -0,0 +1,9 @@
+name: Build Pull Request
+
+on:
+ pull_request:
+
+jobs:
+ pr:
+ uses: onecx/ci-quarkus/.github/workflows/build-pr.yml@v1
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
new file mode 100644
index 0000000..b04a59a
--- /dev/null
+++ b/.github/workflows/build-release.yml
@@ -0,0 +1,9 @@
+name: Build Release
+on:
+ push:
+ tags:
+ - '**'
+jobs:
+ release:
+ uses: onecx/ci-quarkus/.github/workflows/build-release.yml@v1
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..71d9db1
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,15 @@
+name: Build
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - 'main'
+ - 'fix/[0-9]+.[0-9]+.x'
+
+jobs:
+ build:
+ uses: onecx/ci-quarkus/.github/workflows/build.yml@v1
+ secrets: inherit
+ with:
+ helmEventTargetRepository: onecx/onecx-tenant
\ No newline at end of file
diff --git a/.github/workflows/create-fix-branch.yml b/.github/workflows/create-fix-branch.yml
new file mode 100644
index 0000000..92af624
--- /dev/null
+++ b/.github/workflows/create-fix-branch.yml
@@ -0,0 +1,7 @@
+name: Create Fix Branch
+on:
+ workflow_dispatch:
+jobs:
+ fix:
+ uses: onecx/ci-common/.github/workflows/create-fix-branch.yml@v1
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/create-new-build.yml b/.github/workflows/create-new-build.yml
new file mode 100644
index 0000000..5051e3c
--- /dev/null
+++ b/.github/workflows/create-new-build.yml
@@ -0,0 +1,9 @@
+name: Create new build
+
+on:
+ workflow_dispatch:
+
+jobs:
+ build:
+ uses: onecx/ci-common/.github/workflows/create-new-build.yml@v1
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
new file mode 100644
index 0000000..c97eb42
--- /dev/null
+++ b/.github/workflows/create-release.yml
@@ -0,0 +1,7 @@
+name: Create Release Version
+on:
+ workflow_dispatch:
+jobs:
+ release:
+ uses: onecx/ci-common/.github/workflows/create-release.yml@v1
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
new file mode 100644
index 0000000..e43d7dc
--- /dev/null
+++ b/.github/workflows/documentation.yml
@@ -0,0 +1,10 @@
+name: Update documentation
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'docs/**'
+jobs:
+ release:
+ uses: onecx/ci-common/.github/workflows/documentation.yml@v1
+ secrets: inherit
diff --git a/.github/workflows/sonar-pr.yml b/.github/workflows/sonar-pr.yml
new file mode 100644
index 0000000..02c3e1e
--- /dev/null
+++ b/.github/workflows/sonar-pr.yml
@@ -0,0 +1,12 @@
+name: Sonar Pull Request
+
+on:
+ workflow_run:
+ workflows: ["Build Pull Request"]
+ types:
+ - completed
+
+jobs:
+ pr:
+ uses: onecx/ci-quarkus/.github/workflows/quarkus-pr-sonar.yml@v1
+ secrets: inherit
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1b89851
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,48 @@
+#Maven
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+release.properties
+.flattened-pom.xml
+
+# Eclipse
+.project
+.classpath
+.settings/
+bin/
+
+# IntelliJ
+.idea
+*.ipr
+*.iml
+*.iws
+
+# NetBeans
+nb-configuration.xml
+
+# Visual Studio Code
+.vscode
+.factorypath
+
+# OSX
+.DS_Store
+
+# Vim
+*.swp
+*.swo
+
+# patch
+*.orig
+*.rej
+
+# Local environment
+.env
+
+# Plugin directory
+/.quarkus/cli/plugins/
+
+# Chart
+Chart.lock
+local.values.yaml
+src/main/helm/charts
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..d9d996b
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,148 @@
+
+
+
+ 4.0.0
+
+
+ org.tkit.onecx
+ onecx-quarkus3-parent
+ 0.32.0
+
+
+ onecx-iam-kc-client-operator
+ 999-SNAPSHOT
+
+
+
+ io.quarkiverse.operatorsdk
+ quarkus-operator-sdk-bundle-generator
+
+
+ io.quarkiverse.operatorsdk
+ quarkus-operator-sdk
+
+
+
+
+ org.tkit.quarkus.lib
+ tkit-quarkus-log-cdi
+
+
+ org.tkit.quarkus.lib
+ tkit-quarkus-log-rs
+
+
+ org.tkit.quarkus.lib
+ tkit-quarkus-log-json
+
+
+
+
+ io.quarkus
+ quarkus-arc
+
+
+ io.quarkus
+ quarkus-micrometer-registry-prometheus
+
+
+ io.quarkus
+ quarkus-opentelemetry
+
+
+ io.quarkus
+ quarkus-keycloak-admin-client-reactive
+
+
+ io.quarkus
+ quarkus-rest-client-reactive
+
+
+ io.quarkus
+ quarkus-rest-client-reactive-jackson
+
+
+
+
+ org.mapstruct
+ mapstruct
+
+
+
+ io.quarkus
+ quarkus-oidc
+ provided
+
+
+
+
+ io.quarkus
+ quarkus-junit5
+ test
+
+
+ io.swagger.parser.v3
+ swagger-parser
+ test
+
+
+ io.quarkus
+ quarkus-junit5-mockito
+ test
+
+
+ io.quarkus
+ quarkus-test-keycloak-server
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven.compiler-plugin.version}
+
+
+ -parameters
+ -Amapstruct.defaultComponentModel=jakarta-cdi
+ -Amapstruct.defaultInjectionStrategy=constructor
+ -Amapstruct.unmappedTargetPolicy=ERROR
+ -Amapstruct.unmappedSourcePolicy=IGNORE
+
+
+
+ org.hibernate
+ hibernate-jpamodelgen
+ ${quarkus.hibernate-orm.version}
+
+
+ io.quarkus
+ quarkus-panache-common
+ ${quarkus.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+ org.projectlombok
+ lombok
+ ${projectlombok.version}
+
+
+ io.quarkus
+ quarkus-extension-processor
+ ${quarkus.version}
+
+
+
+
+
+
+
+
+
diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm
new file mode 100644
index 0000000..327bf74
--- /dev/null
+++ b/src/main/docker/Dockerfile.jvm
@@ -0,0 +1,7 @@
+FROM ghcr.io/onecx/docker-quarkus-jvm:0.4.0
+
+COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/
+COPY --chown=185 target/quarkus-app/*.jar /deployments/
+COPY --chown=185 target/quarkus-app/app/ /deployments/app/
+COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/
+USER 185
\ No newline at end of file
diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native
new file mode 100644
index 0000000..7aaeff9
--- /dev/null
+++ b/src/main/docker/Dockerfile.native
@@ -0,0 +1,3 @@
+FROM ghcr.io/onecx/docker-quarkus-native:0.2.0-rc.1
+
+COPY --chown=1001:root target/*-runner /work/application
\ No newline at end of file
diff --git a/src/main/helm/Chart.yaml b/src/main/helm/Chart.yaml
new file mode 100644
index 0000000..37abaae
--- /dev/null
+++ b/src/main/helm/Chart.yaml
@@ -0,0 +1,17 @@
+apiVersion: v2
+name: onecx-iam-kc-client-operator
+version: 0.0.0
+appVersion: 0.0.0
+description: Onecx IAM KC client operator
+keywords:
+ - keycloak client
+sources:
+ - https://github.com/onecx/onecx-iam-kc-client-operator
+maintainers:
+ - name: Tkit Developer
+ email: tkit_dev@1000kit.org
+dependencies:
+ - name: helm-quarkus-app
+ alias: app
+ version: ^0
+ repository: oci://ghcr.io/onecx/charts
diff --git a/src/main/helm/crds/keycloakclients.onecx.tkit.org-v1.yml b/src/main/helm/crds/keycloakclients.onecx.tkit.org-v1.yml
new file mode 100644
index 0000000..bb952b5
--- /dev/null
+++ b/src/main/helm/crds/keycloakclients.onecx.tkit.org-v1.yml
@@ -0,0 +1,94 @@
+# Generated by Fabric8 CRDGenerator, manual edits might get overwritten!
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: keycloakclients.onecx.tkit.org
+spec:
+ group: onecx.tkit.org
+ names:
+ kind: KeycloakClient
+ plural: keycloakclients
+ singular: keycloakclient
+ scope: Namespaced
+ versions:
+ - name: v1
+ schema:
+ openAPIV3Schema:
+ properties:
+ spec:
+ properties:
+ kcConfig:
+ properties:
+ attributes:
+ additionalProperties:
+ type: string
+ type: object
+ bearerOnly:
+ type: boolean
+ clientAuthenticatorType:
+ type: string
+ clientId:
+ type: string
+ defaultClientScopes:
+ items:
+ type: string
+ type: array
+ description:
+ type: string
+ directAccessGrantsEnabled:
+ type: boolean
+ enabled:
+ type: boolean
+ implicitFlowEnabled:
+ type: boolean
+ optionalClientScopes:
+ items:
+ type: string
+ type: array
+ protocol:
+ type: string
+ publicClient:
+ type: boolean
+ redirectUris:
+ items:
+ type: string
+ type: array
+ secret:
+ type: string
+ serviceAccountsEnabled:
+ type: boolean
+ standardFlowEnabled:
+ type: boolean
+ webOrigins:
+ items:
+ type: string
+ type: array
+ type: object
+ realm:
+ type: string
+ type:
+ type: string
+ type: object
+ status:
+ properties:
+ clientId:
+ type: string
+ message:
+ type: string
+ observedGeneration:
+ type: integer
+ response-code:
+ type: integer
+ status:
+ enum:
+ - CREATED
+ - ERROR
+ - UNDEFINED
+ - UPDATED
+ type: string
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/src/main/helm/templates/kc-client-cluster-role-binding.yaml b/src/main/helm/templates/kc-client-cluster-role-binding.yaml
new file mode 100644
index 0000000..1c2995f
--- /dev/null
+++ b/src/main/helm/templates/kc-client-cluster-role-binding.yaml
@@ -0,0 +1,43 @@
+{{ if eq $.Values.watchNamespaces "JOSDK_WATCH_CURRENT" }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-role-binding
+roleRef:
+ kind: ClusterRole
+ apiGroup: rbac.authorization.k8s.io
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-cluster-role
+subjects:
+ - kind: ServiceAccount
+ name: {{ .Release.Name }}-{{ .Values.app.name }}
+{{ else if eq $.Values.watchNamespaces "JOSDK_ALL_NAMESPACES" }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-role-binding
+roleRef:
+ kind: ClusterRole
+ apiGroup: rbac.authorization.k8s.io
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-cluster-role
+subjects:
+ - kind: ServiceAccount
+ name: {{ .Release.Name }}-{{ .Values.app.name }}
+ namespace: {{ $.Release.Namespace }}
+{{ else }}
+{{ range $anamespace := ( split "," $.Values.watchNamespaces ) }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-role-binding
+ namespace: {{ $anamespace }}
+roleRef:
+ kind: ClusterRole
+ apiGroup: rbac.authorization.k8s.io
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-cluster-role
+subjects:
+ - kind: ServiceAccount
+ name: {{ .Release.Name }}-{{ .Values.app.name }}
+ namespace: {{ $.Release.Namespace }}
+---
+{{- end }}
+{{- end }}
\ No newline at end of file
diff --git a/src/main/helm/templates/kc-client-cluster-role.yaml b/src/main/helm/templates/kc-client-cluster-role.yaml
new file mode 100644
index 0000000..3bd5b9e
--- /dev/null
+++ b/src/main/helm/templates/kc-client-cluster-role.yaml
@@ -0,0 +1,19 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-cluster-role
+rules:
+ - apiGroups:
+ - "onecx.tkit.org"
+ resources:
+ - "keycloakclients"
+ - "keycloakclients/status"
+ - "keycloakclients/finalizers"
+ verbs:
+ - "get"
+ - "list"
+ - "watch"
+ - "patch"
+ - "update"
+ - "create"
+ - "delete"
\ No newline at end of file
diff --git a/src/main/helm/templates/operator-cluster-role-binding.yaml b/src/main/helm/templates/operator-cluster-role-binding.yaml
new file mode 100644
index 0000000..5e739ff
--- /dev/null
+++ b/src/main/helm/templates/operator-cluster-role-binding.yaml
@@ -0,0 +1,12 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-validating-role-binding
+roleRef:
+ kind: ClusterRole
+ apiGroup: rbac.authorization.k8s.io
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-validating-cluster-role
+subjects:
+ - kind: ServiceAccount
+ name: {{ .Release.Name }}-{{ .Values.app.name }}
+ namespace: {{ .Release.Namespace }}
diff --git a/src/main/helm/templates/operator-cluster-role.yaml b/src/main/helm/templates/operator-cluster-role.yaml
new file mode 100644
index 0000000..4d51a68
--- /dev/null
+++ b/src/main/helm/templates/operator-cluster-role.yaml
@@ -0,0 +1,12 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: {{ .Release.Name }}-{{ .Values.app.name }}-validating-cluster-role
+rules:
+ - apiGroups:
+ - apiextensions.k8s.io
+ resources:
+ - customresourcedefinitions
+ verbs:
+ - get
+ - list
diff --git a/src/main/helm/templates/service-account.yaml b/src/main/helm/templates/service-account.yaml
new file mode 100644
index 0000000..56ccce8
--- /dev/null
+++ b/src/main/helm/templates/service-account.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ .Release.Name }}-{{ .Values.app.name }}
\ No newline at end of file
diff --git a/src/main/helm/values.yaml b/src/main/helm/values.yaml
new file mode 100644
index 0000000..a6e3620
--- /dev/null
+++ b/src/main/helm/values.yaml
@@ -0,0 +1,17 @@
+app:
+ name: operator
+ image:
+ repository: "onecx/onecx-iam-kc-client-operator"
+ env:
+ # See watchNamespaces
+ "QUARKUS_OPERATOR_SDK_CONTROLLERS_KC_NAMESPACES": "JOSDK_WATCH_CURRENT"
+ envCustom:
+ - name: KUBERNETES_NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ serviceAccount:
+ enabled: true
+
+# Values: JOSDK_WATCH_CURRENT, JOSDK_ALL_NAMESPACES or comma separated list of namespaces
+watchNamespaces: "JOSDK_WATCH_CURRENT"
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KCConfig.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KCConfig.java
new file mode 100644
index 0000000..93a93b0
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KCConfig.java
@@ -0,0 +1,175 @@
+package org.tkit.onecx.iam.kc.client.operator;
+
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class KCConfig {
+
+ @JsonProperty(required = true)
+ private String clientId;
+
+ private String description;
+
+ private Boolean enabled;
+
+ private String clientAuthenticatorType;
+
+ private String password;
+
+ private List redirectUris;
+
+ private List webOrigins;
+
+ private Boolean bearerOnly;
+
+ private Boolean standardFlowEnabled;
+ private Boolean implicitFlowEnabled;
+ private Boolean directAccessGrantsEnabled;
+ private Boolean serviceAccountsEnabled;
+ private Boolean publicClient;
+
+ private String protocol;
+
+ private Map attributes;
+
+ private List defaultClientScopes;
+ private List optionalClientScopes;
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public void setClientId(String clientId) {
+ this.clientId = clientId;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public Boolean getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(Boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getClientAuthenticatorType() {
+ return clientAuthenticatorType;
+ }
+
+ public void setClientAuthenticatorType(String clientAuthenticatorType) {
+ this.clientAuthenticatorType = clientAuthenticatorType;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public List getRedirectUris() {
+ return redirectUris;
+ }
+
+ public void setRedirectUris(List redirectUris) {
+ this.redirectUris = redirectUris;
+ }
+
+ public List getWebOrigins() {
+ return webOrigins;
+ }
+
+ public void setWebOrigins(List webOrigins) {
+ this.webOrigins = webOrigins;
+ }
+
+ public Boolean getBearerOnly() {
+ return bearerOnly;
+ }
+
+ public void setBearerOnly(Boolean bearerOnly) {
+ this.bearerOnly = bearerOnly;
+ }
+
+ public Boolean getStandardFlowEnabled() {
+ return standardFlowEnabled;
+ }
+
+ public void setStandardFlowEnabled(Boolean standardFlowEnabled) {
+ this.standardFlowEnabled = standardFlowEnabled;
+ }
+
+ public Boolean getImplicitFlowEnabled() {
+ return implicitFlowEnabled;
+ }
+
+ public void setImplicitFlowEnabled(Boolean implicitFlowEnabled) {
+ this.implicitFlowEnabled = implicitFlowEnabled;
+ }
+
+ public Boolean getDirectAccessGrantsEnabled() {
+ return directAccessGrantsEnabled;
+ }
+
+ public void setDirectAccessGrantsEnabled(Boolean directAccessGrantsEnabled) {
+ this.directAccessGrantsEnabled = directAccessGrantsEnabled;
+ }
+
+ public Boolean getServiceAccountsEnabled() {
+ return serviceAccountsEnabled;
+ }
+
+ public void setServiceAccountsEnabled(Boolean serviceAccountsEnabled) {
+ this.serviceAccountsEnabled = serviceAccountsEnabled;
+ }
+
+ public Boolean getPublicClient() {
+ return publicClient;
+ }
+
+ public void setPublicClient(Boolean publicClient) {
+ this.publicClient = publicClient;
+ }
+
+ public String getProtocol() {
+ return protocol;
+ }
+
+ public void setProtocol(String protocol) {
+ this.protocol = protocol;
+ }
+
+ public Map getAttributes() {
+ return attributes;
+ }
+
+ public void setAttributes(Map attributes) {
+ this.attributes = attributes;
+ }
+
+ public List getDefaultClientScopes() {
+ return defaultClientScopes;
+ }
+
+ public void setDefaultClientScopes(List defaultClientScopes) {
+ this.defaultClientScopes = defaultClientScopes;
+ }
+
+ public List getOptionalClientScopes() {
+ return optionalClientScopes;
+ }
+
+ public void setOptionalClientScopes(List optionalClientScopes) {
+ this.optionalClientScopes = optionalClientScopes;
+ }
+}
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClient.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClient.java
new file mode 100644
index 0000000..90c8faa
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClient.java
@@ -0,0 +1,10 @@
+package org.tkit.onecx.iam.kc.client.operator;
+
+import io.fabric8.kubernetes.api.model.Namespaced;
+import io.fabric8.kubernetes.client.CustomResource;
+import io.fabric8.kubernetes.model.annotation.*;
+
+@Version("v1")
+@Group("onecx.tkit.org")
+public class KeycloakClient extends CustomResource implements Namespaced {
+}
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientController.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientController.java
new file mode 100644
index 0000000..66867f2
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientController.java
@@ -0,0 +1,181 @@
+package org.tkit.onecx.iam.kc.client.operator;
+
+import java.util.Base64;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import jakarta.inject.Inject;
+import jakarta.ws.rs.WebApplicationException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.tkit.onecx.iam.kc.client.operator.service.KeycloakAdminService;
+import org.tkit.onecx.iam.kc.client.operator.service.TypeNotSupportedException;
+
+import io.fabric8.kubernetes.api.model.Secret;
+import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.*;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.EventSource;
+import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper;
+import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter;
+import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+
+@ControllerConfiguration(name = "kc", onAddFilter = KeycloakClientController.AddFilter.class, onUpdateFilter = KeycloakClientController.UpdateFilter.class)
+public class KeycloakClientController
+ implements Reconciler, ErrorStatusHandler, Cleaner,
+ EventSourceInitializer {
+
+ private static final Logger log = LoggerFactory.getLogger(KeycloakClientController.class);
+
+ @Inject
+ KeycloakAdminService service;
+
+ @Override
+ public ErrorStatusUpdateControl updateErrorStatus(KeycloakClient keycloakClient,
+ Context context, Exception e) {
+ int responseCode = -1;
+ String message = e.getMessage();
+ if (e.getCause() instanceof WebApplicationException re) {
+ responseCode = re.getResponse().getStatus();
+ message = re.getMessage();
+ }
+ if (e.getCause() instanceof TypeNotSupportedException tnse) {
+ // set the response code as 500 INTERNAL Server error
+ responseCode = 500;
+ message = tnse.getMessage();
+ }
+ if (e.getCause() instanceof MissingMandatoryKeyException mmke) {
+ // set the response code as 500 INTERNAL Server error
+ responseCode = 500;
+ message = mmke.getMessage();
+ }
+
+ log.error("Error reconcile resource", e);
+ var status = new KeycloakClientStatus();
+ String clientId = null;
+ if (keycloakClient.getSpec().getKcConfig() != null) {
+ clientId = keycloakClient.getSpec().getKcConfig().getClientId() != null
+ ? keycloakClient.getSpec().getKcConfig().getClientId()
+ : null;
+ }
+ status.setClientId(clientId);
+ status.setResponseCode(responseCode);
+ status.setStatus(KeycloakClientStatus.Status.ERROR);
+ status.setMessage(message);
+ keycloakClient.setStatus(status);
+ return ErrorStatusUpdateControl.updateStatus(keycloakClient);
+ }
+
+ @Override
+ public UpdateControl reconcile(KeycloakClient keycloakClient, Context context)
+ throws Exception {
+ log.info("Reconcile resource: {} appId: {}", keycloakClient.getMetadata().getName(),
+ keycloakClient.getSpec().getKcConfig().getClientId());
+
+ Optional secret = context.getSecondaryResource(Secret.class);
+ if (secret.isPresent()) {
+
+ String name = keycloakClient.getMetadata().getName();
+ String namespace = keycloakClient.getMetadata().getNamespace();
+
+ log.info("Reconcile password from secret for client: {} namespace: {}", name, namespace);
+ byte[] password = createRequestData(keycloakClient.getSpec(), secret.get());
+ keycloakClient.getSpec().getKcConfig().setPassword(new String(password));
+ }
+
+ int responseCode = service.createClient(keycloakClient);
+
+ updateStatusPojo(keycloakClient, responseCode);
+ log.info("Resource '{}' reconciled - updating status", keycloakClient.getMetadata().getName());
+ return UpdateControl.updateStatus(keycloakClient);
+ }
+
+ private void updateStatusPojo(KeycloakClient keycloakClient, int responseCode) {
+ KeycloakClientStatus result = new KeycloakClientStatus();
+ KeycloakClientSpec spec = keycloakClient.getSpec();
+ result.setClientId(spec.getKcConfig().getClientId());
+ result.setResponseCode(responseCode);
+ var status = KeycloakClientStatus.Status.UNDEFINED;
+ if (responseCode == 200) {
+ status = KeycloakClientStatus.Status.UPDATED;
+ }
+ if (responseCode == 201) {
+ status = KeycloakClientStatus.Status.CREATED;
+ }
+
+ result.setStatus(status);
+ keycloakClient.setStatus(result);
+ }
+
+ @Override
+ public DeleteControl cleanup(KeycloakClient keycloakclient, Context context) {
+ service.deleteClient(keycloakclient);
+ return DeleteControl.defaultDelete();
+ }
+
+ @Override
+ public Map prepareEventSources(EventSourceContext context) {
+ final SecondaryToPrimaryMapper webappsMatchingTomcatName = (Secret t) -> context.getPrimaryCache()
+ .list(keycloakClient -> {
+ if (keycloakClient.getSpec() != null) {
+ return t.getMetadata().getName().equals(keycloakClient.getSpec().getPasswordSecrets());
+ }
+ return false;
+ })
+ .map(ResourceID::fromResource)
+ .collect(Collectors.toSet());
+
+ InformerConfiguration configuration = InformerConfiguration.from(Secret.class, context)
+ .withSecondaryToPrimaryMapper(webappsMatchingTomcatName)
+ .withPrimaryToSecondaryMapper(
+ (KeycloakClient primary) -> Set.of(new ResourceID(primary.getSpec().getPasswordSecrets(),
+ primary.getMetadata().getNamespace())))
+ .build();
+ return EventSourceInitializer
+ .nameEventSources(new InformerEventSource<>(configuration, context));
+ }
+
+ public static class AddFilter implements OnAddFilter {
+
+ @Override
+ public boolean accept(KeycloakClient resource) {
+ return resource.getSpec() != null;
+ }
+ }
+
+ public static class UpdateFilter implements OnUpdateFilter {
+
+ @Override
+ public boolean accept(KeycloakClient newResource, KeycloakClient oldResource) {
+ return newResource.getSpec() != null;
+ }
+ }
+
+ private static byte[] createRequestData(KeycloakClientSpec spec, Secret secret) throws MissingMandatoryKeyException {
+ Map data = secret.getData();
+
+ String key = spec.getPasswordKey();
+ if (key == null) {
+ throw new MissingMandatoryKeyException("Secret key is mandatory. No key found!");
+ }
+ if (!data.containsKey(key)) {
+ throw new MissingMandatoryKeyException("Secret key is mandatory. No key secret found!");
+ }
+ String value = data.get(key);
+ if (value.isEmpty()) {
+ throw new MissingMandatoryKeyException("Secret key '" + key + "' is mandatory. No value found!");
+ }
+ return Base64.getDecoder().decode(value);
+ }
+
+ public static class MissingMandatoryKeyException extends RuntimeException {
+
+ public MissingMandatoryKeyException(String msg) {
+ super(msg);
+ }
+ }
+}
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientSpec.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientSpec.java
new file mode 100644
index 0000000..4476523
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientSpec.java
@@ -0,0 +1,60 @@
+package org.tkit.onecx.iam.kc.client.operator;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class KeycloakClientSpec {
+
+ private String realm;
+
+ @JsonProperty(required = true)
+ private String type;
+
+ private String passwordSecrets;
+
+ private String passwordKey;
+
+ @JsonProperty(required = true)
+ private KCConfig kcConfig;
+
+ public String getRealm() {
+ return realm;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public KCConfig getKcConfig() {
+ return kcConfig;
+ }
+
+ public void setKcConfig(KCConfig kcConfig) {
+ this.kcConfig = kcConfig;
+ }
+
+ public void setRealm(String realm) {
+ this.realm = realm;
+ }
+
+ public String getPasswordSecrets() {
+ return passwordSecrets;
+ }
+
+ public void setPasswordSecrets(String passwordSecrets) {
+ this.passwordSecrets = passwordSecrets;
+ }
+
+ public String getPasswordKey() {
+ return passwordKey;
+ }
+
+ public void setPasswordKey(String passwordKey) {
+ this.passwordKey = passwordKey;
+ }
+}
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientStatus.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientStatus.java
new file mode 100644
index 0000000..670422d
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientStatus.java
@@ -0,0 +1,63 @@
+package org.tkit.onecx.iam.kc.client.operator;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import io.javaoperatorsdk.operator.api.ObservedGenerationAwareStatus;
+
+public class KeycloakClientStatus extends ObservedGenerationAwareStatus {
+
+ @JsonProperty("clientId")
+ private String clientId;
+
+ @JsonProperty("response-code")
+ private int responseCode;
+
+ @JsonProperty("status")
+ private Status status;
+
+ @JsonProperty("message")
+ private String message;
+
+ public enum Status {
+
+ ERROR,
+
+ CREATED,
+
+ UPDATED,
+
+ UNDEFINED;
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public void setClientId(String clientId) {
+ this.clientId = clientId;
+ }
+
+ public int getResponseCode() {
+ return responseCode;
+ }
+
+ public void setResponseCode(int responseCode) {
+ this.responseCode = responseCode;
+ }
+
+ public Status getStatus() {
+ return status;
+ }
+
+ public void setStatus(Status status) {
+ this.status = status;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+}
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCClientConfig.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCClientConfig.java
new file mode 100644
index 0000000..313ec2e
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCClientConfig.java
@@ -0,0 +1,28 @@
+package org.tkit.onecx.iam.kc.client.operator.config;
+
+import java.util.Map;
+
+import io.quarkus.runtime.annotations.ConfigPhase;
+import io.quarkus.runtime.annotations.ConfigRoot;
+import io.smallrye.config.ConfigMapping;
+import io.smallrye.config.WithDefault;
+import io.smallrye.config.WithName;
+
+@ConfigMapping(prefix = "onecx.iam.kc.client")
+@ConfigRoot(phase = ConfigPhase.RUN_TIME)
+public interface KCClientConfig {
+
+ /**
+ * Define realm where to insert/update/delete the clients
+ */
+ @WithName("realm")
+ @WithDefault("onecx")
+ String realm();
+
+ /**
+ * Default configuration for the (ui|machine) clients
+ */
+ @WithName("config")
+ Map config();
+
+}
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCDefaultConfig.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCDefaultConfig.java
new file mode 100644
index 0000000..0455739
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCDefaultConfig.java
@@ -0,0 +1,111 @@
+package org.tkit.onecx.iam.kc.client.operator.config;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import io.smallrye.config.WithDefault;
+import io.smallrye.config.WithName;
+
+public interface KCDefaultConfig {
+
+ /**
+ * Add default scopes from realm to the client.
+ */
+ @WithName("add-def-scopes")
+ @WithDefault(value = "true")
+ Boolean addDefaultScopes();
+
+ /**
+ * Enable the client
+ */
+ @WithName("enabled")
+ @WithDefault(value = "true")
+ Boolean enabled();
+
+ /**
+ * Authentication type.
+ */
+ @WithName("auth-type")
+ @WithDefault("client-secret")
+ String clientAuthenticatorType();
+
+ /**
+ * List of redirect uris.
+ */
+ @WithName("redirect-uris")
+ Optional> redirectUris();
+
+ /**
+ * List of web origins
+ */
+ @WithName("web-origins")
+ Optional> webOrigins();
+
+ /**
+ * Bearer token only.
+ */
+ @WithName("bearer-only")
+ @WithDefault(value = "false")
+ Boolean bearerOnly();
+
+ /**
+ * Standard flow enabled.
+ */
+ @WithName("standard-flow")
+ @WithDefault("false")
+ Boolean standardFlowEnabled();
+
+ /**
+ * Implicit flow enabled.
+ */
+ @WithName("implicit-flow")
+ @WithDefault("false")
+ Boolean implicitFlowEnabled();
+
+ /**
+ * Enable direct access grants.
+ */
+ @WithName("direct-access")
+ @WithDefault("false")
+ Boolean directAccessGrantsEnabled();
+
+ /**
+ * Enable service account.
+ */
+ @WithName("service-account")
+ @WithDefault("true")
+ Boolean serviceAccountsEnabled();
+
+ /**
+ * Public client flag.
+ */
+ @WithName("public")
+ @WithDefault("false")
+ Boolean publicClient();
+
+ /**
+ * Protocol used with the client.
+ */
+ @WithName("protocol")
+ @WithDefault("openid-connect")
+ String protocol();
+
+ /**
+ * Attributes map for the client.
+ */
+ @WithName("attributes")
+ Map attributes();
+
+ /**
+ * Default client scopes.
+ */
+ @WithName("default-scopes")
+ Optional> defaultClientScopes();
+
+ /**
+ * Optional client scopes.
+ */
+ @WithName("optional-scopes")
+ Optional> optionalClientScopes();
+}
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/KeycloakAdminService.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/KeycloakAdminService.java
new file mode 100644
index 0000000..92a6be6
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/KeycloakAdminService.java
@@ -0,0 +1,254 @@
+package org.tkit.onecx.iam.kc.client.operator.service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.context.control.ActivateRequestContext;
+import jakarta.inject.Inject;
+
+import org.keycloak.admin.client.Keycloak;
+import org.keycloak.admin.client.resource.ClientResource;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.ClientScopeRepresentation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.tkit.onecx.iam.kc.client.operator.KCConfig;
+import org.tkit.onecx.iam.kc.client.operator.KeycloakClient;
+import org.tkit.onecx.iam.kc.client.operator.config.KCClientConfig;
+import org.tkit.onecx.iam.kc.client.operator.config.KCDefaultConfig;
+import org.tkit.quarkus.log.cdi.LogService;
+
+@LogService
+@ApplicationScoped
+public class KeycloakAdminService {
+
+ private static final Logger log = LoggerFactory.getLogger(KeycloakAdminService.class);
+
+ public static final String UI_TYPE = "ui";
+ public static final String MACHINE_TYPE = "machine";
+
+ public static final String PROTOCOL_OPENID_CONNECT = "openid-connect";
+
+ @Inject
+ Keycloak keycloak;
+
+ @Inject
+ KCClientConfig kcClientConfig;
+
+ @ActivateRequestContext
+ public int createClient(KeycloakClient keycloakClient) {
+ var spec = keycloakClient.getSpec();
+ var clientId = spec.getKcConfig().getClientId();
+ var realm = spec.getRealm() != null ? spec.getRealm() : kcClientConfig.realm();
+ ClientRepresentation client = null;
+ KCDefaultConfig clientDefaultConfig;
+ List clients = keycloak.realm(realm).clients().findByClientId(clientId);
+ client = clients.isEmpty() ? new ClientRepresentation() : clients.get(0);
+
+ if (UI_TYPE.equalsIgnoreCase(spec.getType())) {
+ clientDefaultConfig = kcClientConfig.config().get(UI_TYPE.toLowerCase());
+ prepareUpdateClient(client, spec.getKcConfig(), clientDefaultConfig);
+ } else if (MACHINE_TYPE.equalsIgnoreCase(spec.getType())) {
+ clientDefaultConfig = kcClientConfig.config().get(MACHINE_TYPE.toLowerCase());
+ prepareUpdateClient(client, spec.getKcConfig(), clientDefaultConfig);
+ } else {
+ throw new TypeNotSupportedException(spec.getType());
+ }
+
+ // check scopes if they exist and create them if necessary
+ checkAndCreateScopes(client, realm);
+
+ if (Boolean.TRUE.equals(clientDefaultConfig.addDefaultScopes())) {
+ var defaultScopesKC = keycloak.realm(realm).getDefaultDefaultClientScopes().stream()
+ .filter(csr -> csr.getProtocol().equals(PROTOCOL_OPENID_CONNECT)).map(ClientScopeRepresentation::getName)
+ .collect(Collectors.toSet());
+ var optionalScopesKC = keycloak.realm(realm).getDefaultOptionalClientScopes().stream()
+ .filter(csr -> csr.getProtocol().equals(PROTOCOL_OPENID_CONNECT)).map(ClientScopeRepresentation::getName)
+ .collect(Collectors.toSet());
+
+ // add default/optional scopes from realm
+ client.getDefaultClientScopes().addAll(defaultScopesKC);
+ client.getOptionalClientScopes().addAll(optionalScopesKC);
+ }
+
+ if (clients.isEmpty()) {
+ // do create
+ try (var resp = keycloak.realm(realm).clients().create(client)) {
+ return resp.getStatus();
+ }
+ } else {
+ // do update
+ var defaultClientScopes = client.getDefaultClientScopes();
+ var optionalClientScopes = client.getOptionalClientScopes();
+ var clientToUpdate = keycloak.realm(realm).clients().get(clients.get(0).getId());
+ clientToUpdate.update(client);
+ // update default client scopes
+ var toRemove = clientToUpdate.getDefaultClientScopes().stream()
+ .filter(rep -> !defaultClientScopes.contains(rep.getName())).map(ClientScopeRepresentation::getId)
+ .collect(Collectors.toSet());
+ var toAdd = new ArrayList<>(client.getDefaultClientScopes());
+ toAdd.removeAll(clientToUpdate.getDefaultClientScopes().stream().map(ClientScopeRepresentation::getName)
+ .collect(Collectors.toSet()));
+ toRemove.forEach(scope -> removeDefaultClientScope(clientToUpdate, scope));
+ toAdd.forEach(scope -> addDefaultClientScope(clientToUpdate, scope));
+ // update optional client scopes
+ var toRemoveOpt = clientToUpdate.getOptionalClientScopes().stream()
+ .filter(rep -> !optionalClientScopes.contains(rep.getName())).map(ClientScopeRepresentation::getId)
+ .collect(Collectors.toSet());
+ var toAddOpt = new ArrayList<>(client.getOptionalClientScopes());
+ toAddOpt.removeAll(clientToUpdate.getOptionalClientScopes().stream().map(ClientScopeRepresentation::getName)
+ .collect(Collectors.toSet()));
+ toRemoveOpt.forEach(scope -> removeOptClientScope(clientToUpdate, scope));
+ toAddOpt.forEach(scope -> addOptClientScope(clientToUpdate, scope));
+
+ return 200;
+ }
+ }
+
+ @ActivateRequestContext
+ public void deleteClient(KeycloakClient keycloakClient) {
+ var spec = keycloakClient.getSpec();
+ var clientId = spec.getKcConfig().getClientId();
+ var realm = spec.getRealm() != null ? spec.getRealm() : kcClientConfig.realm();
+
+ List clients = keycloak.realm(realm).clients().findByClientId(clientId);
+ if (!clients.isEmpty()) {
+ keycloak.realm(realm).clients().get(clients.get(0).getId()).remove();
+ }
+ }
+
+ void addDefaultClientScope(ClientResource cr, String clientScope) {
+ try {
+ cr.addDefaultClientScope(clientScope);
+ } catch (Exception e) {
+ log.error("Error adding default client scope " + clientScope, e);
+ }
+ }
+
+ void removeDefaultClientScope(ClientResource cr, String clientScope) {
+ try {
+ cr.removeDefaultClientScope(clientScope);
+ } catch (Exception e) {
+ log.error("Error removing default client scope " + clientScope, e);
+ }
+ }
+
+ void addOptClientScope(ClientResource cr, String clientScope) {
+ try {
+ cr.addOptionalClientScope(clientScope);
+ } catch (Exception e) {
+ log.error("Error adding optional client scope " + clientScope, e);
+ }
+ }
+
+ void removeOptClientScope(ClientResource cr, String clientScope) {
+ try {
+ cr.removeOptionalClientScope(clientScope);
+ } catch (Exception e) {
+ log.error("Error removing optional client scope " + clientScope, e);
+ }
+ }
+
+ private void checkAndCreateScopes(ClientRepresentation clientRepresentation, String realm) {
+ var kcScopes = keycloak.realm(realm).clientScopes().findAll();
+ var scopeNames = kcScopes.stream().map(ClientScopeRepresentation::getName).collect(Collectors.toSet());
+
+ var scopesToAdd = new HashSet();
+
+ // collect default scopes
+ scopesToAdd.addAll(clientRepresentation.getDefaultClientScopes());
+
+ // collect optional scopes
+ scopesToAdd.addAll(clientRepresentation.getOptionalClientScopes());
+
+ // remove all known scopes
+ scopesToAdd.removeAll(scopeNames);
+
+ // add all missing scopes to keycloak
+ if (!scopesToAdd.isEmpty()) {
+ scopesToAdd.forEach(scopeName -> createClientScope(scopeName, realm));
+ }
+ }
+
+ private void createClientScope(String clientScopeName, String realm) {
+ var clientScope = new ClientScopeRepresentation();
+ clientScope.setId(clientScopeName);
+ clientScope.setName(clientScopeName);
+ clientScope.setProtocol(PROTOCOL_OPENID_CONNECT);
+ clientScope.setDescription("Generated scope " + clientScopeName + " by operator");
+
+ try (var resp = keycloak.realm(realm).clientScopes().create(clientScope)) {
+ log.info("Client scope {} creation ended with status {}", clientScopeName, resp.getStatus());
+ }
+
+ }
+
+ private void prepareUpdateClient(ClientRepresentation clientRepresentation, KCConfig client,
+ KCDefaultConfig defaultConfig) {
+ clientRepresentation.setClientId(client.getClientId());
+ clientRepresentation.setDescription(client.getDescription());
+
+ clientRepresentation.setEnabled(resolveValue(client.getEnabled(), defaultConfig.enabled()));
+ clientRepresentation.setClientAuthenticatorType(
+ resolveValue(client.getClientAuthenticatorType(), defaultConfig.clientAuthenticatorType()));
+ clientRepresentation.setSecret(client.getPassword());
+ clientRepresentation.setRedirectUris(
+ resolveValue(client.getRedirectUris(), defaultConfig.redirectUris().orElse(new ArrayList<>())));
+ clientRepresentation
+ .setWebOrigins(resolveValue(client.getWebOrigins(), defaultConfig.webOrigins().orElse(new ArrayList<>())));
+ clientRepresentation.setBearerOnly(resolveValue(client.getBearerOnly(), defaultConfig.bearerOnly()));
+ clientRepresentation
+ .setStandardFlowEnabled(resolveValue(client.getStandardFlowEnabled(), defaultConfig.standardFlowEnabled()));
+ clientRepresentation
+ .setImplicitFlowEnabled(resolveValue(client.getImplicitFlowEnabled(), defaultConfig.implicitFlowEnabled()));
+ clientRepresentation.setDirectAccessGrantsEnabled(
+ resolveValue(client.getDirectAccessGrantsEnabled(), defaultConfig.directAccessGrantsEnabled()));
+ clientRepresentation.setServiceAccountsEnabled(
+ resolveValue(client.getServiceAccountsEnabled(), defaultConfig.serviceAccountsEnabled()));
+ clientRepresentation.setPublicClient(resolveValue(client.getPublicClient(), defaultConfig.publicClient()));
+ clientRepresentation.setProtocol(resolveValue(client.getProtocol(), defaultConfig.protocol()));
+ clientRepresentation.setAttributes(resolveValue(client.getAttributes(), defaultConfig.attributes()));
+
+ clientRepresentation
+ .setDefaultClientScopes(resolveValue(client.getDefaultClientScopes(),
+ defaultConfig.defaultClientScopes().orElse(new ArrayList<>())));
+ clientRepresentation.setOptionalClientScopes(
+ resolveValue(client.getOptionalClientScopes(), defaultConfig.optionalClientScopes().orElse(new ArrayList<>())));
+ }
+
+ private Map resolveValue(Map value, Map defaultValue) {
+ Map finalMap = new HashMap<>(defaultValue);
+
+ if (value != null) {
+ finalMap.putAll(value);
+ }
+
+ return finalMap;
+ }
+
+ private List resolveValue(List value, List defaultValue) {
+ if (value != null) {
+ return new ArrayList<>(value);
+ }
+
+ return new ArrayList<>(defaultValue);
+ }
+
+ private Boolean resolveValue(Boolean value, Boolean defaultValue) {
+ if (value != null) {
+ return value;
+ }
+
+ return defaultValue;
+ }
+
+ private String resolveValue(String value, String defaultValue) {
+ if (value != null) {
+ return value;
+ }
+
+ return defaultValue;
+ }
+
+}
diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/TypeNotSupportedException.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/TypeNotSupportedException.java
new file mode 100644
index 0000000..0b434dc
--- /dev/null
+++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/TypeNotSupportedException.java
@@ -0,0 +1,12 @@
+package org.tkit.onecx.iam.kc.client.operator.service;
+
+import java.util.List;
+
+public class TypeNotSupportedException extends RuntimeException {
+
+ public TypeNotSupportedException(String typeName) {
+ super("Client type " + typeName + " is not supported. Supported options only: "
+ + List.of(KeycloakAdminService.UI_TYPE, KeycloakAdminService.MACHINE_TYPE));
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..a32a3c3
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,57 @@
+quarkus.kubernetes-client.devservices.override-kubeconfig=true
+quarkus.keycloak.admin-client.server-url=http://keycloak:8080
+quarkus.keycloak.admin-client.realm=master
+quarkus.keycloak.admin-client.username=admin
+quarkus.keycloak.admin-client.password=admin
+
+quarkus.operator-sdk.helm.enabled=true
+
+# Setup UI / Machine keycloak client defaults
+onecx.iam.kc.client.realm=onecx
+
+onecx.iam.kc.client.config.ui.enabled=true
+onecx.iam.kc.client.config.ui.auth-type=client-secret
+onecx.iam.kc.client.config.ui.redirect-uris=*
+onecx.iam.kc.client.config.ui.web-origins=*
+onecx.iam.kc.client.config.ui.bearer-only=false
+onecx.iam.kc.client.config.ui.standard-flow=true
+onecx.iam.kc.client.config.ui.implicit-flow=false
+onecx.iam.kc.client.config.ui.direct-access=true
+onecx.iam.kc.client.config.ui.service-account=false
+onecx.iam.kc.client.config.ui.protocol=openid-connect
+onecx.iam.kc.client.config.ui.default-scopes=web-origins,roles,profile,email
+onecx.iam.kc.client.config.ui.public=true
+onecx.iam.kc.client.config.ui.add-def-scopes=true
+
+onecx.iam.kc.client.config.machine.enabled=true
+onecx.iam.kc.client.config.machine.auth-type=client-secret
+onecx.iam.kc.client.config.machine.bearer-only=false
+onecx.iam.kc.client.config.machine.standard-flow=false
+onecx.iam.kc.client.config.machine.implicit-flow=false
+onecx.iam.kc.client.config.machine.direct-access=false
+onecx.iam.kc.client.config.machine.service-account=true
+onecx.iam.kc.client.config.machine.protocol=openid-connect
+onecx.iam.kc.client.config.machine.default-scopes=web-origins,roles,profile,email
+onecx.iam.kc.client.config.machine.public=false
+onecx.iam.kc.client.config.machine.add-def-scopes=true
+
+# PROD
+
+# TEST
+quarkus.test.integration-test-profile=test
+%test.quarkus.keycloak.admin-client.server-url=${keycloak.url}
+%test.quarkus.keycloak.devservices.show-logs=false
+%test.quarkus.keycloak.devservices.roles.alice=onecx-portal-super-admin,onecx-portal-admin
+%test.quarkus.keycloak.devservices.roles.bob=onecx-portal-user
+%test.smallrye.jwt.verify.key.location=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs
+%test.onecx.iam.kc.client.realm=quarkus
+%test.onecx.iam.kc.client.config.ui.add-def-scopes=true
+%test.onecx.iam.kc.client.config.machine.add-def-scopes=false
+
+# DEV
+%dev.quarkus.keycloak.admin-client.server-url=${keycloak.url}
+%dev.quarkus.keycloak.devservices.show-logs=false
+%dev.quarkus.keycloak.devservices.roles.alice=onecx-portal-super-admin,onecx-portal-admin
+%dev.quarkus.keycloak.devservices.roles.bob=onecx-portal-user
+%dev.smallrye.jwt.verify.key.location=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs
+
diff --git a/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerMockTest.java b/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerMockTest.java
new file mode 100644
index 0000000..31296c9
--- /dev/null
+++ b/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerMockTest.java
@@ -0,0 +1,211 @@
+package org.tkit.onecx.iam.kc.client.operator;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+import java.util.List;
+import java.util.Map;
+
+import jakarta.inject.Inject;
+
+import org.junit.jupiter.api.*;
+import org.keycloak.admin.client.resource.*;
+import org.keycloak.representations.adapters.action.GlobalRequestResult;
+import org.keycloak.representations.idm.*;
+import org.tkit.onecx.iam.kc.client.operator.service.KeycloakAdminService;
+import org.tkit.onecx.iam.kc.client.test.AbstractTest;
+
+import io.quarkus.test.junit.QuarkusTest;
+
+@QuarkusTest
+class KeycloakClientControllerMockTest extends AbstractTest {
+
+ @Inject
+ KeycloakAdminService kas;
+
+ @Test
+ void testThrowException() throws NoSuchMethodException {
+
+ MockClientResource mockClientResource = new MockClientResource();
+
+ var addOptMethod = KeycloakAdminService.class.getDeclaredMethod("addOptClientScope", ClientResource.class,
+ String.class);
+ addOptMethod.setAccessible(true);
+ assertDoesNotThrow(() -> addOptMethod.invoke(kas, mockClientResource, "test"));
+
+ var removeOptMethod = KeycloakAdminService.class.getDeclaredMethod("removeOptClientScope", ClientResource.class,
+ String.class);
+ removeOptMethod.setAccessible(true);
+ assertDoesNotThrow(() -> removeOptMethod.invoke(kas, mockClientResource, "test"));
+
+ var addDefaultMethod = KeycloakAdminService.class.getDeclaredMethod("addDefaultClientScope", ClientResource.class,
+ String.class);
+ addDefaultMethod.setAccessible(true);
+ assertDoesNotThrow(() -> addDefaultMethod.invoke(kas, mockClientResource, "test"));
+
+ var removeDefaultMethod = KeycloakAdminService.class.getDeclaredMethod("removeDefaultClientScope", ClientResource.class,
+ String.class);
+ removeDefaultMethod.setAccessible(true);
+ assertDoesNotThrow(() -> removeDefaultMethod.invoke(kas, mockClientResource, "test"));
+
+ }
+
+ public class MockClientResource implements ClientResource {
+
+ @Override
+ public ManagementPermissionReference setPermissions(
+ ManagementPermissionRepresentation managementPermissionRepresentation) {
+ return null;
+ }
+
+ @Override
+ public ManagementPermissionReference getPermissions() {
+ return null;
+ }
+
+ @Override
+ public ProtocolMappersResource getProtocolMappers() {
+ return null;
+ }
+
+ @Override
+ public ClientRepresentation toRepresentation() {
+ return null;
+ }
+
+ @Override
+ public void update(ClientRepresentation clientRepresentation) {
+
+ }
+
+ @Override
+ public void remove() {
+
+ }
+
+ @Override
+ public CredentialRepresentation generateNewSecret() {
+ return null;
+ }
+
+ @Override
+ public CredentialRepresentation getSecret() {
+ return null;
+ }
+
+ @Override
+ public ClientRepresentation regenerateRegistrationAccessToken() {
+ return null;
+ }
+
+ @Override
+ public ClientAttributeCertificateResource getCertficateResource(String s) {
+ return null;
+ }
+
+ @Override
+ public String getInstallationProvider(String s) {
+ return null;
+ }
+
+ @Override
+ public Map getApplicationSessionCount() {
+ return null;
+ }
+
+ @Override
+ public List getUserSessions(Integer integer, Integer integer1) {
+ return null;
+ }
+
+ @Override
+ public Map getOfflineSessionCount() {
+ return null;
+ }
+
+ @Override
+ public List getOfflineUserSessions(Integer integer, Integer integer1) {
+ return null;
+ }
+
+ @Override
+ public void pushRevocation() {
+
+ }
+
+ @Override
+ public RoleMappingResource getScopeMappings() {
+ return null;
+ }
+
+ @Override
+ public RolesResource roles() {
+ return null;
+ }
+
+ @Override
+ public List getDefaultClientScopes() {
+ return null;
+ }
+
+ @Override
+ public void addDefaultClientScope(String s) {
+ throw new RuntimeException("error");
+ }
+
+ @Override
+ public void removeDefaultClientScope(String s) {
+ throw new RuntimeException("error");
+ }
+
+ @Override
+ public List getOptionalClientScopes() {
+ return null;
+ }
+
+ @Override
+ public void addOptionalClientScope(String s) {
+ throw new RuntimeException("error");
+ }
+
+ @Override
+ public void removeOptionalClientScope(String s) {
+ throw new RuntimeException("error");
+ }
+
+ @Override
+ public UserRepresentation getServiceAccountUser() {
+ return null;
+ }
+
+ @Override
+ public void registerNode(Map map) {
+
+ }
+
+ @Override
+ public void unregisterNode(String s) {
+
+ }
+
+ @Override
+ public GlobalRequestResult testNodesAvailable() {
+ return null;
+ }
+
+ @Override
+ public AuthorizationResource authorization() {
+ return null;
+ }
+
+ @Override
+ public CredentialRepresentation getClientRotatedSecret() {
+ return null;
+ }
+
+ @Override
+ public void invalidateRotatedSecret() {
+
+ }
+ }
+
+}
diff --git a/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerTest.java b/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerTest.java
new file mode 100644
index 0000000..1944194
--- /dev/null
+++ b/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerTest.java
@@ -0,0 +1,771 @@
+package org.tkit.onecx.iam.kc.client.operator;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.inject.Inject;
+
+import org.apache.groovy.util.Maps;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.*;
+import org.keycloak.admin.client.Keycloak;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.tkit.onecx.iam.kc.client.operator.service.KeycloakAdminService;
+import org.tkit.onecx.iam.kc.client.test.AbstractTest;
+
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.javaoperatorsdk.operator.Operator;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.keycloak.client.KeycloakTestClient;
+
+@QuarkusTest
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class KeycloakClientControllerTest extends AbstractTest {
+
+ final static Logger log = LoggerFactory.getLogger(KeycloakClientControllerTest.class);
+
+ @Inject
+ Operator operator;
+
+ @Inject
+ KubernetesClient client;
+
+ private static final KeycloakTestClient keycloakClient = new KeycloakTestClient();
+
+ @Inject
+ Keycloak keycloak;
+
+ @BeforeAll
+ public static void init() {
+ Awaitility.setDefaultPollDelay(2, SECONDS);
+ Awaitility.setDefaultPollInterval(2, SECONDS);
+ Awaitility.setDefaultTimeout(10, SECONDS);
+ }
+
+ @Test
+ @Order(1)
+ void createUIClient() {
+ var CLIENT_ID = "test-ui-client";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setRealm(REALM_QUARKUS);
+ kcClientSpec.setType(KeycloakAdminService.UI_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ kcConfig.setDescription(CLIENT_ID);
+ kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2"));
+ kcConfig.setOptionalClientScopes(List.of("opt-scope-1", "opt-scope-2"));
+ kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2"));
+ data.setSpec(kcClientSpec);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED);
+ });
+
+ var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(clients).isNotEmpty();
+ var clientRep = clients.get(0);
+ assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription());
+ // validate that attributes are all in
+ assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes());
+ assertThat(clientRep.getOptionalClientScopes()).containsAll(kcConfig.getOptionalClientScopes());
+
+ var token = keycloakClient.getAccessToken(USER_ALICE, CLIENT_ID);
+ assertThat(token).isNotNull();
+
+ var jws = resolveToken(token);
+ assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID);
+ var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME);
+ var scopes = scopeString.split(" ");
+ // validate all scopes are in
+ assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes());
+ }
+
+ @Test
+ @Order(3)
+ void createUIClientAllOptionsFilled() {
+ var CLIENT_ID = "test-ui-client-all-ops";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setRealm(REALM_QUARKUS);
+ kcClientSpec.setType(KeycloakAdminService.UI_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ kcConfig.setDescription(CLIENT_ID);
+ kcConfig.setEnabled(true);
+ kcConfig.setClientAuthenticatorType("client-secret");
+ kcConfig.setPassword(CLIENT_ID);
+ kcConfig.setRedirectUris(List.of("*", "localhost"));
+ kcConfig.setWebOrigins(List.of("*", "localhost"));
+ kcConfig.setBearerOnly(false);
+ kcConfig.setStandardFlowEnabled(true);
+ kcConfig.setImplicitFlowEnabled(false);
+ kcConfig.setDirectAccessGrantsEnabled(true);
+ kcConfig.setServiceAccountsEnabled(false);
+ kcConfig.setPublicClient(true);
+ kcConfig.setProtocol(KeycloakAdminService.PROTOCOL_OPENID_CONNECT);
+ kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2"));
+ kcConfig.setOptionalClientScopes(List.of("opt-scope-1", "opt-scope-2"));
+ kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2"));
+ data.setSpec(kcClientSpec);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED);
+ });
+
+ var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(clients).isNotEmpty();
+ var clientRep = clients.get(0);
+ assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription());
+ // validate that attributes are all in
+ assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes());
+ assertThat(clientRep.getOptionalClientScopes()).containsAll(kcConfig.getOptionalClientScopes());
+
+ var token = keycloakClient.getAccessToken(USER_ALICE, CLIENT_ID);
+ assertThat(token).isNotNull();
+
+ var jws = resolveToken(token);
+ assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID);
+ var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME);
+ var scopes = scopeString.split(" ");
+ // validate all scopes are in
+ assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes());
+ }
+
+ @Test
+ @Order(2)
+ void updateUIClient() {
+ var CLIENT_ID = "test-ui-client";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setRealm(REALM_QUARKUS);
+ kcClientSpec.setType(KeycloakAdminService.UI_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ kcConfig.setDescription("UPDATED-" + CLIENT_ID);
+ kcConfig.setDefaultClientScopes(List.of("test-scope-1", "test-scope-2"));
+ kcConfig.setOptionalClientScopes(List.of("opt-scope-1", "opt-scope-2-updated"));
+ kcConfig.setAttributes(Maps.of("create.attr.1", "udpate.values.1", "update.attr.2", "update.values.2"));
+ data.setSpec(kcClientSpec);
+
+ log.info("Update test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is UPDATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED);
+ });
+
+ var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(clients).isNotEmpty();
+ var clientRep = clients.get(0);
+ assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription());
+ // validate that attributes are all in
+ assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes());
+ assertThat(clientRep.getOptionalClientScopes()).containsAll(kcConfig.getOptionalClientScopes());
+
+ var token = keycloakClient.getAccessToken(USER_ALICE, CLIENT_ID);
+ assertThat(token).isNotNull();
+
+ var jws = resolveToken(token);
+ assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID);
+ var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME);
+ var scopes = scopeString.split(" ");
+ // validate all scopes are in
+ assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes());
+ }
+
+ @Test
+ @Order(4)
+ void createUIClientMinimumOption() {
+ var CLIENT_ID = "test-ui-client-min-ops";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setType(KeycloakAdminService.UI_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ data.setSpec(kcClientSpec);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED);
+ });
+
+ var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(clients).isNotEmpty();
+
+ var token = keycloakClient.getRealmAccessToken(REALM_QUARKUS, USER_ALICE, CLIENT_ID);
+ assertThat(token).isNotNull();
+
+ var jws = resolveToken(token);
+ assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID);
+
+ log.info("Deleting test keycloak client object: {}", data);
+ var statusDetails = client.resource(data).delete();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ var mfeStatus = client.resource(data).get();
+ assertThat(mfeStatus).isNull();
+ });
+ }
+
+ @Test
+ @Order(5)
+ void deleteUIClientMinimumOption() {
+ var CLIENT_ID = "test-ui-client-min-ops-for-del";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setType(KeycloakAdminService.UI_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ data.setSpec(kcClientSpec);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED);
+ });
+
+ var foundClients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(foundClients).isNotEmpty();
+
+ log.info("Deleting test keycloak client object: {}", data);
+ client.resource(data).delete();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ var clientResource = client.resource(data).get();
+ assertThat(clientResource).isNull();
+ });
+
+ foundClients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(foundClients).isEmpty();
+ }
+
+ @Test
+ @Order(6)
+ void deleteAlreadyDeletedUIClient() {
+ var CLIENT_ID = "test-ui-client-min-ops-for-del";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setType(KeycloakAdminService.UI_TYPE);
+ kcClientSpec.setRealm("quarkus");
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ data.setSpec(kcClientSpec);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED);
+ });
+
+ var foundClients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(foundClients).isNotEmpty();
+
+ keycloak.realm(REALM_QUARKUS).clients().get(foundClients.get(0).getId()).remove();
+
+ log.info("Deleting test keycloak client object: {}", data);
+ client.resource(data).delete();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ var clientResource = client.resource(data).get();
+ assertThat(clientResource).isNull();
+ });
+
+ foundClients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(foundClients).isEmpty();
+ }
+
+ @Test
+ @Order(10)
+ void createMachineClient() {
+ var CLIENT_ID = "test-client";
+ var CLIENT_SECRET = "test-client-secret";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setRealm(REALM_QUARKUS);
+ kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ kcConfig.setPassword(CLIENT_SECRET);
+ kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2"));
+ kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2"));
+ data.setSpec(kcClientSpec);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED);
+ });
+
+ var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(clients).isNotEmpty();
+ var clientRep = clients.get(0);
+ assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription());
+ // validate that attributes are all in
+ assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes());
+
+ var token = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET);
+ assertThat(token).isNotNull();
+
+ var jws = resolveToken(token);
+ assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID);
+ var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME);
+ var scopes = scopeString.split(" ");
+ // validate all scopes are in
+ assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes());
+ }
+
+ @Test
+ @Order(11)
+ void updateMachineClient() {
+ var CLIENT_ID = "test-client";
+ var CLIENT_SECRET = "test-client-secret";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setRealm(REALM_QUARKUS);
+ kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ kcConfig.setPassword(CLIENT_SECRET);
+ kcConfig.setDefaultClientScopes(List.of("create-scope-1", "update-scope-2"));
+ kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1.update", "update.attr.2", "update.values.2"));
+ data.setSpec(kcClientSpec);
+
+ log.info("Updating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is UPDATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED);
+ });
+
+ var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID);
+ assertThat(clients).isNotEmpty();
+ var clientRep = clients.get(0);
+ assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription());
+ // validate that attributes are all in
+ assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes());
+
+ var token = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET);
+ assertThat(token).isNotNull();
+
+ var jws = resolveToken(token);
+ assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID);
+ var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME);
+ var scopes = scopeString.split(" ");
+ // validate all scopes are in
+ assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes());
+
+ // update machine client with empty spec
+ data.setSpec(null);
+ log.info("Updating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED);
+ });
+ }
+
+ @Test
+ void updateMachinePwdClient() {
+ var CLIENT_ID = "test-client-pwd-chg";
+ var CLIENT_SECRET = "test-client-secret";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setRealm(REALM_QUARKUS);
+ kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ kcConfig.setPassword(CLIENT_SECRET);
+ kcConfig.setDefaultClientScopes(List.of("create-scope-1", "update-scope-2"));
+ kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1.update", "update.attr.2", "update.values.2"));
+ data.setSpec(kcClientSpec);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is UPDATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED);
+ });
+
+ var secret = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID).get(0).getSecret();
+ log.info("Old secret {}", secret);
+
+ // update the password
+ var NEW_CLIENT_PASSWORD = "test-client-secret-new";
+ data.getSpec().getKcConfig().setPassword(NEW_CLIENT_PASSWORD);
+
+ log.info("Updating test keycloak client with new password object: {}", data);
+ client.resource(data).update();
+
+ log.info("Waiting 4 seconds and status is UPDATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED);
+ });
+
+ secret = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID).get(0).getSecret();
+ log.info("New secret {}", secret);
+
+ var tokenWithOldPwd = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET);
+ var tokenWithNewPwd = keycloakClient.getClientAccessToken(CLIENT_ID, NEW_CLIENT_PASSWORD);
+
+ assertThat(tokenWithOldPwd).isNull();
+ assertThat(tokenWithNewPwd).isNotNull();
+ }
+
+ @Test
+ void createUpdatePasswordFromSecretTest() {
+ Base64.Encoder encoder = Base64.getEncoder();
+
+ var CLIENT_ID = "test-machine-secret-client";
+ var CLIENT_SECRET = "test-client-secret";
+ var CLIENT_PWD_SECRET = "test-machine-secret-client-secret";
+ var CLIENT_PWD_KEY = "pwd";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setRealm(REALM_QUARKUS);
+ kcClientSpec.setPasswordKey(CLIENT_PWD_KEY);
+ kcClientSpec.setPasswordSecrets(CLIENT_PWD_SECRET);
+ kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ kcConfig.setPassword("someRandomPwdShouldBeIgnored");
+ kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2"));
+ kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2"));
+ data.setSpec(kcClientSpec);
+
+ Secret secret = new Secret();
+ secret.setMetadata(new ObjectMetaBuilder().withName(kcClientSpec.getPasswordSecrets())
+ .withNamespace(client.getNamespace()).build());
+ secret.setData(Map.of(kcClientSpec.getPasswordKey(), encoder.encodeToString(CLIENT_SECRET.getBytes())));
+
+ log.info("Creating secret object: {}", secret);
+ client.resource(secret).serverSideApply();
+
+ log.info("Creating keycloak client object {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is CREATED");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED);
+ });
+
+ var token = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET);
+ assertThat(token).isNotNull();
+
+ // update the password
+ var CLIENT_SECRET_NEW = "new-machine-client-secret";
+ secret.setData(Map.of(kcClientSpec.getPasswordKey(), encoder.encodeToString(CLIENT_SECRET_NEW.getBytes())));
+ log.info("Updating secret object: {}", secret);
+ client.resource(secret).update();
+
+ log.info("Waiting 6 seconds and status is UPDATED");
+
+ await().pollDelay(6, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED);
+ });
+ // old password token empty
+ var oldSecretToken = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET);
+ assertThat(oldSecretToken).isNull();
+
+ // new password generates token
+ var newSecretToken = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET_NEW);
+ assertThat(newSecretToken).isNotNull();
+ }
+
+ @Test
+ void clientErrorTest() {
+ operator.start();
+
+ // Null specification
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName("null-spec").withNamespace(client.getNamespace()).build());
+ data.setSpec(null);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is still null");
+
+ KeycloakClient finalData = data;
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(finalData).get().getStatus();
+ assertThat(mfeStatus).isNull();
+ });
+
+ // empty specification
+ data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName("empty-spec").withNamespace(client.getNamespace()).build());
+ data.setSpec(new KeycloakClientSpec());
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status has an ERROR");
+
+ KeycloakClient finalData2 = data;
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(finalData2).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR);
+ });
+
+ // empty config
+ data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName("empty-config").withNamespace(client.getNamespace()).build());
+ data.setSpec(new KeycloakClientSpec());
+ data.getSpec().setKcConfig(new KCConfig());
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status has an ERROR");
+
+ KeycloakClient finalData3 = data;
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(finalData3).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR);
+ });
+ }
+
+ @Test
+ void clientNotExistingRealmTest() {
+ var CLIENT_ID = "wrong-type";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ data.setSpec(new KeycloakClientSpec());
+ data.getSpec().setType(KeycloakAdminService.MACHINE_TYPE);
+ data.getSpec().setRealm("NOT_EXISTING");
+ data.getSpec().setKcConfig(new KCConfig());
+ data.getSpec().getKcConfig().setClientId(CLIENT_ID);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status has an ERROR");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR);
+ });
+ }
+
+ @Test
+ void clientWrongTypeTest() {
+ var CLIENT_ID = "wrong-type";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ data.setSpec(new KeycloakClientSpec());
+ data.getSpec().setType("CUSTOM_TYPE");
+ data.getSpec().setKcConfig(new KCConfig());
+ data.getSpec().getKcConfig().setClientId(CLIENT_ID);
+
+ log.info("Creating test keycloak client object: {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status has an ERROR");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR);
+ });
+ }
+
+ @Test
+ void createUpdatePasswordFromSecretErrorTest() {
+ Base64.Encoder encoder = Base64.getEncoder();
+
+ var CLIENT_ID = "test-machine-secret-client-err1";
+ var CLIENT_SECRET = "test-client-secret";
+ var CLIENT_PWD_SECRET = "err-machine-secret-client-secret";
+ var CLIENT_PWD_KEY = "pwd";
+ operator.start();
+
+ KeycloakClient data = new KeycloakClient();
+ data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build());
+ var kcClientSpec = new KeycloakClientSpec();
+ kcClientSpec.setRealm(REALM_QUARKUS);
+ kcClientSpec.setPasswordSecrets(CLIENT_PWD_SECRET);
+ kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE);
+ var kcConfig = new KCConfig();
+ kcClientSpec.setKcConfig(kcConfig);
+ kcConfig.setClientId(CLIENT_ID);
+ kcConfig.setPassword("someRandomPwdShouldBeIgnored");
+ kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2"));
+ kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2"));
+ data.setSpec(kcClientSpec);
+
+ Secret secret = new Secret();
+ secret.setMetadata(new ObjectMetaBuilder().withName(kcClientSpec.getPasswordSecrets())
+ .withNamespace(client.getNamespace()).build());
+ secret.setData(Map.of("other-key", encoder.encodeToString(CLIENT_SECRET.getBytes())));
+
+ log.info("Creating secret object: {}", secret);
+ client.resource(secret).serverSideApply();
+
+ // test when the client does container pwd secret name but not the pwd key
+ log.info("Creating keycloak client object {}", data);
+ client.resource(data).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is ERROR");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getResponseCode()).isEqualTo(500);
+ assertThat(mfeStatus.getMessage()).isEqualTo("Secret key is mandatory. No key found!");
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR);
+ });
+
+ // test error when secret does not contain the right key
+ KeycloakClient data1 = new KeycloakClient();
+ data1.setMetadata(new ObjectMetaBuilder().withName("test-machine-secret-client-err2")
+ .withNamespace(client.getNamespace()).build());
+ data1.setSpec(kcClientSpec);
+ kcClientSpec.setPasswordKey(CLIENT_PWD_KEY);
+
+ log.info("Creating keycloak client object {}", data1);
+ client.resource(data1).serverSideApply();
+
+ log.info("Waiting 4 seconds and status is ERROR");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data1).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getMessage()).isEqualTo("Secret key is mandatory. No key secret found!");
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR);
+ });
+
+ // test error when secret has the right key but the value is empty
+ secret.setData(Map.of(CLIENT_PWD_KEY, ""));
+
+ log.info("Update secret object {}", secret);
+ client.resource(secret).update();
+
+ log.info("Waiting 4 seconds and status is ERROR");
+
+ await().pollDelay(4, SECONDS).untilAsserted(() -> {
+ KeycloakClientStatus mfeStatus = client.resource(data1).get().getStatus();
+ assertThat(mfeStatus).isNotNull();
+ assertThat(mfeStatus.getMessage()).isEqualTo("Secret key '" + CLIENT_PWD_KEY + "' is mandatory. No value found!");
+ assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR);
+ });
+ }
+}
diff --git a/src/test/java/org/tkit/onecx/iam/kc/client/test/AbstractTest.java b/src/test/java/org/tkit/onecx/iam/kc/client/test/AbstractTest.java
new file mode 100644
index 0000000..88932ee
--- /dev/null
+++ b/src/test/java/org/tkit/onecx/iam/kc/client/test/AbstractTest.java
@@ -0,0 +1,36 @@
+package org.tkit.onecx.iam.kc.client.test;
+
+import org.eclipse.microprofile.jwt.Claims;
+import org.jose4j.jws.JsonWebSignature;
+import org.jose4j.jwt.JwtClaims;
+import org.jose4j.jwx.JsonWebStructure;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.smallrye.jwt.auth.principal.DefaultJWTCallerPrincipal;
+
+public abstract class AbstractTest {
+
+ final static Logger log = LoggerFactory.getLogger(AbstractTest.class);
+
+ public static String REALM_QUARKUS = "quarkus";
+ public static String USER_ALICE = "alice";
+ public static String USER_BOB = "bob";
+
+ public static String UI_TOKEN_CLIENT_CLAIM_NAME = Claims.azp.name();
+
+ public static String MACHINE_TOKEN_CLIENT_CLAIM_NAME = "client_id";
+ public static String SCOPE_CLAIM_NAME = "scope";
+
+ public DefaultJWTCallerPrincipal resolveToken(String token) {
+ try {
+ var jws = (JsonWebSignature) JsonWebStructure.fromCompactSerialization(token);
+ var jwtClaims = JwtClaims.parse(jws.getUnverifiedPayload());
+
+ return new DefaultJWTCallerPrincipal(token, jws.getKeyType(), jwtClaims);
+ } catch (Exception e) {
+ log.error("Error parse token {}", token);
+ }
+ return null;
+ }
+}