diff --git a/src/IamAwsProvider.ts b/src/IamAwsProvider.ts new file mode 100644 index 00000000..6324da00 --- /dev/null +++ b/src/IamAwsProvider.ts @@ -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 { + if (!this._credentials || this.isAboutToExpire()) { + this._credentials = await this.fetchCredentials() + } + return this._credentials + } + + private async fetchCredentials(): Promise { + 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 { + 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 { + 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 { + const headers: Record = {} + 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 diff --git a/src/internal/client.ts b/src/internal/client.ts index 26a0eceb..b8df18b5 100644 --- a/src/internal/client.ts +++ b/src/internal/client.ts @@ -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 @@ -322,6 +322,7 @@ export class TypedClient { this.anonymous = !this.accessKey || !this.secretKey if (params.credentialsProvider) { + this.anonymous = false this.credentialsProvider = params.credentialsProvider }