-
Notifications
You must be signed in to change notification settings - Fork 284
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f800683
commit 6cd0223
Showing
2 changed files
with
237 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters