Skip to content

Commit

Permalink
feat: add iam aws provider (#1359)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexisFilipozzi authored Dec 18, 2024
1 parent f800683 commit 6cd0223
Show file tree
Hide file tree
Showing 2 changed files with 237 additions and 2 deletions.
234 changes: 234 additions & 0 deletions src/IamAwsProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import * as fs from 'node:fs/promises'
import * as http from 'node:http'
import * as https from 'node:https'
import { URL, URLSearchParams } from 'node:url'

import { CredentialProvider } from './CredentialProvider.ts'
import { Credentials } from './Credentials.ts'
import { parseXml } from './internal/helper.ts'
import { request } from './internal/request.ts'
import { readAsString } from './internal/response.ts'

interface AssumeRoleResponse {
AssumeRoleWithWebIdentityResponse: {
AssumeRoleWithWebIdentityResult: {
Credentials: {
AccessKeyId: string
SecretAccessKey: string
SessionToken: string
Expiration: string
}
}
}
}

interface EcsCredentials {
AccessKeyID: string
SecretAccessKey: string
Token: string
Expiration: string
Code: string
Message: string
}

export interface IamAwsProviderOptions {
customEndpoint?: string
transportAgent?: http.Agent
}

export class IamAwsProvider extends CredentialProvider {
private readonly customEndpoint?: string

private _credentials: Credentials | null
private readonly transportAgent?: http.Agent
private accessExpiresAt = ''

constructor({ customEndpoint = undefined, transportAgent = undefined }: IamAwsProviderOptions) {
super({ accessKey: '', secretKey: '' })

this.customEndpoint = customEndpoint
this.transportAgent = transportAgent

/**
* Internal Tracking variables
*/
this._credentials = null
}

async getCredentials(): Promise<Credentials> {
if (!this._credentials || this.isAboutToExpire()) {
this._credentials = await this.fetchCredentials()
}
return this._credentials
}

private async fetchCredentials(): Promise<Credentials> {
try {
// check for IRSA (https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)
const tokenFile = process.env.AWS_WEB_IDENTITY_TOKEN_FILE
if (tokenFile) {
return await this.fetchCredentialsUsingTokenFile(tokenFile)
}

// try with IAM role for EC2 instances (https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)
let tokenHeader = 'Authorization'
let token = process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN
const relativeUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
const fullUri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI
let url: URL
if (relativeUri) {
url = new URL(relativeUri, 'http://169.254.170.2')
} else if (fullUri) {
url = new URL(fullUri)
} else {
token = await this.fetchImdsToken()
tokenHeader = 'X-aws-ec2-metadata-token'
url = await this.getIamRoleNamedUrl(token)
}

return this.requestCredentials(url, tokenHeader, token)
} catch (err) {
throw new Error(`Failed to get Credentials: ${err}`, { cause: err })
}
}

private async fetchCredentialsUsingTokenFile(tokenFile: string): Promise<Credentials> {
const token = await fs.readFile(tokenFile, { encoding: 'utf8' })
const region = process.env.AWS_REGION
const stsEndpoint = new URL(region ? `https://sts.${region}.amazonaws.com` : 'https://sts.amazonaws.com')

const hostValue = stsEndpoint.hostname
const portValue = stsEndpoint.port
const qryParams = new URLSearchParams({
Action: 'AssumeRoleWithWebIdentity',
Version: '2011-06-15',
})

const roleArn = process.env.AWS_ROLE_ARN
if (roleArn) {
qryParams.set('RoleArn', roleArn)
const roleSessionName = process.env.AWS_ROLE_SESSION_NAME
qryParams.set('RoleSessionName', roleSessionName ? roleSessionName : Date.now().toString())
}

qryParams.set('WebIdentityToken', token)
qryParams.sort()

const requestOptions = {
hostname: hostValue,
port: portValue,
path: `${stsEndpoint.pathname}?${qryParams.toString()}`,
protocol: stsEndpoint.protocol,
method: 'POST',
headers: {},
agent: this.transportAgent,
} satisfies http.RequestOptions

const transport = stsEndpoint.protocol === 'http:' ? http : https
const res = await request(transport, requestOptions, null)
const body = await readAsString(res)

const assumeRoleResponse: AssumeRoleResponse = parseXml(body)
const creds = assumeRoleResponse.AssumeRoleWithWebIdentityResponse.AssumeRoleWithWebIdentityResult.Credentials
this.accessExpiresAt = creds.Expiration
return new Credentials({
accessKey: creds.AccessKeyId,
secretKey: creds.SecretAccessKey,
sessionToken: creds.SessionToken,
})
}

private async fetchImdsToken() {
const endpoint = this.customEndpoint ? this.customEndpoint : 'http://169.254.169.254'
const url = new URL('/latest/api/token', endpoint)

const requestOptions = {
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
protocol: url.protocol,
method: 'PUT',
headers: {
'X-aws-ec2-metadata-token-ttl-seconds': '21600',
},
agent: this.transportAgent,
} satisfies http.RequestOptions

const transport = url.protocol === 'http:' ? http : https
const res = await request(transport, requestOptions, null)
return await readAsString(res)
}

private async getIamRoleNamedUrl(token: string) {
const endpoint = this.customEndpoint ? this.customEndpoint : 'http://169.254.169.254'
const url = new URL('latest/meta-data/iam/security-credentials/', endpoint)

const roleName = await this.getIamRoleName(url, token)
return new URL(`${url.pathname}/${encodeURIComponent(roleName)}`, url.origin)
}

private async getIamRoleName(url: URL, token: string): Promise<string> {
const requestOptions = {
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
protocol: url.protocol,
method: 'GET',
headers: {
'X-aws-ec2-metadata-token': token,
},
agent: this.transportAgent,
} satisfies http.RequestOptions

const transport = url.protocol === 'http:' ? http : https
const res = await request(transport, requestOptions, null)
const body = await readAsString(res)
const roleNames = body.split(/\r\n|[\n\r\u2028\u2029]/)
if (roleNames.length === 0) {
throw new Error(`No IAM roles attached to EC2 service ${url}`)
}
return roleNames[0] as string
}

private async requestCredentials(url: URL, tokenHeader: string, token: string | undefined): Promise<Credentials> {
const headers: Record<string, string> = {}
if (token) {
headers[tokenHeader] = token
}
const requestOptions = {
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
protocol: url.protocol,
method: 'GET',
headers: headers,
agent: this.transportAgent,
} satisfies http.RequestOptions

const transport = url.protocol === 'http:' ? http : https
const res = await request(transport, requestOptions, null)
const body = await readAsString(res)
const ecsCredentials = JSON.parse(body) as EcsCredentials
if (!ecsCredentials.Code || ecsCredentials.Code != 'Success') {
throw new Error(`${url} failed with code ${ecsCredentials.Code} and message ${ecsCredentials.Message}`)
}

this.accessExpiresAt = ecsCredentials.Expiration
return new Credentials({
accessKey: ecsCredentials.AccessKeyID,
secretKey: ecsCredentials.SecretAccessKey,
sessionToken: ecsCredentials.Token,
})
}

private isAboutToExpire() {
const expiresAt = new Date(this.accessExpiresAt)
const provisionalExpiry = new Date(Date.now() + 1000 * 10) // 10 seconds leeway
return provisionalExpiry > expiresAt
}
}

// deprecated default export, please use named exports.
// keep for backward compatibility.
// eslint-disable-next-line import/no-default-export
export default IamAwsProvider
5 changes: 3 additions & 2 deletions src/internal/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ const requestOptionProperties = [

export interface ClientOptions {
endPoint: string
accessKey: string
secretKey: string
accessKey?: string
secretKey?: string
useSSL?: boolean
port?: number
region?: Region
Expand Down Expand Up @@ -322,6 +322,7 @@ export class TypedClient {
this.anonymous = !this.accessKey || !this.secretKey

if (params.credentialsProvider) {
this.anonymous = false
this.credentialsProvider = params.credentialsProvider
}

Expand Down

0 comments on commit 6cd0223

Please sign in to comment.