Adding an integration test with Minikube for Kubernetes Service Account Federated Authenticator

Closes #42983

Signed-off-by: Sebastian Łaskawiec <sebastian.laskawiec@defenseunicorns.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Co-authored-by: Alexander Schwartz <alexander.schwartz@ibm.com>
This commit is contained in:
Sebastian Łaskawiec 2025-11-13 08:52:46 +01:00 committed by GitHub
parent da7993896d
commit 3288f83dc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 365 additions and 5 deletions

View File

@ -48,6 +48,7 @@ include::topics/identity-broker/oidc.adoc[]
include::topics/identity-broker/oauth2.adoc[]
include::topics/identity-broker/saml.adoc[]
include::topics/identity-broker/spiffe.adoc[]
include::topics/identity-broker/kubernetes.adoc[]
include::topics/identity-broker/suggested.adoc[]
include::topics/identity-broker/mappers.adoc[]
include::topics/identity-broker/session-data.adoc[]

View File

@ -0,0 +1,102 @@
ifeval::[{project_community}==true]
[[_identity_broker_kubernetes]]
=== Kubernetes identity providers
[NOTE]
====
Kubernetes service accounts trust relationship provider is *Experimental* and is not fully supported.
This feature is disabled by default.
To enable start the server with `--features=kubernetes`
====
:tech_feature_name: Authenticate clients based on assertions issued by an identity provider
:tech_feature_id: client-auth-federated
include::../../topics/templates/techpreview.adoc[]
A Kubernetes identity provider supports authenticating clients with Kubernetes service account tokens.
It depends on the preview feature `client-auth-federated`.
.Procedure
. Click *Identity Providers* in the menu.
. From the `Add provider` list, select `Kubernetes`.
+
. Enter your initial configuration options or proceed with the defaults.
+
.Kubernetes settings
|===
|Configuration|Description
|Alias
|The alias for the identity provider is used to link a client to the provider
|Kubernetes JWKS URL
|The Kubernetes JWKS URL when accessing an external Kubernetes cluster. The JWKS endpoint must not require authentication. If the Kubernetes JWKS URL is not defined, it defaults to `+https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}/openid/v1/jwks+` with Token-based authentication using `/var/run/secrets/kubernetes.io/serviceaccount/token`.
|===
+
. When you create a new realm with the preview feature `client-auth-federated` enabled, the client authentication flow is already configured correctly. For existing realms, add to the client authentication flow the execution of *Signed JWT - Federated* as an alternative step. As built-in flows can not be updated, and if the default flow is your default, you will first need to duplicate the existing clients
. For each confidential OIDC client that should authenticate via this provider:
.. Change in the *Credentials* tab the *Client Authenticator* to *Signed JWT - Federated*.
.. As *Identity provider*, enter the alias of the Kubernetes identity provider added in step 3.
.. As *Federated Subject*, enter the subject identifier as issued by Kubernetes. This is usually `system:serviceaccount:<namespace>:<serviceaccount>`.
. For the Pod in Kubernetes add a service account :
+
--
[source]
----
apiVersion: v1
kind: Pod
...
spec:
serviceAccountName: <serviceaccount>
...
volumes:
- name: aud-token
projected:
defaultMode: 420
sources:
- serviceAccountToken:
audience: https://example.com:8443/realms/test <1>
expirationSeconds: 600 <2>
path: my-aud-token
----
. Issuer URL of the {project_name} realm.
. Maximum time allowed by Kubernetes and {project_name} is 3600
--
To verify your setup, assuming the client has a service account configured:
. In the Pod, retrieve the token
+
[source]
----
cat /var/run/secrets/tokens/my-aud-token
----
. Use the token as the client credentials
+
[source]
----
curl -k https://example.com:8443/realms/<realm>/protocol/openid-connect/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=client_credentials \
--data-urlencode client_id=system:serviceaccount:<namespace>:<serviceaccount> \
--data-urlencode client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode client_assertion=<token>
----
. Verify the response
+
[source,json]
----
{
"access_token": "ey...bw",
"expires_in": 300,
"refresh_expires_in": 0,
"token_type": "Bearer",
"not-before-policy": 0,
"scope": "profile email"
}
----
endif::[]

View File

