Skip to content

Commit 582d94d

Browse files
committed
Add token refresh for exec credentials.
1 parent 0e94811 commit 582d94d

File tree

6 files changed

+284
-8
lines changed

6 files changed

+284
-8
lines changed

pom.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
<modules>
1212
<!-- These modules are not released -->
1313
<module>e2e</module>
14+
<!--
1415
<module>examples</module>
16+
-->
1517
<module>client-java-contrib/cert-manager</module>
1618
<module>client-java-contrib/prometheus-operator</module>
1719
<module>client-java-contrib/admissionreview</module>

util/src/main/java/io/kubernetes/client/util/ClientBuilder.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ public class ClientBuilder {
7070
private Duration readTimeout = Duration.ZERO;
7171
// default health check is once a minute
7272
private Duration pingInterval = Duration.ofMinutes(1);
73+
// time to refresh exec based credentials
74+
// TODO: Read the expiration from the credential itself
75+
private Duration execCredentialRefreshPeriod = null;
7376

7477
/**
7578
* Creates an {@link ApiClient} by calling {@link #standard()} and {@link #build()}.
@@ -272,6 +275,20 @@ protected ClientBuilder setBasePath(String host, String port) {
272275
* @throws IOException if the files specified in the provided <tt>KubeConfig</tt> are not readable
273276
*/
274277
public static ClientBuilder kubeconfig(KubeConfig config) throws IOException {
278+
return kubeconfig(config, null);
279+
}
280+
281+
/**
282+
* Creates a builder which is pre-configured from a {@link KubeConfig}.
283+
*
284+
* <p>To load a <tt>KubeConfig</tt>, see {@link KubeConfig#loadKubeConfig(Reader)}.
285+
*
286+
* @param config The {@link KubeConfig} to configure the builder from.
287+
* @param tokenRefreshPeriod If the KubeConfig generates a bearer token, after this interval, it will be refreshed.
288+
* @return <tt>ClientBuilder</tt> configured from the provided <tt>KubeConfig</tt>
289+
* @throws IOException if the files specified in the provided <tt>KubeConfig</tt> are not readable
290+
*/
291+
public static ClientBuilder kubeconfig(KubeConfig config, Duration tokenRefreshPeriod) throws IOException {
275292
final ClientBuilder builder = new ClientBuilder();
276293

277294
String server = config.getServer();
@@ -295,7 +312,7 @@ public static ClientBuilder kubeconfig(KubeConfig config) throws IOException {
295312
builder.setVerifyingSsl(config.verifySSL());
296313

297314
builder.setBasePath(server);
298-
builder.setAuthentication(new KubeconfigAuthentication(config));
315+
builder.setAuthentication(new KubeconfigAuthentication(config, tokenRefreshPeriod));
299316
return builder;
300317
}
301318

util/src/main/java/io/kubernetes/client/util/credentials/KubeconfigAuthentication.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import io.kubernetes.client.util.KubeConfig;
1717
import java.io.IOException;
1818
import java.nio.charset.StandardCharsets;
19+
import java.time.Duration;
1920
import java.util.Map;
2021
import org.apache.commons.lang3.StringUtils;
2122

@@ -33,8 +34,9 @@
3334
public class KubeconfigAuthentication implements Authentication {
3435

3536
private final Authentication delegateAuthentication;
37+
private Duration tokenRefreshPeriod;
3638

37-
public KubeconfigAuthentication(final KubeConfig config) throws IOException {
39+
public KubeconfigAuthentication(final KubeConfig config, Duration tokenRefreshPeriod) throws IOException {
3840
byte[] clientCert =
3941
config.getDataOrFileRelative(
4042
config.getClientCertificateData(), config.getClientCertificateFile());
@@ -60,7 +62,9 @@ public KubeconfigAuthentication(final KubeConfig config) throws IOException {
6062
if (credentials != null) {
6163
if (StringUtils.isNotEmpty(credentials.get(KubeConfig.CRED_TOKEN_KEY))) {
6264
delegateAuthentication =
63-
new AccessTokenAuthentication(credentials.get(KubeConfig.CRED_TOKEN_KEY));
65+
tokenRefreshPeriod == null ?
66+
new AccessTokenAuthentication(credentials.get(KubeConfig.CRED_TOKEN_KEY)) :
67+
new RefreshAuthentication(() -> config.getCredentials().get(KubeConfig.CRED_TOKEN_KEY), tokenRefreshPeriod);
6468
return;
6569
} else if (StringUtils.isNotEmpty(
6670
credentials.get(KubeConfig.CRED_CLIENT_CERTIFICATE_DATA_KEY))
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
package io.kubernetes.client.util.credentials;
14+
15+
import io.kubernetes.client.openapi.ApiClient;
16+
17+
import java.io.IOException;
18+
import java.time.Clock;
19+
import java.time.Duration;
20+
import java.time.Instant;
21+
import java.util.function.Supplier;
22+
23+
import okhttp3.Interceptor;
24+
import okhttp3.OkHttpClient;
25+
import okhttp3.Request;
26+
import okhttp3.Response;
27+
28+
// TODO: prefer OpenAPI backed Auentication once it is available. see details in
29+
// https://github.com/OpenAPITools/openapi-generator/pull/6036. currently, the
30+
// workaround is to hijack the http request.
31+
// TODO: Merge this with TokenFileAuthentication.
32+
public class RefreshAuthentication implements Authentication, Interceptor {
33+
private Instant expiry;
34+
private Duration refreshPeriod;
35+
private String token;
36+
private Supplier<String> tokenSupplier;
37+
private Clock clock;
38+
39+
public RefreshAuthentication(Supplier<String> tokenSupplier, Duration refreshPeriod) {
40+
this(tokenSupplier, refreshPeriod, Clock.systemUTC());
41+
}
42+
43+
public RefreshAuthentication(Supplier<String> tokenSupplier, Duration refreshPeriod, Clock clock) {
44+
this.expiry = Instant.MIN;
45+
this.refreshPeriod = refreshPeriod;
46+
this.token = tokenSupplier.get();
47+
this.tokenSupplier = tokenSupplier;
48+
this.clock = clock;
49+
}
50+
51+
private String getToken() {
52+
if (Instant.now(this.clock).isAfter(this.expiry)) {
53+
this.token = tokenSupplier.get();
54+
expiry = Instant.now(this.clock).plusSeconds(refreshPeriod.toSeconds());
55+
}
56+
return this.token;
57+
}
58+
59+
public Duration getRefreshPeriod() {
60+
return this.refreshPeriod;
61+
}
62+
63+
public void setExpiry(Instant expiry) {
64+
this.expiry = expiry;
65+
}
66+
67+
@Override
68+
public void provide(ApiClient client) {
69+
OkHttpClient withInterceptor = client.getHttpClient().newBuilder().addInterceptor(this).build();
70+
client.setHttpClient(withInterceptor);
71+
}
72+
73+
@Override
74+
public Response intercept(Interceptor.Chain chain) throws IOException {
75+
Request request = chain.request();
76+
Request newRequest;
77+
newRequest = request.newBuilder().header("Authorization", "Bearer " + getToken()).build();
78+
return chain.proceed(newRequest);
79+
}
80+
}

util/src/test/java/io/kubernetes/client/util/credentials/KubeconfigAuthenticationTest.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import io.kubernetes.client.util.KubeConfig;
2222
import java.io.IOException;
23+
import java.time.Duration;
2324
import java.util.HashMap;
2425
import java.util.Map;
2526
import org.junit.jupiter.api.Test;
@@ -39,7 +40,7 @@ void certificateAuthenticationFromExecCommand() throws IOException {
3940
certCredentials.put(KubeConfig.CRED_CLIENT_KEY_DATA_KEY, "key");
4041
when(kubeConfig.getCredentials()).thenReturn(certCredentials);
4142

42-
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig);
43+
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null);
4344

4445
assertThat(kubeconfigAuthentication.getDelegateAuthentication())
4546
.isInstanceOf(ClientCertificateAuthentication.class);
@@ -49,7 +50,7 @@ void certificateAuthenticationFromExecCommand() throws IOException {
4950
void certificateAuthenticationFromKubeConfig() throws IOException {
5051
when(kubeConfig.getDataOrFileRelative(any(), any())).thenReturn("data".getBytes());
5152

52-
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig);
53+
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null);
5354

5455
assertThat(kubeconfigAuthentication.getDelegateAuthentication())
5556
.isInstanceOf(ClientCertificateAuthentication.class);
@@ -61,7 +62,7 @@ void usernamePasswordAuthenticationFromKubeConfig() throws IOException {
6162
when(kubeConfig.getUsername()).thenReturn("user");
6263
when(kubeConfig.getPassword()).thenReturn("password");
6364

64-
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig);
65+
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null);
6566

6667
assertThat(kubeconfigAuthentication.getDelegateAuthentication())
6768
.isInstanceOf(UsernamePasswordAuthentication.class);
@@ -73,15 +74,31 @@ void accessTokenAuthenticationFromExecComand() throws IOException {
7374
certCredentials.put(KubeConfig.CRED_TOKEN_KEY, "token");
7475
when(kubeConfig.getCredentials()).thenReturn(certCredentials);
7576

76-
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig);
77+
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null);
7778

7879
assertThat(kubeconfigAuthentication.getDelegateAuthentication())
7980
.isInstanceOf(AccessTokenAuthentication.class);
8081
}
8182

83+
@Test
84+
void accessTokenAuthenticationFromExecComandWithRefresh() throws IOException {
85+
Map<String, String> certCredentials = new HashMap<>();
86+
certCredentials.put(KubeConfig.CRED_TOKEN_KEY, "token");
87+
when(kubeConfig.getCredentials()).thenReturn(certCredentials);
88+
89+
Duration period = Duration.ofSeconds(60);
90+
91+
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, period);
92+
93+
assertThat(kubeconfigAuthentication.getDelegateAuthentication())
94+
.isInstanceOf(RefreshAuthentication.class);
95+
RefreshAuthentication auth = (RefreshAuthentication) kubeconfigAuthentication.getDelegateAuthentication();
96+
assertThat(auth.getRefreshPeriod()).isEqualTo(period);
97+
}
98+
8299
@Test
83100
void dummyAuthentication() throws IOException {
84-
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig);
101+
KubeconfigAuthentication kubeconfigAuthentication = new KubeconfigAuthentication(kubeConfig, null);
85102

86103
assertThat(kubeconfigAuthentication.getDelegateAuthentication())
87104
.isInstanceOf(DummyAuthentication.class);
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
package io.kubernetes.client.util.credentials;
14+
15+
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
16+
import static com.github.tomakehurst.wiremock.client.WireMock.get;
17+
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
18+
import static com.github.tomakehurst.wiremock.client.WireMock.okForContentType;
19+
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
20+
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
21+
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
import static org.mockito.ArgumentMatchers.any;
24+
import static org.mockito.Mockito.times;
25+
import static org.mockito.Mockito.verify;
26+
import static org.mockito.Mockito.when;
27+
28+
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
29+
30+
import io.kubernetes.client.Resources;
31+
import io.kubernetes.client.openapi.ApiClient;
32+
import io.kubernetes.client.openapi.ApiException;
33+
import io.kubernetes.client.openapi.Configuration;
34+
import io.kubernetes.client.openapi.apis.CoreV1Api;
35+
import io.kubernetes.client.util.KubeConfig;
36+
37+
import java.io.IOException;
38+
import java.time.Clock;
39+
import java.time.Duration;
40+
import java.time.Instant;
41+
import java.time.ZoneId;
42+
import java.time.ZoneOffset;
43+
import java.util.HashMap;
44+
import java.util.Map;
45+
import org.junit.jupiter.api.BeforeEach;
46+
import org.junit.jupiter.api.Test;
47+
import org.junit.jupiter.api.extension.ExtendWith;
48+
import org.junit.jupiter.api.extension.RegisterExtension;
49+
50+
class RefreshAuthenticationTest {
51+
@RegisterExtension
52+
static WireMockExtension apiServer = WireMockExtension.newInstance().options(options().dynamicPort()).build();
53+
54+
private int refreshCount;
55+
private Instant instant;
56+
private MockClock clock;
57+
58+
@BeforeEach
59+
void setup() {
60+
final ApiClient client = new ApiClient();
61+
client.setBasePath("http://localhost:" + apiServer.getPort());
62+
this.instant = Instant.now();
63+
this.clock = new MockClock(instant);
64+
RefreshAuthentication auth = new RefreshAuthentication(
65+
() -> {
66+
refreshCount++;
67+
return "foo " + refreshCount;
68+
}, Duration.ofSeconds(60),
69+
this.clock);
70+
auth.provide(client);
71+
Configuration.setDefaultApiClient(client);
72+
73+
refreshCount = 0;
74+
}
75+
76+
@Test
77+
void tokenProvided() throws ApiException {
78+
apiServer.stubFor(
79+
get(urlPathEqualTo("/api/v1/pods")).willReturn(okForContentType("application/json",
80+
"{\"items\":[]}")));
81+
CoreV1Api api = new CoreV1Api();
82+
83+
api.listPodForAllNamespaces().execute();
84+
apiServer.verify(
85+
1,
86+
getRequestedFor(urlPathEqualTo("/api/v1/pods"))
87+
.withHeader("Authorization", equalTo("Bearer foo 1")));
88+
assertThat(refreshCount).isEqualTo(1);
89+
}
90+
91+
@Test
92+
void tokenDoesntRefreshEarly() throws ApiException {
93+
apiServer.stubFor(
94+
get(urlPathEqualTo("/api/v1/pods")).willReturn(okForContentType("application/json",
95+
"{\"items\":[]}")));
96+
CoreV1Api api = new CoreV1Api();
97+
98+
api.listPodForAllNamespaces().execute();
99+
api.listPodForAllNamespaces().execute();
100+
101+
apiServer.verify(
102+
2,
103+
getRequestedFor(urlPathEqualTo("/api/v1/pods"))
104+
.withHeader("Authorization", equalTo("Bearer foo 1")));
105+
assertThat(refreshCount).isEqualTo(1);
106+
}
107+
108+
@Test
109+
void tokenRefreshes() throws ApiException {
110+
apiServer.stubFor(
111+
get(urlPathEqualTo("/api/v1/pods")).willReturn(okForContentType("application/json",
112+
"{\"items\":[]}")));
113+
CoreV1Api api = new CoreV1Api();
114+
115+
api.listPodForAllNamespaces().execute();
116+
clock.setInstant(instant.plusSeconds(70));
117+
api.listPodForAllNamespaces().execute();
118+
119+
apiServer.verify(
120+
1,
121+
getRequestedFor(urlPathEqualTo("/api/v1/pods"))
122+
.withHeader("Authorization", equalTo("Bearer foo 1")));
123+
apiServer.verify(
124+
1,
125+
getRequestedFor(urlPathEqualTo("/api/v1/pods"))
126+
.withHeader("Authorization", equalTo("Bearer foo 2")));
127+
assertThat(refreshCount).isEqualTo(2);
128+
}
129+
130+
static class MockClock extends Clock {
131+
Instant now;
132+
133+
public MockClock(Instant start) {
134+
this.now = start;
135+
}
136+
137+
public void setInstant(Instant instant) {
138+
this.now = instant;
139+
}
140+
141+
@Override
142+
public Instant instant() {
143+
return now;
144+
}
145+
146+
@Override
147+
public ZoneId getZone() {
148+
return ZoneOffset.UTC;
149+
}
150+
151+
@Override
152+
public Clock withZone(ZoneId zone) {
153+
throw new UnsupportedOperationException();
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)