5 minutes
How to build an AWS token for EKS cluster authentication
Problem
- You want your AWS Lambda to talk to your EKS cluster. However, as of the time of this article, the aws-sdk-js does not support this out of the box.
- You want to understand how an AWS session token gets generated.
Preliminaries
- You have a working AWS Lambda deployed.
- You have an EKS cluster setup.
- You’ve made sure that networking does not stop the Lambda from talking to the EKS control plane.
- You have added the necessary permissions in the
aws-auth
config map in thekube-system
namespace, so the role the AWS Lambda assumes has permissions to access the cluster.
Solution
tl;dr: If you prefer to just copy and paste the code StackOverflow style, here is a Github Gist
These are the constants we will use to build the request
const AUTH_SERVICE = 'sts'
const AUTH_COMMAND = 'GetCallerIdentity'
const AUTH_API_VERSION = '2011-06-15'
const URL_TIMEOUT = 60
const TOKEN_PREFIX = 'k8s-aws-v1.'
const CLUSTER_NAME_HEADER = 'x-k8s-aws-id'
const KEY_TYPE_IDENTIFIER = 'aws4_request'
const ALGORITHM_QUERY_PARAM = 'X-Amz-Algorithm'
const CREDENTIAL_QUERY_PARAM = 'X-Amz-Credential'
const AMZ_DATE_QUERY_PARAM = 'X-Amz-Date'
const AMZ_EXPIRES_QUERY_PARAM = 'X-Amz-Expires'
const AMZ_SIGNED_HEADERS_QUERY_PARAM = 'X-Amz-SignedHeaders'
const TOKEN_QUERY_PARAM = 'X-Amz-Security-Token'
const SIGNATURE_QUERY_PARAM = 'X-Amz-Signature'
const ALGORITHM_IDENTIFIER = 'AWS4-HMAC-SHA256'
Building the request and payload for signing
Let’s assume the function that pulls it all together looks as follows
const generateAWSToken = (clusterName: string, accessKeyId: string, secretAccessKey: string, sessionToken: string, clientRegion: string): string => {
const now = new Date()
const signingDate = iso8601(now).replace(/[\-:]/g, '')
const shortDate = signingDate.substr(0, 8)
const method = 'GET'
const path = '/'
const headers = {
host: `sts.${clientRegion}.amazonaws.com`,
[CLUSTER_NAME_HEADER]: clusterName,
}
const credentialScope = buildScope(shortDate, clientRegion, AUTH_SERVICE)
const params = buildParams(accessKeyId, credentialScope, signingDate, sessionToken)
const canonicalRequest = buildCanonicalRequest(method, path, params, headers)
const stringToSign = buildStringToSign(signingDate, credentialScope, canonicalRequest)
const signature = sign(secretAccessKey, stringToSign, shortDate, clientRegion, AUTH_SERVICE)
const url = `https://${AUTH_SERVICE}.${clientRegion}.amazonaws.com/?${params}&${SIGNATURE_QUERY_PARAM}=${signature}`
const authToken = TOKEN_PREFIX + rtrim(Buffer.from(url).toString('base64'), '=')
return authToken
}
and let’s assume it’s called with the following parameters
const awsAccessKeyId = "ASIAZLXXNKS7QX2QVS6J"
const awsSecretAccessKey = "G6Cgq2sc5Xc26agFIymzewHdsQ4pGGPSjNVqdcsB"
const awsSessionToken = "FwoGZXIvYXdzEEMaDDAQUCVCZWjeY5j5GyLuARK2UVXm7IG8jzWrGAyn26Ilu+QgH3zfThfAon0SnBrXLRBBD3Ff+MONZ78QtF829CVZRkmMYXv+7K1fIkif0t8chtmtktwL6W4LqjUx/khC5NCUbPqv4PJ3rAzDky3+kNp3+azGw4AVo1kGe4YE/znw5dWz441ZPywmodgWwm09HESiee0W6f8EEJgjkFW72w80RI56EWkei8Oc50Yp7hOiRFkUdnziXx77iIYjCTEumW7VF72/y0XapzaVgGuUGjT2TAiyC+5wMrVUide5pMBqo4wcb2tH2HBpQTObttaUxnPzhvY+StEem7x313Qo5+LoiAYyK/JCSne1tpR4h8u0VrMyLD62TPeYDxkru+La/AhLsNMhvww/33yBKCT2u1M="
const region = "eu-west-3"
const clusterName = "eks-cluster"
const authToken = generateAWSToken(clusterName, awsAccessKeyId,awsSecretAccessKey, awsSessionToken, region)
The first step is to create the scope parameter
const buildScope = (shortDate: string, region: string, service: string): string => {
return `${shortDate}/${region}/${service}/${KEY_TYPE_IDENTIFIER}`
}
the result should be
20210817/eu-west-3/sts/aws4_request
Next we need to build the request parameters
const buildParams = (accessKeyId: string, credentialScope: string, longDate: string, sessionToken?: string): string => {
return [
`Action=${AUTH_COMMAND}`,
`Version=${AUTH_API_VERSION}`,
`${ALGORITHM_QUERY_PARAM}=${ALGORITHM_IDENTIFIER}`,
`${CREDENTIAL_QUERY_PARAM}=${encodeURIComponent(`${accessKeyId}/${credentialScope}`)}`,
`${AMZ_DATE_QUERY_PARAM}=${longDate}`,
`${AMZ_EXPIRES_QUERY_PARAM}=${URL_TIMEOUT}`,
sessionToken ? `${TOKEN_QUERY_PARAM}=${encodeURIComponent(sessionToken)}` : undefined,
`${AMZ_SIGNED_HEADERS_QUERY_PARAM}=${encodeURIComponent(`host;${CLUSTER_NAME_HEADER}`)}`,
]
.filter((val) => val)
.join('&')
}
the resulting query params should look as follows
Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAZLXXNKS7QX2QVS6J%2F20210817%2Feu-west-3%2Fsts%2Faws4_request&X-Amz-Date=20210817T172449Z&X-Amz-Expires=60&X-Amz-Security-Token=FwoGZXIvYXdzEEMaDDAQUCVCZWjeY5j5GyLuARK2UVXm7IG8jzWrGAyn26Ilu%2BQgH3zfThfAon0SnBrXLRBBD3Ff%2BMONZ78QtF829CVZRkmMYXv%2B7K1fIkif0t8chtmtktwL6W4LqjUx%2FkhC5NCUbPqv4PJ3rAzDky3%2BkNp3%2BazGw4AVo1kGe4YE%2Fznw5dWz441ZPywmodgWwm09HESiee0W6f8EEJgjkFW72w80RI56EWkei8Oc50Yp7hOiRFkUdnziXx77iIYjCTEumW7VF72%2Fy0XapzaVgGuUGjT2TAiyC%2B5wMrVUide5pMBqo4wcb2tH2HBpQTObttaUxnPzhvY%2BStEem7x313Qo5%2BLoiAYyK%2FJCSne1tpR4h8u0VrMyLD62TPeYDxkru%2BLa%2FAhLsNMhvww%2F33yBKCT2u1M%3D&X-Amz-SignedHeaders=host%3Bx-k8s-aws-id
Next we generate the full canonical request
const buildCanonicalRequest = (method: string, path: string, params: string, headers: { [key: string]: any }): string => {
const res = [method, path, params]
const headersToSign = Object.keys(headers).sort()
const headerValues = headersToSign.map((k) => `${k}${headers[k]}`).join('\n') + '\n'
res.push(headerValues)
res.push(headersToSign.join(';'))
// magic hash for empty body
res.push('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
return res.join('\n')
}
Notice the magic no-body string e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
that gets added.
This is a sha256 hash of an empty file in Linux.
The result from the buildCanonicalRequest
call should look roughly something like this:
GET
/
Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAZLXXNKS7QX2QVS6J%2F20210817%2Feu-west-3%2Fsts%2Faws4_request&X-Amz-Date=20210817T172449Z&X-Amz-Expires=60&X-Amz-Security-Token=FwoGZXIvYXdzEEMaDDAQUCVCZWjeY5j5GyLuARK2UVXm7IG8jzWrGAyn26Ilu%2BQgH3zfThfAon0SnBrXLRBBD3Ff%2BMONZ78QtF829CVZRkmMYXv%2B7K1fIkif0t8chtmtktwL6W4LqjUx%2FkhC5NCUbPqv4PJ3rAzDky3%2BkNp3%2BazGw4AVo1kGe4YE%2Fznw5dWz441ZPywmodgWwm09HESiee0W6f8EEJgjkFW72w80RI56EWkei8Oc50Yp7hOiRFkUdnziXx77iIYjCTEumW7VF72%2Fy0XapzaVgGuUGjT2TAiyC%2B5wMrVUide5pMBqo4wcb2tH2HBpQTObttaUxnPzhvY%2BStEem7x313Qo5%2BLoiAYyK%2FJCSne1tpR4h8u0VrMyLD62TPeYDxkru%2BLa%2FAhLsNMhvww%2F33yBKCT2u1M%3D&X-Amz-SignedHeaders=host%3Bx-k8s-aws-id
host:sts.eu-west-3.amazonaws.com
x-k8s-aws-id:eks-cluster
host;x-k8s-aws-id
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
This by the way is exactly what it sounds like - it’s a HTTP/1.1 compliant request. The reason for the format is that it gets sent as is once the k8s API server receives the request.
Next we generate a newline seperated string from timestamp, scope and sha256 hash of the canonical request
const sha256 = (msg: string, encoding: BinaryToTextEncoding = 'hex'): string =>
crypto.createHash('sha256').update(msg).digest(encoding)
const buildStringToSign = (timestamp: string, scope: string, canonicalRequest: string): string =>
[ALGORITHM_IDENTIFIER, timestamp, scope, sha256(canonicalRequest)].join('\n')
it should look like so
AWS4-HMAC-SHA256
20210817T172449Z
20210817/eu-west-3/sts/aws4_request
6b476172365151d52d47e7e09bb9fa0a8e87fcecf9a95b2f806223d4a4c04a6b
Generating the signature
const hmacSha256 = (key: string | Buffer, msg: string, encoding?: BinaryToTextEncoding): string | Buffer => {
const res = crypto.createHmac('sha256', key).update(msg)
if (encoding) {
return res.digest(encoding)
}
return res.digest()
}
const sign = (key: string, stringToSign: string, dateStamp: string, regionName: string, serviceName: string): string => {
const kDate = hmacSha256('AWS4' + key, dateStamp)
const kRegion = hmacSha256(kDate, regionName)
const kService = hmacSha256(kRegion, serviceName)
const kSigning = hmacSha256(kService, KEY_TYPE_IDENTIFIER)
return hmacSha256(kSigning, stringToSign, 'hex') as string
}
You will notice that on each cycle the update happens directly on the Buffer, as opposed to converting it to a string. Keep that in mind when rehashing in NodeJs, as these bugs take a fair amount of time to troubleshoot. The resulting signature looks as follows
d8a7cbaa1132a7b0a34b773ca73f85dbe7cbe1b13113fb16252dc79886c24523
The authentication token
Putting all this together gives us
https://sts.eu-west-3.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAZLXXNKS7QX2QVS6J%2F20210817%2Feu-west-3%2Fsts%2Faws4_request&X-Amz-Date=20210817T172449Z&X-Amz-Expires=60&X-Amz-Security-Token=FwoGZXIvYXdzEEMaDDAQUCVCZWjeY5j5GyLuARK2UVXm7IG8jzWrGAyn26Ilu%2BQgH3zfThfAon0SnBrXLRBBD3Ff%2BMONZ78QtF829CVZRkmMYXv%2B7K1fIkif0t8chtmtktwL6W4LqjUx%2FkhC5NCUbPqv4PJ3rAzDky3%2BkNp3%2BazGw4AVo1kGe4YE%2Fznw5dWz441ZPywmodgWwm09HESiee0W6f8EEJgjkFW72w80RI56EWkei8Oc50Yp7hOiRFkUdnziXx77iIYjCTEumW7VF72%2Fy0XapzaVgGuUGjT2TAiyC%2B5wMrVUide5pMBqo4wcb2tH2HBpQTObttaUxnPzhvY%2BStEem7x313Qo5%2BLoiAYyK%2FJCSne1tpR4h8u0VrMyLD62TPeYDxkru%2BLa%2FAhLsNMhvww%2F33yBKCT2u1M%3D&X-Amz-SignedHeaders=host%3Bx-k8s-aws-id&X-Amz-Signature=d8a7cbaa1132a7b0a34b773ca73f85dbe7cbe1b13113fb16252dc79886c24523
and a base64 encoding of that url is the Authorization
token that kubectl or k8s sdk send when authenticates against the EKS control plane
k8s-aws-v1.aHR0cHM6Ly9zdHMuZXUtd2VzdC0zLmFtYXpvbmF3cy5jb20vP0FjdGlvbj1HZXRDYWxsZXJJZGVudGl0eSZWZXJzaW9uPTIwMTEtMDYtMTUmWC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BU0lBWkxYWE5LUzdRWDJRVlM2SiUyRjIwMjEwODE3JTJGZXUtd2VzdC0zJTJGc3RzJTJGYXdzNF9yZXF1ZXN0JlgtQW16LURhdGU9MjAyMTA4MTdUMTcyNDQ5WiZYLUFtei1FeHBpcmVzPTYwJlgtQW16LVNlY3VyaXR5LVRva2VuPUZ3b0daWEl2WVhkekVFTWFEREFRVUNWQ1pXamVZNWo1R3lMdUFSSzJVVlhtN0lHOGp6V3JHQXluMjZJbHUlMkJRZ0gzemZUaGZBb24wU25CclhMUkJCRDNGZiUyQk1PTlo3OFF0RjgyOUNWWlJrbU1ZWHYlMkI3SzFmSWtpZjB0OGNodG10a3R3TDZXNExxalV4JTJGa2hDNU5DVWJQcXY0UEozckF6RGt5MyUyQmtOcDMlMkJhekd3NEFWbzFrR2U0WUUlMkZ6bnc1ZFd6NDQxWlB5d21vZGdXd20wOUhFU2llZTBXNmY4RUVKZ2prRlc3Mnc4MFJJNTZFV2tlaThPYzUwWXA3aE9pUkZrVWRuemlYeDc3aUlZakNURXVtVzdWRjcyJTJGeTBYYXB6YVZnR3VVR2pUMlRBaXlDJTJCNXdNclZVaWRlNXBNQnFvNHdjYjJ0SDJIQnBRVE9idHRhVXhuUHpodlklMkJTdEVlbTd4MzEzUW81JTJCTG9pQVl5SyUyRkpDU25lMXRwUjRoOHUwVnJNeUxENjJUUGVZRHhrcnUlMkJMYSUyRkFoTHNOTWh2d3clMkYzM3lCS0NUMnUxTSUzRCZYLUFtei1TaWduZWRIZWFkZXJzPWhvc3QlM0J4LWs4cy1hd3MtaWQmWC1BbXotU2lnbmF0dXJlPWQ4YTdjYmFhMTEzMmE3YjBhMzRiNzczY2E3M2Y4NWRiZTdjYmUxYjEzMTEzZmIxNjI1MmRjNzk4ODZjMjQ1MjM
Using the token in practice
The generateAWSToken
function can be injected into the standard k8s Javascript SDK. Given that AWS_ACCESS_KEY_ID
, AWS_REGION
, AWS_SECRET_ACCESS_KEY
, AWS_SESSION_TOKEN
are passed as env vars to the AWS Lambda the only thing you need to worry about is setting EKS_CA_DATA
, EKS_CLUSTER_NAME
, EKS_CONTROL_PLANE_URL
import { KubeConfig, Cluster, Context, User } from '@kubernetes/client-node'
import {
AWS_ACCESS_KEY_ID,
AWS_REGION,
AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN,
EKS_CA_DATA,
EKS_CLUSTER_NAME,
EKS_CONTROL_PLANE_URL,
} from '../config'
export const buildK8sConfig = (): KubeConfig => {
const kc = new KubeConfig()
kc.addCluster({
name: EKS_CLUSTER_NAME,
caData: EKS_CA_DATA,
server: EKS_CONTROL_PLANE_URL,
} as Cluster)
kc.addContext({
cluster: EKS_CLUSTER_NAME,
name: EKS_CLUSTER_NAME,
user: EKS_CLUSTER_NAME,
} as Context)
kc.addUser({
name: EKS_CLUSTER_NAME,
token: generateAWSToken(EKS_CLUSTER_NAME, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_REGION),
} as User)
kc.setCurrentContext(EKS_CLUSTER_NAME)
return kc
}
That’s all, now your JavaScript Lambda can talk to your EKS cluster.