@ -164,6 +164,11 @@
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-client-tests</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,156 @@
/*
* Copyright 2022 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.operator.testsuite.integration;
import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.EnvVar;
import io.fabric8.kubernetes.api.model.PodBuilder;
import io.fabric8.kubernetes.api.model.PodSpec;
import io.fabric8.kubernetes.api.model.PodTemplateSpec;
import io.fabric8.kubernetes.client.LocalPortForward;
import io.quarkus.logging.Log;
import io.quarkus.test.junit.QuarkusTest;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.keycloak.operator.controllers.KeycloakServiceDependentResource;
import org.keycloak.operator.crds.v2alpha1.deployment.Keycloak;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.FeatureSpec;
import org.keycloak.operator.crds.v2alpha1.deployment.spec.UnsupportedSpec;
import org.keycloak.operator.testsuite.apiserver.DisabledIfApiServerTest;
import org.keycloak.operator.testsuite.utils.TrustAllSSLContext;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import static java.util.concurrent.TimeUnit.MINUTES;
import static org.assertj.core.api.Assertions.assertThat;
import static org.keycloak.operator.Constants.KEYCLOAK_HTTPS_PORT;
import static org.keycloak.operator.testsuite.utils.K8sUtils.deployKeycloak;
import static org.keycloak.operator.testsuite.utils.K8sUtils.inClusterCurl;
import static org.keycloak.operator.testsuite.utils.K8sUtils.inClusterCurlCommand;
@DisabledIfApiServerTest
@Tag(BaseOperatorTest.SLOW)
@QuarkusTest
public class KeycloakKubernetesJwtTest extends BaseOperatorTest {
@DisabledIfApiServerTest
@Test
public void testSignedJWTs() throws IOException {
// Arrange
var kc = getTestKeycloakDeployment(false);
if (kc.getSpec().getFeatureSpec() ==null) {
kc.getSpec().setFeatureSpec(new FeatureSpec());
}
kc.getSpec().getFeatureSpec().setEnabledFeatures(List.of("kubernetes-service-accounts", "client-auth-federated"));
kc.getSpec().setStartOptimized(false);
PodTemplateSpec podTemplate = new PodTemplateSpec();
kc.getSpec().setUnsupported(new UnsupportedSpec(podTemplate));
PodSpec podSpec = new PodSpec();
podTemplate.setSpec(podSpec);
Container container = new Container();
podSpec.setContainers(List.of(container));
container.setEnv(List.of(
new EnvVar("JAVA_OPTS_APPEND", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8787", null),
new EnvVar("KC_BOOTSTRAP_ADMIN_USERNAME", "admin", null),
new EnvVar("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin", null)
));
deployKeycloak(k8sclient, kc, false);
var builder = new PodBuilder();
builder.withNewMetadata()
.withName("example-kc-0")
.withNamespace(namespace)
.endMetadata();
var kcPod = builder.build();
k8sclient.pods().resource(kcPod).waitUntilReady(5, MINUTES);
try (LocalPortForward pf = k8sclient.pods().resource(kcPod)
.portForward(8443)) {
var keycloak = org.keycloak.admin.client.Keycloak.getInstance(
"https://127.0.0.1:" + pf.getLocalPort(),
"master",
"admin",
"admin",
"admin-cli",
TrustAllSSLContext.getContext());
RealmRepresentation realm = new RealmRepresentation();
realm.setRealm("test");
realm.setEnabled(true);
keycloak.realms().create(realm);
ClientRepresentation client = new ClientRepresentation();
client.setClientId("kubernetes-client");
client.setEnabled(true);
client.setClientAuthenticatorType("federated-jwt");
client.setServiceAccountsEnabled(true);
client.setAttributes(Map.of(
"jwt.credential.issuer", "kubernetes",
"jwt.credential.sub", "system:serviceaccount:" + namespace + ":default"
));
keycloak.realm("test").clients().create(client).close();
IdentityProviderRepresentation idp = new IdentityProviderRepresentation();
idp.setAlias("kubernetes");
idp.setProviderId("kubernetes");
keycloak.realm("test").identityProviders().create(idp).close();
}
// Assert
assertSignedJWT(kc);
}
private void assertSignedJWT(Keycloak kc) {
Awaitility.await().atMost(10, MINUTES).ignoreExceptions().untilAsserted(() -> {
Log.info("Starting curl Pod to test if Kubernetes Signed JWT is available");
var curlOutput = inClusterCurlCommand(k8sclient, namespace, Map.of(), "cat", "/var/run/secrets/tokens/test-aud-token");
assertThat(curlOutput.exitCode()).isZero();
var token = curlOutput.stdout();
String url =
"https://" + KeycloakServiceDependentResource.getServiceName(kc) + "." + namespace + ":" + KEYCLOAK_HTTPS_PORT + "/realms/test/protocol/openid-connect/token";
// To not quote arguments as this is only necessary on a shell CLI, but this is executed directly and it then confuses cURL
String[] args = {
"-v", "-k",
url,
"-H", "Content-Type: application/x-www-form-urlencoded",
"--data-urlencode", "grant_type=client_credentials",
"--data-urlencode", "client_id=system:serviceaccount:" + namespace + ":default",
"--data-urlencode", "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"--data-urlencode", "client_assertion=" + token
};
Log.info("Url: '" + url + "'");
String clientCredentialsOutput = inClusterCurl(k8sclient, namespace, args);
assertThat(clientCredentialsOutput).contains("access_token");
});
}
}

View File

@ -151,7 +151,7 @@ public class RealmImportTest extends BaseOperatorTest {
assertThat(envvars.stream().filter(e -> e.getName().equals("MY_SMTP_SERVER")).findAny().get().getValueFrom().getSecretKeyRef().getKey()).isEqualTo("SMTP_SERVER");
}
private List<EnvVar> assertWorkingRealmImport(Keycloak kc) {
private void waitForRealmImport(Keycloak kc) {
var crSelector = k8sclient
.resources(KeycloakRealmImport.class)
.inNamespace(namespace)
@ -177,6 +177,10 @@ public class RealmImportTest extends BaseOperatorTest {
CRAssert.assertKeycloakRealmImportStatusCondition(cr, STARTED, false);
CRAssert.assertKeycloakRealmImportStatusCondition(cr, HAS_ERRORS, false);
});
}
private List<EnvVar> assertWorkingRealmImport(Keycloak kc) {
waitForRealmImport(kc);
var job = k8sclient.batch().v1().jobs().inNamespace(namespace).withName("example-count0-kc").get();
assertThat(job.getSpec().getTemplate().getMetadata().getLabels().get("app")).isEqualTo("keycloak-realm-import");
var container = job.getSpec().getTemplate().getSpec().getContainers().get(0);

View File

@ -21,6 +21,7 @@ import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -127,6 +128,12 @@ public final class K8sUtils {
}
public static CurlResult inClusterCurl(KubernetesClient k8sClient, String namespace, Map<String, String> labels, String... args) {
return inClusterCurlCommand(k8sClient, namespace, labels, "curl", args);
}
public static CurlResult inClusterCurlCommand(KubernetesClient k8sClient, String namespace, Map<String, String> labels, String commandString, String... args) {
Log.infof("Executing curl labels: %s commandString: %s %s", labels, commandString, String.join(" ", Arrays.stream(args).map(s -> s.contains(" ") ? "'" + s + "'" : s).toList()));
Log.infof("Starting cURL in namespace '%s' with labels '%s'", namespace, labels);
var podName = "curl-pod" + (labels.isEmpty()?"":("-" + UUID.randomUUID()));
try {
@ -148,12 +155,19 @@ public final class K8sUtils {
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
ByteArrayOutputStream error = new ByteArrayOutputStream();
String[] command = Stream.concat(Stream.of(commandString), Stream.of(args)).toArray(String[]::new);
try (ExecWatch watch = k8sClient.pods().resource(curlPod).withReadyWaitTimeout(15000)
.writingOutput(output)
.exec(Stream.concat(Stream.of("curl"), Stream.of(args)).toArray(String[]::new))) {
.writingError(error)
.exec(command)) {
var exitCode = watch.exitCode().get(5, TimeUnit.SECONDS);
return new CurlResult(exitCode, output.toString(StandardCharsets.UTF_8));
output.close();
error.close();
var curlResult = new CurlResult(exitCode, output.toString(StandardCharsets.UTF_8), error.toString(StandardCharsets.UTF_8));
Log.infof("curl result: %s", curlResult);
return curlResult;
} finally {
if (!labels.isEmpty()) {
k8sClient.resource(curlPod).delete();
@ -171,7 +185,26 @@ public final class K8sUtils {
.withCommand("sh")
.withName("curl")
.withStdin()
// Mount the projected service account token with audience
.addNewVolumeMount()
.withName("aud-token")
.withMountPath("/var/run/secrets/tokens")
.withReadOnly(true)
.endVolumeMount()
.endContainer()
// Define the projected volume providing a service account token
.addNewVolume()
.withName("aud-token")
.withNewProjected()
.addNewSource()
.withNewServiceAccountToken()
.withAudience("https://example.com:8443/realms/test")
.withExpirationSeconds(3600L)
.withPath("test-aud-token")
.endServiceAccountToken()
.endSource()
.endProjected()
.endVolume()
.endSpec();
}
@ -218,5 +251,5 @@ public final class K8sUtils {
keycloak.getSpec().getHttpSpec().setTlsSecret(null);
}
public record CurlResult(int exitCode, String stdout) {}
public record CurlResult(int exitCode, String stdout, String stderr) {}
}

View File

@ -0,0 +1,56 @@
/*
* Copyright 2025 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.operator.testsuite.utils;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;
import java.security.SecureRandom;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
/**
* Helper class to provide an SSLContext that trusts all server certificates.
*/
public class TrustAllSSLContext {
private static SSLContext sslContext;
public static SSLContext getContext() {
if (sslContext == null) {
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
}
};
try {
sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new SecureRandom());
} catch (NoSuchAlgorithmException | KeyManagementException e) {
throw new RuntimeException("Failed to initialize TrustAll SSLContext", e);
}
}
return sslContext;
}
}

View File

@ -60,6 +60,9 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
@Override
public void authenticateClient(ClientAuthenticationFlowContext context) {
try {
// Mark it as attempted for all items that return directly
context.attempted();
ClientAssertionState clientAssertionState = context.getState(ClientAssertionState.class, ClientAssertionState.supplier());
if (clientAssertionState == null || clientAssertionState.getClientAssertionType() == null) {

View File

@ -33,7 +33,7 @@ public class KubernetesJwksEndpointLoader implements PublicKeyLoader {
throw new RuntimeException("Not running on Kubernetes and Kubernetes JWKS endpoint not set");
}
if (globalEndpoint != null && !globalEndpoint.isEmpty() && globalEndpoint.equals(providerEndpoint)) {
if (globalEndpoint != null && (providerEndpoint == null || providerEndpoint.isEmpty() || globalEndpoint.equals(providerEndpoint))) {
this.endpoint = globalEndpoint;
authenticate = true;
} else {