From 64db0b07e321c4c81588d1b9da7e4798f523b476 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Wed, 17 Apr 2024 14:26:02 +0200 Subject: [PATCH] test: complete behavioral test suite for OpenIDConnectionUtils Signed-off-by: Marc Nuri --- .../client/http/TestStandardHttpClient.java | 7 +- .../http/TestStandardHttpClientBuilder.java | 5 + .../OpenIDConnectionUtilsBehaviorTest.java | 375 ++++++++++++++++++ 3 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtilsBehaviorTest.java diff --git a/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/TestStandardHttpClient.java b/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/TestStandardHttpClient.java index 2e94a749a9..7abc63f90b 100644 --- a/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/TestStandardHttpClient.java +++ b/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/TestStandardHttpClient.java @@ -17,7 +17,6 @@ import lombok.AllArgsConstructor; import lombok.Getter; -import org.opentest4j.AssertionFailedError; import java.net.URI; import java.nio.ByteBuffer; @@ -67,7 +66,7 @@ public synchronized CompletableFuture buildWebSocketDirect( future = find(standardWebSocketBuilder.asHttpRequest().uri()).wsFutures.poll() .get(standardWebSocketBuilder, listener); } catch (Exception e) { - throw new AssertionFailedError("Unexpected exception", e); + throw new AssertionError("Unexpected exception", e); } recordedBuildWebSocketDirects.add(new RecordedBuildWebSocketDirect(standardWebSocketBuilder, listener, future)); return future; @@ -80,7 +79,7 @@ public synchronized CompletableFuture> consumeBytesDirec try { future = find(request.uri()).futures.poll().get(request, consumer); } catch (Exception e) { - throw new AssertionFailedError("Unexpected exception", e); + throw new AssertionError("Unexpected exception", e); } recordedConsumeBytesDirects.add(new RecordedConsumeBytesDirect(request, consumer, future)); return future; @@ -98,7 +97,7 @@ private Expectation find(URI uri) { return e.getValue(); } } - throw new AssertionFailedError("Missing expectation for path: " + path); + throw new AssertionError("Missing expectation for path: " + path); } public final TestStandardHttpClient expect(String pathRegex, Throwable exception) { diff --git a/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/TestStandardHttpClientBuilder.java b/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/TestStandardHttpClientBuilder.java index 6e2d653955..c58464a044 100644 --- a/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/TestStandardHttpClientBuilder.java +++ b/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/TestStandardHttpClientBuilder.java @@ -19,6 +19,8 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicBoolean; +import javax.net.ssl.TrustManager; + public class TestStandardHttpClientBuilder extends StandardHttpClientBuilder { @@ -51,4 +53,7 @@ public TestStandardHttpClientBuilder tag(Object value) { return (TestStandardHttpClientBuilder) super.tag(value); } + public final TrustManager[] getTrustManagers() { + return trustManagers; + } } diff --git a/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtilsBehaviorTest.java b/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtilsBehaviorTest.java new file mode 100644 index 0000000000..944467f273 --- /dev/null +++ b/kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/utils/OpenIDConnectionUtilsBehaviorTest.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes.client.utils; + +import io.fabric8.kubernetes.api.model.AuthProviderConfig; +import io.fabric8.kubernetes.api.model.NamedAuthInfo; +import io.fabric8.kubernetes.api.model.NamedAuthInfoBuilder; +import io.fabric8.kubernetes.api.model.NamedClusterBuilder; +import io.fabric8.kubernetes.api.model.NamedContextBuilder; +import io.fabric8.kubernetes.client.Config; +import io.fabric8.kubernetes.client.ConfigBuilder; +import io.fabric8.kubernetes.client.http.TestStandardHttpClientBuilder; +import io.fabric8.kubernetes.client.http.TestStandardHttpClientFactory; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Principal; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.X509ExtendedTrustManager; + +import static io.fabric8.kubernetes.client.http.TestStandardHttpClientFactory.Mode.SINGLETON; +import static io.fabric8.kubernetes.client.utils.OpenIDConnectionUtils.resolveOIDCTokenFromAuthConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OpenIDConnectionUtilsBehaviorTest { + + @TempDir + Path tempDir; + private TestStandardHttpClientFactory httpClientFactory; + private TestStandardHttpClientBuilder httpClientBuilder; + private PrintStream originalSystemErrStream; + private ByteArrayOutputStream systemErr; + private Config originalConfig; + private Map authProviderConfig; + + @BeforeEach + void setUp() throws Exception { + httpClientFactory = new TestStandardHttpClientFactory(SINGLETON); + httpClientBuilder = httpClientFactory.newBuilder(); + // Log capture + originalSystemErrStream = System.err; + systemErr = new ByteArrayOutputStream(); + System.setErr(new PrintStream(systemErr)); + // Valid self-signed certificate + final KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + final X509CertificateHolder cert = new JcaX509v3CertificateBuilder( + new X500Name("o=Fabric8"), BigInteger.ONE, new Date(), new Date(new Date().getTime() + 1000L), + new X500Name("cn=auth.fabric8.example.com"), keyPair.getPublic()) + .build(new JcaContentSignerBuilder("SHA256WithRSA").build(keyPair.getPrivate())); + final Path validCert = tempDir.resolve("valid.crt"); + Files.write(validCert, Base64.getEncoder().encode(cert.getEncoded())); + // Original configuration (valid) + final Path kubeConfigFile = tempDir.resolve("kube-config"); + final io.fabric8.kubernetes.api.model.Config kubeConfig = new io.fabric8.kubernetes.api.model.ConfigBuilder() + .addToClusters(new NamedClusterBuilder() + .withName("default-cluster") + .withNewCluster().withServer("https://cluster.example.com") + .withCertificateAuthority(validCert.toFile().getAbsolutePath()).endCluster() + .build()) + .addToUsers(new NamedAuthInfoBuilder() + .withName("default-user") + .withNewUser().withAuthProvider(new AuthProviderConfig()).endUser() + .build()) + .addToContexts(new NamedContextBuilder() + .withName("default").withNewContext().withCluster("default-cluster").withUser("default-user").endContext() + .build()) + .withCurrentContext("default") + .build(); + Files.write(kubeConfigFile, Serialization.asYaml(kubeConfig).getBytes(StandardCharsets.UTF_8)); + originalConfig = new ConfigBuilder(Config.empty()) + .withFile(tempDir.resolve("kube-config").toFile()) + .build() + .refresh(); + // Auth provider configuration (minimal) + authProviderConfig = new HashMap<>(); + authProviderConfig.put("id-token", "original-token"); + authProviderConfig.put("idp-issuer-url", "https://auth.fabric8.example.com"); + authProviderConfig.put("client-id", "id-of-test-client"); + } + + @AfterEach + void tearDown() { + System.setErr(originalSystemErrStream); + } + + @Test + @DisplayName("Unsupported token refresh, resolves token from auth provider config") + void withUnsupportedTokenRefresh() throws Exception { + final String result = resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder) + .get(10, TimeUnit.SECONDS); + assertThat(result).isEqualTo("original-token"); + } + + @Nested + @DisplayName("With support for token refresh") + class WithRefreshToken { + + @BeforeEach + void setUp() { + authProviderConfig.put("refresh-token", "original-refresh-token"); + } + + @Test + @DisplayName("With invalid cert data in original config, throws certificate exception") + void withInvalidCertDataInConfig() { + originalConfig = new ConfigBuilder(originalConfig) + .withCaCertData(Base64.getEncoder().encodeToString(new byte[] { 48, -17, -65, -67, 3, 6 })) + .withCaCertFile(null) + .build(); + assertThatThrownBy(() -> resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Could not import idp certificate") + .cause() + .isInstanceOf(CertificateException.class); + } + + @Test + @DisplayName("With invalid cert data in provided auth config, throws certificate exception") + void withInvalidCertDataInAuthProviderConfig() { + authProviderConfig.put("idp-certificate-authority-data", + Base64.getEncoder().encodeToString(new byte[] { 48, -17, -65, -67, 3, 6 })); + assertThatThrownBy(() -> resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Could not import idp certificate") + .cause() + .isInstanceOf(CertificateException.class); + } + + @Test + @DisplayName("With invalid cert file in original config, throws certificate exception") + void withInvalidCertFileInConfig() throws IOException { + final Path invalidCert = tempDir.resolve("invalid.crt"); + Files.write(invalidCert, new byte[] { 48, -17, -65, -67, 3, 6 }); + originalConfig = new ConfigBuilder(originalConfig) + .withCaCertFile(invalidCert.toFile().getAbsolutePath()) + .build(); + assertThatThrownBy(() -> resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Could not import idp certificate") + .cause() + .isInstanceOf(CertificateException.class); + } + + @Test + @DisplayName("With missing cert file in original config, throws NPE") + // TODO: improve handling for missing or invalid content + void withMissingCertFileInConfig() { + final Path missingCert = tempDir.resolve("missing.crt"); + originalConfig = new ConfigBuilder(originalConfig) + .withCaCertFile(missingCert.toFile().getAbsolutePath()) + .build(); + assertThatThrownBy(() -> resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder)) + .isInstanceOf(NullPointerException.class); + } + + @Nested + @DisplayName("With 404 OpenID Connect Discovery response") + @Disabled("This scenario is not implemented") // TODO + class WithNotFoundOpenIDConnectDiscovery { + @BeforeEach + void setUp() { + httpClientFactory.expect("/.well-known/openid-configuration", + 404, "Not Found /.well-known/openid-configuration"); + } + + @Test + @DisplayName("Resolves token from auth provider config (fallback)") + void fallbacksToOriginalToken() throws Exception { + final String result = resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder) + .get(10, TimeUnit.SECONDS); + assertThat(result).isEqualTo("original-token"); + } + } + + @Nested + @DisplayName("With valid OpenID Connect Discovery") + class WithValidOpenIDConnectDiscovery { + + @BeforeEach + void setUp() { + httpClientFactory.expect("/.well-known/openid-configuration", 200, "{" + + "\"issuer\": \"https://auth.example.com\"," + + "\"token_endpoint\": \"https://auth.example.com/token\"," + + "\"response_types_supported\": [\"code\",\"id_token\"]" + + "}"); + } + + @Nested + @DisplayName("With 404 token response") + class WithNotFoundTokenResponse { + + private String result; + + @BeforeEach + void setUp() throws Exception { + httpClientFactory.expect("/token", 404, "Not Found /token"); + result = resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder) + .get(10, TimeUnit.SECONDS); + } + + @Test + @DisplayName("Resolves token from auth provider config (fallback)") + void fallbacksToOriginalToken() { + assertThat(result).isEqualTo("original-token"); + } + + @Test + @DisplayName("Logs refresh token response") + void logsRefreshTokenResponse() { + assertThat(systemErr.toString()).contains("Response: Not Found /token"); + } + + @Test + @DisplayName("Logs token fallback warning") + void logsTokenFallbackWarning() { + assertThat(systemErr.toString()) + .contains( + "token response did not contain an id_token, either the scope \\\"openid\\\" wasn't requested upon login, or the provider doesn't support id_tokens as part of the refresh response."); + } + } + + @Nested + @DisplayName("With invalid token response body") + class WithInvalidTokenResponseBody { + + private String result; + + @BeforeEach + void setUp() throws Exception { + httpClientFactory.expect("/token", 200, "Not JSON"); + result = resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder) + .get(10, TimeUnit.SECONDS); + } + + @Test + @DisplayName("Resolves token from auth provider config (fallback)") + void fallbacksToOriginalToken() { + assertThat(result).isEqualTo("original-token"); + } + + @Test + @DisplayName("Logs JSON parsing error") + void logsJsonParsingError() { + assertThat(systemErr.toString()) + .contains("Failure in fetching refresh token:") + .contains("Cannot construct instance of `java.util.LinkedHashMap`"); + } + + @Test + @DisplayName("Logs token fallback warning") + void logsTokenFallbackWarning() { + assertThat(systemErr.toString()) + .contains( + "token response did not contain an id_token, either the scope \\\"openid\\\" wasn't requested upon login, or the provider doesn't support id_tokens as part of the refresh response."); + } + } + + @Test + @DisplayName("With valid token repsonse and missing kube config, logs warning") + void withValidTokenResponseAndMissingKubeConfig() throws Exception { + Files.delete(originalConfig.getFile().toPath()); + httpClientFactory.expect("/token", 200, "{" + + "\"id_token\": \"new-token\"" + + "}"); + final String result = resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder) + .get(10, TimeUnit.SECONDS); + assertThat(result).isEqualTo("new-token"); + assertThat(systemErr.toString()) + .contains("oidc: failure while persisting new tokens into KUBECONFIG"); + } + + @Nested + @DisplayName("With valid token response") + class WithValidTokenResponse { + + private String result; + + @BeforeEach + void setUp() throws Exception { + httpClientFactory.expect("/token", 200, "{" + + "\"id_token\": \"new-token\"," + + "\"refresh_token\": \"new-refresh-token\"" + + "}"); + result = resolveOIDCTokenFromAuthConfig(originalConfig, authProviderConfig, httpClientBuilder) + .get(10, TimeUnit.SECONDS); + } + + @Test + @DisplayName("Resolves token from token endpoint") + void resolvesTokenFromTokenEndpoint() { + assertThat(result).isEqualTo("new-token"); + } + + @Test + @DisplayName("Updates current config auth provider config with new token") + void updatesCurrentConfigAuthProviderConfigWithNewToken() { + assertThat(originalConfig) + .extracting(Config::getAuthProvider) + .extracting(AuthProviderConfig::getConfig) + .asInstanceOf(InstanceOfAssertFactories.map(String.class, String.class)) + .containsEntry("id-token", "new-token") + .containsEntry("refresh-token", "new-refresh-token"); + } + + @Test + @DisplayName("Updates current config auth provider config with new token in file") + void updatesCurrentConfigAuthProviderConfigWithNewTokenInFile() throws Exception { + assertThat( + Serialization.unmarshal(new String(Files.readAllBytes(originalConfig.getFile().toPath()), StandardCharsets.UTF_8), + io.fabric8.kubernetes.api.model.Config.class)) + .extracting(io.fabric8.kubernetes.api.model.Config::getUsers) + .asInstanceOf(InstanceOfAssertFactories.list(NamedAuthInfo.class)) + .singleElement() + .extracting("user.authProvider.config") + .asInstanceOf(InstanceOfAssertFactories.map(String.class, String.class)) + .containsEntry("id-token", "new-token") + .containsEntry("refresh-token", "new-refresh-token"); + } + + @Test + @DisplayName("Certificate is loaded into HttpClient trust manager") + void certificateIsLoadedIntoHttpClientTrustManager() throws Exception { + assertThat(httpClientBuilder.getTrustManagers()) + .singleElement() + .asInstanceOf(InstanceOfAssertFactories.type(X509ExtendedTrustManager.class)) + .extracting(X509ExtendedTrustManager::getAcceptedIssuers) + .asInstanceOf(InstanceOfAssertFactories.array(X509Certificate[].class)) + .extracting(X509Certificate::getSubjectDN) + .extracting(Principal::getName) + .contains("CN=auth.fabric8.example.com"); + } + } + } + } +}