-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix: refresh EKS authentication token per request #4741
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -50,61 +49,82 @@ 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; | ||
|
|
||
| private final int expirySeconds; | ||
|
|
||
| @Override | ||
| public void provide(ApiClient client) { | ||
| 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)); | ||
| EKSAuthentication( | ||
| AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds, Clock clock) { | ||
| super( | ||
| new EksTokenSupplier(provider, region, clusterName, cappedExpirySeconds(expirySeconds)), | ||
| Duration.of(cappedExpirySeconds(expirySeconds), ChronoUnit.SECONDS), | ||
| clock); | ||
| setExpiry(Instant.now(clock).plus(cappedExpirySeconds(expirySeconds), ChronoUnit.SECONDS)); | ||
| } | ||
|
|
||
| private static String presignedUrlToEncodedUrl(String presignedUrl) { | ||
| return Base64.getUrlEncoder() | ||
| .withoutPadding() | ||
| .encodeToString(SdkHttpUtils.urlEncodeIgnoreSlashes(presignedUrl).getBytes(StandardCharsets.UTF_8)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are we removing the urlEncodeIgnoreSlashes call?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @brendandburns Thanks for catching this. I checked the AWS SDK path locally. The signer adds the That was why I initially removed the extra However, I should have called this out explicitly in the PR, and this is outside the refresh-related scope of the change. Since the existing implementation included this extra encoding step, I agree it is safer to preserve the previous token payload behavior. I also should have considered compatibility with other AWS SDK versions and the existing behavior more carefully. I will restore
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I looked into this more deeply with an actual EKS API request, and I think the issue is a bit broader than the refresh change itself. What I foundIt looks like the existing In my own usage, I had been using a custom Token payload differenceThe expected decoded token payload is the presigned STS URL itself: However, with the extra That means the URL structure itself is encoded, and already encoded query values such as STS pathI also changed the STS request construction to keep the endpoint URI pathless and set the request path explicitly: .uri(stsEndpoint)
.encodedPath("/")This produces the working presigned URL form: Reproduction branchI created a separate branch to make this easier to verify outside this PR: https://github.com/hwayoungjun/java/tree/repro/eks-authentication-401 That branch compares:
The smoke test result against a real EKS cluster was: Run command: ConclusionFrom the real EKS verification, it looks like the existing Because of that, I committed the token payload fix here by removing the extra whole-URL encoding and making the STS root path explicit with That said, I realize this is separate from the refresh behavior itself. Would you prefer that I split the token payload fix into a separate issue/PR, and keep this PR focused only on refresh support? |
||
| private static int cappedExpirySeconds(int expirySeconds) { | ||
| return Math.min(expirySeconds, MAX_EXPIRY_SECONDS); | ||
| } | ||
|
|
||
| private SdkHttpRequest generateStsRequest() { | ||
| return SdkHttpRequest.builder() | ||
| .uri(stsEndpoint) | ||
| .putRawQueryParameter("Version", "2011-06-15") | ||
| .putRawQueryParameter("Action", "GetCallerIdentity") | ||
| .method(SdkHttpMethod.GET) | ||
| .putHeader("x-k8s-aws-id", clusterName) | ||
| .build(); | ||
| } | ||
| private static class EksTokenSupplier implements Supplier<String> { | ||
| private final AwsCredentialsProvider provider; | ||
| private final String region; | ||
| private final String clusterName; | ||
| private final int expirySeconds; | ||
| private final URI stsEndpoint; | ||
|
|
||
| EksTokenSupplier(AwsCredentialsProvider provider, String region, String clusterName, int expirySeconds) { | ||
| this.provider = provider; | ||
| this.region = region; | ||
| this.clusterName = clusterName; | ||
| this.expirySeconds = expirySeconds; | ||
| this.stsEndpoint = URI.create("https://sts." + this.region + ".amazonaws.com"); | ||
| } | ||
|
|
||
| @Override | ||
| public String get() { | ||
| return generateToken(); | ||
| } | ||
|
|
||
| private String requestToPresignedUrl(SdkHttpRequest httpRequest) { | ||
| AwsV4HttpSigner signer = AwsV4HttpSigner.create(); | ||
| SignedRequest signedRequest = | ||
| signer.sign(r -> r.identity(this.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))); | ||
| SdkHttpRequest request = signedRequest.request(); | ||
| return request.getUri().toString(); | ||
| private String generateToken() { | ||
| SdkHttpRequest httpRequest = generateStsRequest(); | ||
| String presignedUrl = requestToPresignedUrl(httpRequest); | ||
| String encodedUrl = presignedUrlToEncodedUrl(presignedUrl); | ||
| 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(presignedUrl.getBytes(StandardCharsets.UTF_8)); | ||
| } | ||
|
|
||
| private SdkHttpRequest generateStsRequest() { | ||
| return SdkHttpRequest.builder() | ||
| .uri(stsEndpoint) | ||
| .encodedPath("/") | ||
| .putRawQueryParameter("Version", "2011-06-15") | ||
| .putRawQueryParameter("Action", "GetCallerIdentity") | ||
| .method(SdkHttpMethod.GET) | ||
| .putHeader("x-k8s-aws-id", clusterName) | ||
| .build(); | ||
| } | ||
|
|
||
| private String requestToPresignedUrl(SdkHttpRequest httpRequest) { | ||
| AwsV4HttpSigner signer = AwsV4HttpSigner.create(); | ||
| SignedRequest signedRequest = | ||
| signer.sign(r -> r.identity(this.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(expirySeconds, ChronoUnit.SECONDS))); | ||
| SdkHttpRequest request = signedRequest.request(); | ||
| return request.getUri().toString(); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's keep the STS endpoint as a member variable vs. constructing it every time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@brendandburns This was also a side effect of using static methods instead of a separate supplier class. With the small supplier implementation outlined below,
stsEndpointcould be kept as a field and would not need to be reconstructed each time. I can update it that way if that direction sounds right.