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 the kube-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.