Skip to content
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

feat: add iam aws provider #1359

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
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 @@ -151,8 +151,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 @@ -318,6 +318,7 @@ export class TypedClient {
this.anonymous = !this.accessKey || !this.secretKey

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

Expand Down
Loading