mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
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:
parent
da7993896d
commit
3288f83dc9
@ -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[]
|
||||
|
||||
@ -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::[]
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user