Skip to content

Add token refresh for exec credentials. #3642

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion util/src/main/java/io/kubernetes/client/util/ClientBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()}.
Expand Down Expand Up @@ -272,6 +275,20 @@ protected ClientBuilder setBasePath(String host, String port) {
* @throws IOException if the files specified in the provided <tt>KubeConfig</tt> 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}.
*
* <p>To load a <tt>KubeConfig</tt>, 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 <tt>ClientBuilder</tt> configured from the provided <tt>KubeConfig</tt>
* @throws IOException if the files specified in the provided <tt>KubeConfig</tt> are not readable
*/
public static ClientBuilder kubeconfig(KubeConfig config, Duration tokenRefreshPeriod) throws IOException {
final ClientBuilder builder = new ClientBuilder();

String server = config.getServer();
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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());
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> tokenSupplier;
private Clock clock;

public RefreshAuthentication(Supplier<String> tokenSupplier, Duration refreshPeriod) {
this(tokenSupplier, refreshPeriod, Clock.systemUTC());
}

public RefreshAuthentication(Supplier<String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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<String, String> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Loading