Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,30 @@
*/
package io.kubernetes.client.util.credentials;

import io.kubernetes.client.openapi.ApiClient;
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 java.util.function.Supplier;
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 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.
Expand All @@ -50,60 +49,64 @@ public EKSAuthentication(AwsCredentialsProvider provider, String region, String
}

public EKSAuthentication(AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds) {
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(provider, region, clusterName, expirySeconds, Clock.systemUTC());
}

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;
EKSAuthentication(
AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds, Clock clock) {
super(
tokenSupplier(provider, region, clusterName, cappedExpirySeconds(expirySeconds), clock),
Duration.of(cappedExpirySeconds(expirySeconds), ChronoUnit.SECONDS),
clock);
setExpiry(Instant.now(clock).plus(cappedExpirySeconds(expirySeconds), ChronoUnit.SECONDS));
}

private static int cappedExpirySeconds(int expirySeconds) {
return Math.min(expirySeconds, MAX_EXPIRY_SECONDS);
}

private final int expirySeconds;
private static Supplier<String> tokenSupplier(
AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds, Clock clock) {
return () -> generateToken(provider, region, clusterName, expirySeconds, clock);
}

@Override
public void provide(ApiClient client) {
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);
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));
log.info(
"Generated BEARER token for ApiClient, expiring at {}",
Instant.now(clock).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() {
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)
.putHeader("x-k8s-aws-id", clusterName)
.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)
.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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<String> 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<String> 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();
}
}
}