diff --git a/util/src/main/java/io/kubernetes/client/util/ClientBuilder.java b/util/src/main/java/io/kubernetes/client/util/ClientBuilder.java index c3a72c6f77..ee3ba4087c 100644 --- a/util/src/main/java/io/kubernetes/client/util/ClientBuilder.java +++ b/util/src/main/java/io/kubernetes/client/util/ClientBuilder.java @@ -70,6 +70,9 @@ public class ClientBuilder { private Duration readTimeout = Duration.ZERO; // default health check is once a minute private Duration pingInterval = Duration.ofMinutes(1); + // time to refresh exec based credentials + // TODO: Read the expiration from the credential itself + private Duration execCredentialRefreshPeriod = null; /** * Creates an {@link ApiClient} by calling {@link #standard()} and {@link #build()}. @@ -272,6 +275,20 @@ protected ClientBuilder setBasePath(String host, String port) { * @throws IOException if the files specified in the provided KubeConfig are not readable */ public static ClientBuilder kubeconfig(KubeConfig config) throws IOException { + return kubeconfig(config, null); + } + + /** + * Creates a builder which is pre-configured from a {@link KubeConfig}. + * + *

To load a KubeConfig, see {@link KubeConfig#loadKubeConfig(Reader)}. + * + * @param config The {@link KubeConfig} to configure the builder from. + * @param tokenRefreshPeriod If the KubeConfig generates a bearer token, after this interval, it will be refreshed. + * @return ClientBuilder configured from the provided KubeConfig + * @throws IOException if the files specified in the provided KubeConfig are not readable + */ + public static ClientBuilder kubeconfig(KubeConfig config, Duration tokenRefreshPeriod) throws IOException { final ClientBuilder builder = new ClientBuilder(); String server = config.getServer(); @@ -295,7 +312,7 @@ public static ClientBuilder kubeconfig(KubeConfig config) throws IOException { builder.setVerifyingSsl(config.verifySSL()); builder.setBasePath(server); - builder.setAuthentication(new KubeconfigAuthentication(config)); + builder.setAuthentication(new KubeconfigAuthentication(config, tokenRefreshPeriod)); return builder; } diff --git a/util/src/main/java/io/kubernetes/client/util/credentials/KubeconfigAuthentication.java b/util/src/main/java/io/kubernetes/client/util/credentials/KubeconfigAuthentication.java index c2430565e5..236308fb46 100644 --- a/util/src/main/java/io/kubernetes/client/util/credentials/KubeconfigAuthentication.java +++ b/util/src/main/java/io/kubernetes/client/util/credentials/KubeconfigAuthentication.java @@ -16,6 +16,7 @@ import io.kubernetes.client.util.KubeConfig; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Map; import org.apache.commons.lang3.StringUtils; @@ -33,8 +34,9 @@ public class KubeconfigAuthentication implements Authentication { private final Authentication delegateAuthentication; + private Duration tokenRefreshPeriod; - public KubeconfigAuthentication(final KubeConfig config) throws IOException { + public KubeconfigAuthentication(final KubeConfig config, Duration tokenRefreshPeriod) throws IOException { byte[] clientCert = config.getDataOrFileRelative( config.getClientCertificateData(), config.getClientCertificateFile()); @@ -60,7 +62,9 @@ public KubeconfigAuthentication(final KubeConfig config) throws IOException { if (credentials != null) { if (StringUtils.isNotEmpty(credentials.get(KubeConfig.CRED_TOKEN_KEY))) { delegateAuthentication = - new AccessTokenAuthentication(credentials.get(KubeConfig.CRED_TOKEN_KEY)); + tokenRefreshPeriod == null ? + new AccessTokenAuthentication(credentials.get(KubeConfig.CRED_TOKEN_KEY)) : + new RefreshAuthentication(() -> config.getCredentials().get(KubeConfig.CRED_TOKEN_KEY), tokenRefreshPeriod); return; } else if (StringUtils.isNotEmpty( credentials.get(KubeConfig.CRED_CLIENT_CERTIFICATE_DATA_KEY)) diff --git a/util/src/main/java/io/kubernetes/client/util/credentials/RefreshAuthentication.java b/util/src/main/java/io/kubernetes/client/util/credentials/RefreshAuthentication.java new file mode 100644 index 0000000000..f03c610cb1 --- /dev/null +++ b/util/src/main/java/io/kubernetes/client/util/credentials/RefreshAuthentication.java @@ -0,0 +1,80 @@ +/* +Copyright 2024 The Kubernetes Authors. +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.kubernetes.client.util.credentials; + +import io.kubernetes.client.openapi.ApiClient; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.function.Supplier; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +// TODO: prefer OpenAPI backed Auentication once it is available. see details in +// https://github.com/OpenAPITools/openapi-generator/pull/6036. currently, the +// workaround is to hijack the http request. +// TODO: Merge this with TokenFileAuthentication. +public class RefreshAuthentication implements Authentication, Interceptor { + private Instant expiry; + private Duration refreshPeriod; + private String token; + private Supplier tokenSupplier; + private Clock clock; + + public RefreshAuthentication(Supplier tokenSupplier, Duration refreshPeriod) { + this(tokenSupplier, refreshPeriod, Clock.systemUTC()); + } + + public RefreshAuthentication(Supplier tokenSupplier, Duration refreshPeriod, Clock clock) { + this.expiry = Instant.MIN; + this.refreshPeriod = refreshPeriod; + this.token = tokenSupplier.get(); + this.tokenSupplier = tokenSupplier; + this.clock = clock; + } + + private String getToken() { + if (Instant.now(this.clock).isAfter(this.expiry)) { + this.token = tokenSupplier.get(); + expiry = Instant.now(this.clock).plusSeconds(refreshPeriod.toSeconds()); + } + return this.token; + } + + public Duration getRefreshPeriod() { + return this.refreshPeriod; + } + + public void setExpiry(Instant expiry) { + this.expiry = expiry; + } + + @Override + public void provide(ApiClient client) { + OkHttpClient withInterceptor = client.getHttpClient().newBuilder().addInterceptor(this).build(); + client.setHttpClient(withInterceptor); + } + + @Override + public Response intercept(Interceptor.Chain chain) throws IOException { + Request request = chain.request(); + Request newRequest; + newRequest = request.newBuilder().header("Authorization", "Bearer " + getToken()).build(); + return chain.proceed(newRequest); + } +} diff --git a/util/src/test/java/io/kubernetes/client/util/credentials/KubeconfigAuthenticationTest.java b/util/src/test/java/io/kubernetes/client/util/credentials/KubeconfigAuthenticationTest.java index 64c920e6b6..ce936bcb16 100644 --- a/util/src/test/java/io/kubernetes/client/util/credentials/KubeconfigAuthenticationTest.java +++ b/util/src/test/java/io/kubernetes/client/util/credentials/KubeconfigAuthenticationTest.java @@ -20,6 +20,7 @@ import io.kubernetes.client.util.KubeConfig; import java.io.IOException; +import java.time.Duration; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; @@ -39,7 +40,7 @@ void certificateAuthenticationFromExecCommand() throws IOException { certCredentials.put(KubeConfig.CRED_CLIENT_KEY_DATA_KEY, "key"); when(kubeConfig.getCredentials()).thenReturn(certCredentials); - KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig); + KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null); assertThat(kubeconfigAuthentication.getDelegateAuthentication()) .isInstanceOf(ClientCertificateAuthentication.class); @@ -49,7 +50,7 @@ void certificateAuthenticationFromExecCommand() throws IOException { void certificateAuthenticationFromKubeConfig() throws IOException { when(kubeConfig.getDataOrFileRelative(any(), any())).thenReturn("data".getBytes()); - KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig); + KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null); assertThat(kubeconfigAuthentication.getDelegateAuthentication()) .isInstanceOf(ClientCertificateAuthentication.class); @@ -61,7 +62,7 @@ void usernamePasswordAuthenticationFromKubeConfig() throws IOException { when(kubeConfig.getUsername()).thenReturn("user"); when(kubeConfig.getPassword()).thenReturn("password"); - KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig); + KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null); assertThat(kubeconfigAuthentication.getDelegateAuthentication()) .isInstanceOf(UsernamePasswordAuthentication.class); @@ -73,15 +74,31 @@ void accessTokenAuthenticationFromExecComand() throws IOException { certCredentials.put(KubeConfig.CRED_TOKEN_KEY, "token"); when(kubeConfig.getCredentials()).thenReturn(certCredentials); - KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig); + KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null); assertThat(kubeconfigAuthentication.getDelegateAuthentication()) .isInstanceOf(AccessTokenAuthentication.class); } + @Test + void accessTokenAuthenticationFromExecComandWithRefresh() throws IOException { + Map certCredentials = new HashMap<>(); + certCredentials.put(KubeConfig.CRED_TOKEN_KEY, "token"); + when(kubeConfig.getCredentials()).thenReturn(certCredentials); + + Duration period = Duration.ofSeconds(60); + + KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, period); + + assertThat(kubeconfigAuthentication.getDelegateAuthentication()) + .isInstanceOf(RefreshAuthentication.class); + RefreshAuthentication auth = (RefreshAuthentication) kubeconfigAuthentication.getDelegateAuthentication(); + assertThat(auth.getRefreshPeriod()).isEqualTo(period); + } + @Test void dummyAuthentication() throws IOException { - KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig); + KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null); assertThat(kubeconfigAuthentication.getDelegateAuthentication()) .isInstanceOf(DummyAuthentication.class); diff --git a/util/src/test/java/io/kubernetes/client/util/credentials/RefreshAuthenticationTest.java b/util/src/test/java/io/kubernetes/client/util/credentials/RefreshAuthenticationTest.java new file mode 100644 index 0000000000..95d0b12d1e --- /dev/null +++ b/util/src/test/java/io/kubernetes/client/util/credentials/RefreshAuthenticationTest.java @@ -0,0 +1,156 @@ +/* +Copyright 2024 The Kubernetes Authors. +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.kubernetes.client.util.credentials; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.okForContentType; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; + +import io.kubernetes.client.Resources; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.util.KubeConfig; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; + +class RefreshAuthenticationTest { + @RegisterExtension + static WireMockExtension apiServer = WireMockExtension.newInstance().options(options().dynamicPort()).build(); + + private int refreshCount; + private Instant instant; + private MockClock clock; + + @BeforeEach + void setup() { + final ApiClient client = new ApiClient(); + client.setBasePath("http://localhost:" + apiServer.getPort()); + this.instant = Instant.now(); + this.clock = new MockClock(instant); + RefreshAuthentication auth = new RefreshAuthentication( + () -> { + refreshCount++; + return "foo " + refreshCount; + }, Duration.ofSeconds(60), + this.clock); + auth.provide(client); + Configuration.setDefaultApiClient(client); + + refreshCount = 0; + } + + @Test + void tokenProvided() throws ApiException { + apiServer.stubFor( + get(urlPathEqualTo("/api/v1/pods")).willReturn(okForContentType("application/json", + "{\"items\":[]}"))); + CoreV1Api api = new CoreV1Api(); + + api.listPodForAllNamespaces().execute(); + apiServer.verify( + 1, + getRequestedFor(urlPathEqualTo("/api/v1/pods")) + .withHeader("Authorization", equalTo("Bearer foo 1"))); + assertThat(refreshCount).isEqualTo(1); + } + + @Test + void tokenDoesntRefreshEarly() throws ApiException { + apiServer.stubFor( + get(urlPathEqualTo("/api/v1/pods")).willReturn(okForContentType("application/json", + "{\"items\":[]}"))); + CoreV1Api api = new CoreV1Api(); + + api.listPodForAllNamespaces().execute(); + api.listPodForAllNamespaces().execute(); + + apiServer.verify( + 2, + getRequestedFor(urlPathEqualTo("/api/v1/pods")) + .withHeader("Authorization", equalTo("Bearer foo 1"))); + assertThat(refreshCount).isEqualTo(1); + } + + @Test + void tokenRefreshes() throws ApiException { + apiServer.stubFor( + get(urlPathEqualTo("/api/v1/pods")).willReturn(okForContentType("application/json", + "{\"items\":[]}"))); + CoreV1Api api = new CoreV1Api(); + + api.listPodForAllNamespaces().execute(); + clock.setInstant(instant.plusSeconds(70)); + api.listPodForAllNamespaces().execute(); + + apiServer.verify( + 1, + getRequestedFor(urlPathEqualTo("/api/v1/pods")) + .withHeader("Authorization", equalTo("Bearer foo 1"))); + apiServer.verify( + 1, + getRequestedFor(urlPathEqualTo("/api/v1/pods")) + .withHeader("Authorization", equalTo("Bearer foo 2"))); + assertThat(refreshCount).isEqualTo(2); + } + + static class MockClock extends Clock { + Instant now; + + public MockClock(Instant start) { + this.now = start; + } + + public void setInstant(Instant instant) { + this.now = instant; + } + + @Override + public Instant instant() { + return now; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + throw new UnsupportedOperationException(); + } + } +}