From e03ee9f6ef92bab3f73f40d36305812e6ae57a35 Mon Sep 17 00:00:00 2001 From: hwayoungjun Date: Wed, 6 May 2026 14:28:28 +0900 Subject: [PATCH 1/2] fix: refresh EKS authentication token per request --- .../util/credentials/EKSAuthentication.java | 69 +++++-- .../credentials/EKSAuthenticationTest.java | 181 ++++++++++++++++-- 2 files changed, 217 insertions(+), 33 deletions(-) diff --git a/util/src/main/java/io/kubernetes/client/util/credentials/EKSAuthentication.java b/util/src/main/java/io/kubernetes/client/util/credentials/EKSAuthentication.java index afa180b918..a099547bcc 100644 --- a/util/src/main/java/io/kubernetes/client/util/credentials/EKSAuthentication.java +++ b/util/src/main/java/io/kubernetes/client/util/credentials/EKSAuthentication.java @@ -13,28 +13,32 @@ package io.kubernetes.client.util.credentials; import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.util.exception.TokenAquisitionException; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.http.SdkHttpMethod; import software.amazon.awssdk.http.SdkHttpRequest; -import software.amazon.awssdk.http.auth.aws.signer.AwsV4FamilyHttpSigner; import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; import software.amazon.awssdk.http.auth.spi.signer.SignedRequest; -import software.amazon.awssdk.utils.http.SdkHttpUtils; - -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Base64; /** * EKS cluster authentication which generates a bearer token from AWS AK/SK. It doesn't require an "aws" * command line tool in the $PATH. */ -public class EKSAuthentication implements Authentication { +public class EKSAuthentication implements Authentication, Interceptor { private static final Logger log = LoggerFactory.getLogger(EKSAuthentication.class); @@ -50,6 +54,11 @@ public EKSAuthentication(AwsCredentialsProvider provider, String region, String } public EKSAuthentication(AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds) { + this(provider, region, clusterName, expirySeconds, Clock.systemUTC()); + } + + EKSAuthentication( + AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds, Clock clock) { this.provider = provider; this.region = region; this.clusterName = clusterName; @@ -57,7 +66,8 @@ public EKSAuthentication(AwsCredentialsProvider provider, String region, String expirySeconds = MAX_EXPIRY_SECONDS; } this.expirySeconds = expirySeconds; - this.stsEndpoint = URI.create("https://sts." + this.region + ".amazonaws.com"); + this.stsEndpoint = URI.create("https://sts." + this.region + ".amazonaws.com/"); + this.clock = clock; } private static final int MAX_EXPIRY_SECONDS = 60 * 15; @@ -67,22 +77,47 @@ public EKSAuthentication(AwsCredentialsProvider provider, String region, String private final URI stsEndpoint; private final int expirySeconds; + private final Clock clock; + private Instant expiry = Instant.MIN; + private String token; @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 = request.newBuilder().header("Authorization", "Bearer " + getToken()).build(); + return chain.proceed(newRequest); + } + + private synchronized String getToken() { + if (Instant.now(clock).isAfter(expiry)) { + try { + token = generateToken(); + } catch (RuntimeException e) { + throw new TokenAquisitionException(e); + } + expiry = Instant.now(clock).plus(expirySeconds, ChronoUnit.SECONDS); + log.info("Generated BEARER token for ApiClient, expiring at {}", expiry); + } + return token; + } + + private String generateToken() { SdkHttpRequest httpRequest = generateStsRequest(); String presignedUrl = requestToPresignedUrl(httpRequest); String encodedUrl = presignedUrlToEncodedUrl(presignedUrl); - String token = "k8s-aws-v1." + encodedUrl; - client.setApiKeyPrefix("Bearer"); - client.setApiKey(token); - log.info("Generated BEARER token for ApiClient, expiring at {}", Instant.now().plus(expirySeconds, ChronoUnit.SECONDS)); + return "k8s-aws-v1." + encodedUrl; } private static String presignedUrlToEncodedUrl(String presignedUrl) { return Base64.getUrlEncoder() .withoutPadding() - .encodeToString(SdkHttpUtils.urlEncodeIgnoreSlashes(presignedUrl).getBytes(StandardCharsets.UTF_8)); + .encodeToString(presignedUrl.getBytes(StandardCharsets.UTF_8)); } private SdkHttpRequest generateStsRequest() { @@ -103,7 +138,7 @@ private String requestToPresignedUrl(SdkHttpRequest httpRequest) { .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, "sts") .putProperty(AwsV4HttpSigner.REGION_NAME, region) .putProperty(AwsV4HttpSigner.AUTH_LOCATION, AwsV4HttpSigner.AuthLocation.QUERY_STRING) - .putProperty(AwsV4HttpSigner.EXPIRATION_DURATION, Duration.of(60, ChronoUnit.SECONDS))); + .putProperty(AwsV4HttpSigner.EXPIRATION_DURATION, Duration.of(expirySeconds, ChronoUnit.SECONDS))); SdkHttpRequest request = signedRequest.request(); return request.getUri().toString(); } diff --git a/util/src/test/java/io/kubernetes/client/util/credentials/EKSAuthenticationTest.java b/util/src/test/java/io/kubernetes/client/util/credentials/EKSAuthenticationTest.java index 0284ca73de..8200e005dc 100644 --- a/util/src/test/java/io/kubernetes/client/util/credentials/EKSAuthenticationTest.java +++ b/util/src/test/java/io/kubernetes/client/util/credentials/EKSAuthenticationTest.java @@ -12,37 +12,186 @@ */ package io.kubernetes.client.util.credentials; +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.matching; +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.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.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +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; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class EKSAuthenticationTest { - @Mock - private AwsCredentialsProvider provider; + private static final String REGION = "us-west-2"; + private static final String CLUSTER_NAME = "test-2"; + private static final String LIST_PODS_PATH = "/api/v1/pods"; + private static final String BEARER_TOKEN_PREFIX = "Bearer k8s-aws-v1."; + + @RegisterExtension + static WireMockExtension apiServer = + WireMockExtension.newInstance().options(options().dynamicPort()).build(); + + @Mock private AwsCredentialsProvider provider; + + private ApiClient apiClient; + private Instant instant; + private MockClock clock; + + @BeforeEach + void setup() { + this.apiClient = new ApiClient(); + this.apiClient.setBasePath("http://localhost:" + apiServer.getPort()); + Configuration.setDefaultApiClient(this.apiClient); + + this.instant = Instant.now(); + this.clock = new MockClock(this.instant); + } + + @Test + void addsBearerTokenToRequests() throws ApiException { + when(provider.resolveCredentials()).thenReturn(credentials("ak", "session")); + CoreV1Api api = authenticatedApi(new EKSAuthentication(provider, REGION, CLUSTER_NAME)); + + stubListPods(); + listPods(api); + + apiServer.verify( + 1, + getRequestedFor(urlPathEqualTo(LIST_PODS_PATH)) + .withHeader("Authorization", matching("Bearer k8s-aws-v1\\..+"))); + verify(provider).resolveCredentials(); + } + + @Test + void reusesTokenBeforeExpiry() throws ApiException { + when(provider.resolveCredentials()).thenReturn(credentials("ak", "session")); + CoreV1Api api = authenticatedApi(new EKSAuthentication(provider, REGION, CLUSTER_NAME, 60, clock)); + + stubListPods(); + listPods(api); + listPods(api); + + List authorizations = authorizationHeaders(); + assertThat(authorizations).hasSize(2); + assertThat(authorizations.get(0)).isEqualTo(authorizations.get(1)); + verify(provider).resolveCredentials(); + } - @Mock - private ApiClient apiClient; + @Test + void refreshesTokenAfterExpiry() throws ApiException { + when(provider.resolveCredentials()) + .thenReturn(credentials("ak-1", "session-1")) + .thenReturn(credentials("ak-2", "session-2")); + CoreV1Api api = authenticatedApi(new EKSAuthentication(provider, REGION, CLUSTER_NAME, 60, clock)); - private String region = "us-west-2"; + stubListPods(); + listPods(api); + clock.setInstant(instant.plusSeconds(70)); + listPods(api); - private String clusterName = "test-2"; + List authorizations = authorizationHeaders(); + assertThat(authorizations).hasSize(2); + assertThat(authorizations.get(0)).isNotEqualTo(authorizations.get(1)); + assertThat(decodedTokenUrl(authorizations.get(0))).contains("X-Amz-Credential=ak-1%2F"); + assertThat(decodedTokenUrl(authorizations.get(1))).contains("X-Amz-Credential=ak-2%2F"); + verify(provider, times(2)).resolveCredentials(); + } @Test - void provideApiClient() { - when(provider.resolveCredentials()).thenReturn(AwsSessionCredentials.create("ak", "sk", "session")); - EKSAuthentication authentication = new EKSAuthentication(provider, region, clusterName); - authentication.provide(apiClient); - verify(apiClient).setApiKey(anyString()); - verify(apiClient).setApiKeyPrefix(anyString()); + void expirySecondsAreCapped() throws ApiException { + when(provider.resolveCredentials()).thenReturn(credentials("ak", "session")); + CoreV1Api api = authenticatedApi(new EKSAuthentication(provider, REGION, CLUSTER_NAME, 1_000)); + + stubListPods(); + listPods(api); + + assertThat(decodedTokenUrl(authorizationHeaders().get(0))) + .startsWith("https://sts.us-west-2.amazonaws.com/?") + .contains("X-Amz-Expires=900"); + } + + private CoreV1Api authenticatedApi(EKSAuthentication authentication) { + authentication.provide(apiClient); + return new CoreV1Api(); + } + + private AwsSessionCredentials credentials(String accessKeyId, String sessionToken) { + return AwsSessionCredentials.create(accessKeyId, "sk", sessionToken); + } + + private void stubListPods() { + apiServer.stubFor( + get(urlPathEqualTo(LIST_PODS_PATH)) + .willReturn(okForContentType("application/json", "{\"items\":[]}"))); + } + + private void listPods(CoreV1Api api) throws ApiException { + api.listPodForAllNamespaces().execute(); + } + + private List authorizationHeaders() { + return apiServer.getAllServeEvents().stream() + .sorted(Comparator.comparing(event -> event.getRequest().getLoggedDate())) + .map(event -> event.getRequest().getHeader("Authorization")) + .collect(Collectors.toList()); + } + + private String decodedTokenUrl(String authorization) { + String encodedToken = authorization.substring(BEARER_TOKEN_PREFIX.length()); + return new String(Base64.getUrlDecoder().decode(encodedToken), StandardCharsets.UTF_8); + } + + static class MockClock extends Clock { + Instant now; + + MockClock(Instant start) { + this.now = start; + } + + 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(); } + } } From 11c4de8b5cc5b2e3ab9e2b172b863bffcf03f606 Mon Sep 17 00:00:00 2001 From: hwayoungjun Date: Sat, 9 May 2026 00:41:33 +0900 Subject: [PATCH 2/2] fix: reuse RefreshAuthentication for EKS token refresh --- .../util/credentials/EKSAuthentication.java | 82 ++++++------------- 1 file changed, 25 insertions(+), 57 deletions(-) diff --git a/util/src/main/java/io/kubernetes/client/util/credentials/EKSAuthentication.java b/util/src/main/java/io/kubernetes/client/util/credentials/EKSAuthentication.java index a099547bcc..432efd6e42 100644 --- a/util/src/main/java/io/kubernetes/client/util/credentials/EKSAuthentication.java +++ b/util/src/main/java/io/kubernetes/client/util/credentials/EKSAuthentication.java @@ -12,9 +12,6 @@ */ package io.kubernetes.client.util.credentials; -import io.kubernetes.client.openapi.ApiClient; -import io.kubernetes.client.util.exception.TokenAquisitionException; -import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Clock; @@ -22,10 +19,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Base64; -import okhttp3.Interceptor; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; @@ -38,9 +32,10 @@ * EKS cluster authentication which generates a bearer token from AWS AK/SK. It doesn't require an "aws" * command line tool in the $PATH. */ -public class EKSAuthentication implements Authentication, Interceptor { +public class EKSAuthentication extends RefreshAuthentication { private static final Logger log = LoggerFactory.getLogger(EKSAuthentication.class); + private static final int MAX_EXPIRY_SECONDS = 60 * 15; /** * Instantiates a new Eks authentication. @@ -59,58 +54,30 @@ public EKSAuthentication(AwsCredentialsProvider provider, String region, String EKSAuthentication( AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds, Clock clock) { - this.provider = provider; - this.region = region; - this.clusterName = clusterName; - if (expirySeconds > MAX_EXPIRY_SECONDS) { - expirySeconds = MAX_EXPIRY_SECONDS; - } - this.expirySeconds = expirySeconds; - this.stsEndpoint = URI.create("https://sts." + this.region + ".amazonaws.com/"); - this.clock = clock; - } - - private static final int MAX_EXPIRY_SECONDS = 60 * 15; - private final AwsCredentialsProvider provider; - private final String region; - private final String clusterName; - private final URI stsEndpoint; - - private final int expirySeconds; - private final Clock clock; - private Instant expiry = Instant.MIN; - private String token; - - @Override - public void provide(ApiClient client) { - OkHttpClient withInterceptor = client.getHttpClient().newBuilder().addInterceptor(this).build(); - client.setHttpClient(withInterceptor); + super( + tokenSupplier(provider, region, clusterName, cappedExpirySeconds(expirySeconds), clock), + Duration.of(cappedExpirySeconds(expirySeconds), ChronoUnit.SECONDS), + clock); + setExpiry(Instant.now(clock).plus(cappedExpirySeconds(expirySeconds), ChronoUnit.SECONDS)); } - @Override - public Response intercept(Interceptor.Chain chain) throws IOException { - Request request = chain.request(); - Request newRequest = request.newBuilder().header("Authorization", "Bearer " + getToken()).build(); - return chain.proceed(newRequest); + private static int cappedExpirySeconds(int expirySeconds) { + return Math.min(expirySeconds, MAX_EXPIRY_SECONDS); } - private synchronized String getToken() { - if (Instant.now(clock).isAfter(expiry)) { - try { - token = generateToken(); - } catch (RuntimeException e) { - throw new TokenAquisitionException(e); - } - expiry = Instant.now(clock).plus(expirySeconds, ChronoUnit.SECONDS); - log.info("Generated BEARER token for ApiClient, expiring at {}", expiry); - } - return token; + private static Supplier tokenSupplier( + AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds, Clock clock) { + return () -> generateToken(provider, region, clusterName, expirySeconds, clock); } - private String generateToken() { - SdkHttpRequest httpRequest = generateStsRequest(); - String presignedUrl = requestToPresignedUrl(httpRequest); + private static String generateToken( + AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds, Clock clock) { + SdkHttpRequest httpRequest = generateStsRequest(region, clusterName); + String presignedUrl = requestToPresignedUrl(httpRequest, provider, region, expirySeconds); String encodedUrl = presignedUrlToEncodedUrl(presignedUrl); + log.info( + "Generated BEARER token for ApiClient, expiring at {}", + Instant.now(clock).plus(expirySeconds, ChronoUnit.SECONDS)); return "k8s-aws-v1." + encodedUrl; } @@ -120,9 +87,9 @@ private static String presignedUrlToEncodedUrl(String presignedUrl) { .encodeToString(presignedUrl.getBytes(StandardCharsets.UTF_8)); } - private SdkHttpRequest generateStsRequest() { + private static SdkHttpRequest generateStsRequest(String region, String clusterName) { return SdkHttpRequest.builder() - .uri(stsEndpoint) + .uri(URI.create("https://sts." + region + ".amazonaws.com/")) .putRawQueryParameter("Version", "2011-06-15") .putRawQueryParameter("Action", "GetCallerIdentity") .method(SdkHttpMethod.GET) @@ -130,10 +97,11 @@ private SdkHttpRequest generateStsRequest() { .build(); } - private String requestToPresignedUrl(SdkHttpRequest httpRequest) { + private static String requestToPresignedUrl( + SdkHttpRequest httpRequest, AwsCredentialsProvider provider, String region, int expirySeconds) { AwsV4HttpSigner signer = AwsV4HttpSigner.create(); SignedRequest signedRequest = - signer.sign(r -> r.identity(this.provider.resolveCredentials()) + signer.sign(r -> r.identity(provider.resolveCredentials()) .request(httpRequest) .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, "sts") .putProperty(AwsV4HttpSigner.REGION_NAME, region)