From 6298bde6a0dadb886c0b7c9e5fd5db33c019bd8e Mon Sep 17 00:00:00 2001 From: Trim21 Date: Wed, 17 May 2023 13:30:58 +0800 Subject: [PATCH] refactor: rewrite `AssumeRoleProvider` in TypeScript (#1140) --- src/AssumeRoleProvider.js | 229 ----------------------- src/AssumeRoleProvider.ts | 262 +++++++++++++++++++++++++++ src/CredentialProvider.ts | 4 +- src/Credentials.ts | 4 +- src/internal/request.ts | 35 ++++ src/internal/response.ts | 26 +++ src/signing.ts | 2 +- tests/functional/functional-tests.js | 2 +- 8 files changed, 329 insertions(+), 235 deletions(-) delete mode 100644 src/AssumeRoleProvider.js create mode 100644 src/AssumeRoleProvider.ts create mode 100644 src/internal/request.ts create mode 100644 src/internal/response.ts diff --git a/src/AssumeRoleProvider.js b/src/AssumeRoleProvider.js deleted file mode 100644 index f6f778ea..00000000 --- a/src/AssumeRoleProvider.js +++ /dev/null @@ -1,229 +0,0 @@ -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 { makeDateLong, parseXml, toSha256 } from './internal/helper.ts' -import { signV4ByServiceName } from './signing.ts' - -export class AssumeRoleProvider extends CredentialProvider { - constructor({ - stsEndpoint, - accessKey, - secretKey, - durationSeconds = 900, - sessionToken, - policy, - region = '', - roleArn, - roleSessionName, - externalId, - token, - webIdentityToken, - action = 'AssumeRole', - transportAgent = undefined, - }) { - super({}) - - this.stsEndpoint = stsEndpoint - this.accessKey = accessKey - this.secretKey = secretKey - this.durationSeconds = durationSeconds - this.policy = policy - this.region = region - this.roleArn = roleArn - this.roleSessionName = roleSessionName - this.externalId = externalId - this.token = token - this.webIdentityToken = webIdentityToken - this.action = action - this.sessionToken = sessionToken - // By default, nodejs uses a global agent if the 'agent' property - // is set to undefined. Otherwise, it's okay to assume the users - // know what they're doing if they specify a custom transport agent. - this.transportAgent = transportAgent - - /** - * Internal Tracking variables - */ - this.credentials = null - this.expirySeconds = null - this.accessExpiresAt = null - } - - getRequestConfig() { - const url = new URL(this.stsEndpoint) - const hostValue = url.hostname - const portValue = url.port - const isHttp = url.protocol.includes('http:') - const qryParams = new URLSearchParams() - qryParams.set('Action', this.action) - qryParams.set('Version', '2011-06-15') - - const defaultExpiry = 900 - let expirySeconds = parseInt(this.durationSeconds) - if (expirySeconds < defaultExpiry) { - expirySeconds = defaultExpiry - } - this.expirySeconds = expirySeconds // for calculating refresh of credentials. - - qryParams.set('DurationSeconds', this.expirySeconds) - - if (this.policy) { - qryParams.set('Policy', this.policy) - } - if (this.roleArn) { - qryParams.set('RoleArn', this.roleArn) - } - - if (this.roleSessionName != null) { - qryParams.set('RoleSessionName', this.roleSessionName) - } - if (this.token != null) { - qryParams.set('Token', this.token) - } - - if (this.webIdentityToken) { - qryParams.set('WebIdentityToken', this.webIdentityToken) - } - - if (this.externalId) { - qryParams.set('ExternalId', this.externalId) - } - - const urlParams = qryParams.toString() - const contentSha256 = toSha256(urlParams) - - const date = new Date() - - /** - * Nodejs's Request Configuration. - */ - const requestOptions = { - hostname: hostValue, - port: portValue, - path: '/', - protocol: url.protocol, - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'content-length': urlParams.length, - host: hostValue, - 'x-amz-date': makeDateLong(date), - 'x-amz-content-sha256': contentSha256, - }, - agent: this.transportAgent, - } - - const authorization = signV4ByServiceName( - requestOptions, - this.accessKey, - this.secretKey, - this.region, - date, - contentSha256, - 'sts', - ) - requestOptions.headers.authorization = authorization - - return { - requestOptions, - requestData: urlParams, - isHttp: isHttp, - } - } - - async performRequest() { - const reqObj = this.getRequestConfig() - const requestOptions = reqObj.requestOptions - const requestData = reqObj.requestData - - const isHttp = reqObj.isHttp - const Transport = isHttp ? Http : Https - - const promise = new Promise((resolve, reject) => { - const requestObj = Transport.request(requestOptions, (resp) => { - let resChunks = [] - resp.on('data', (rChunk) => { - resChunks.push(rChunk) - }) - resp.on('end', () => { - let body = Buffer.concat(resChunks).toString() - const xmlobj = parseXml(body) - resolve(xmlobj) - }) - resp.on('error', (err) => { - reject(err) - }) - }) - requestObj.on('error', (e) => { - reject(e) - }) - requestObj.write(requestData) - requestObj.end() - }) - return promise - } - - parseCredentials(respObj = {}) { - if (respObj.ErrorResponse) { - throw new Error('Unable to obtain credentials:', respObj) - } - const { - AssumeRoleResponse: { - AssumeRoleResult: { - Credentials: { - AccessKeyId: accessKey, - SecretAccessKey: secretKey, - SessionToken: sessionToken, - Expiration: expiresAt, - } = {}, - } = {}, - } = {}, - } = respObj - - this.accessExpiresAt = expiresAt - - const newCreds = new Credentials({ - accessKey, - secretKey, - sessionToken, - }) - - this.setCredentials(newCreds) - return this.credentials - } - - async refreshCredentials() { - try { - const assumeRoleCredentials = await this.performRequest() - this.credentials = this.parseCredentials(assumeRoleCredentials) - } catch (err) { - this.credentials = null - } - return this.credentials - } - - async getCredentials() { - let credConfig - if (!this.credentials || (this.credentials && this.isAboutToExpire())) { - credConfig = await this.refreshCredentials() - } else { - credConfig = this.credentials - } - return credConfig - } - - isAboutToExpire() { - const expiresAt = new Date(this.accessExpiresAt) - const provisionalExpiry = new Date(Date.now() + 1000 * 10) // check before 10 seconds. - const isAboutToExpire = provisionalExpiry > expiresAt - return isAboutToExpire - } -} - -// deprecated default export, please use named exports. -// keep for backward compatibility. -// eslint-disable-next-line import/no-default-export -export default AssumeRoleProvider diff --git a/src/AssumeRoleProvider.ts b/src/AssumeRoleProvider.ts new file mode 100644 index 00000000..71a59525 --- /dev/null +++ b/src/AssumeRoleProvider.ts @@ -0,0 +1,262 @@ +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 { makeDateLong, parseXml, toSha256 } from './internal/helper.ts' +import { request } from './internal/request.ts' +import { readAsString } from './internal/response.ts' +import type { Transport } from './internal/type.ts' +import { signV4ByServiceName } from './signing.ts' + +/** + * @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html + */ +type CredentialResponse = { + ErrorResponse?: { + Error?: { + Code?: string + Message?: string + } + } + + AssumeRoleResponse: { + AssumeRoleResult: { + Credentials: { + AccessKeyId: string + SecretAccessKey: string + SessionToken: string + Expiration: string + } + } + } +} + +export interface AssumeRoleProviderOptions { + stsEndpoint: string + accessKey: string + secretKey: string + durationSeconds?: number + sessionToken?: string + policy?: string + region?: string + roleArn?: string + roleSessionName?: string + externalId?: string + token?: string + webIdentityToken?: string + action?: string + transportAgent?: http.Agent +} + +const defaultExpirySeconds = 900 + +export class AssumeRoleProvider extends CredentialProvider { + private readonly stsEndpoint: URL + private readonly accessKey: string + private readonly secretKey: string + private readonly durationSeconds: number + private readonly policy?: string + private readonly region: string + private readonly roleArn?: string + private readonly roleSessionName?: string + private readonly externalId?: string + private readonly token?: string + private readonly webIdentityToken?: string + private readonly action: string + + private _credentials: Credentials | null + private readonly expirySeconds: number + private accessExpiresAt = '' + private readonly transportAgent?: http.Agent + + private readonly transport: Transport + + constructor({ + stsEndpoint, + accessKey, + secretKey, + durationSeconds = defaultExpirySeconds, + sessionToken, + policy, + region = '', + roleArn, + roleSessionName, + externalId, + token, + webIdentityToken, + action = 'AssumeRole', + transportAgent = undefined, + }: AssumeRoleProviderOptions) { + super({ accessKey, secretKey, sessionToken }) + + this.stsEndpoint = new URL(stsEndpoint) + this.accessKey = accessKey + this.secretKey = secretKey + this.policy = policy + this.region = region + this.roleArn = roleArn + this.roleSessionName = roleSessionName + this.externalId = externalId + this.token = token + this.webIdentityToken = webIdentityToken + this.action = action + + this.durationSeconds = parseInt(durationSeconds as unknown as string) + + let expirySeconds = this.durationSeconds + if (this.durationSeconds < defaultExpirySeconds) { + expirySeconds = defaultExpirySeconds + } + this.expirySeconds = expirySeconds // for calculating refresh of credentials. + + // By default, nodejs uses a global agent if the 'agent' property + // is set to undefined. Otherwise, it's okay to assume the users + // know what they're doing if they specify a custom transport agent. + this.transportAgent = transportAgent + const isHttp: boolean = this.stsEndpoint.protocol === 'http:' + this.transport = isHttp ? http : https + + /** + * Internal Tracking variables + */ + this._credentials = null + } + + getRequestConfig(): { + requestOptions: http.RequestOptions + requestData: string + } { + const hostValue = this.stsEndpoint.hostname + const portValue = this.stsEndpoint.port + const qryParams = new URLSearchParams({ Action: this.action, Version: '2011-06-15' }) + + qryParams.set('DurationSeconds', this.expirySeconds.toString()) + + if (this.policy) { + qryParams.set('Policy', this.policy) + } + if (this.roleArn) { + qryParams.set('RoleArn', this.roleArn) + } + + if (this.roleSessionName != null) { + qryParams.set('RoleSessionName', this.roleSessionName) + } + if (this.token != null) { + qryParams.set('Token', this.token) + } + + if (this.webIdentityToken) { + qryParams.set('WebIdentityToken', this.webIdentityToken) + } + + if (this.externalId) { + qryParams.set('ExternalId', this.externalId) + } + + const urlParams = qryParams.toString() + const contentSha256 = toSha256(urlParams) + + const date = new Date() + + const requestOptions = { + hostname: hostValue, + port: portValue, + path: '/', + protocol: this.stsEndpoint.protocol, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'content-length': urlParams.length.toString(), + host: hostValue, + 'x-amz-date': makeDateLong(date), + 'x-amz-content-sha256': contentSha256, + } as Record, + agent: this.transportAgent, + } satisfies http.RequestOptions + + requestOptions.headers.authorization = signV4ByServiceName( + requestOptions, + this.accessKey, + this.secretKey, + this.region, + date, + contentSha256, + 'sts', + ) + + return { + requestOptions, + requestData: urlParams, + } + } + + async performRequest(): Promise { + const { requestOptions, requestData } = this.getRequestConfig() + + const res = await request(this.transport, requestOptions, requestData) + + const body = await readAsString(res) + + return parseXml(body) + } + + parseCredentials(respObj: CredentialResponse): Credentials { + if (respObj.ErrorResponse) { + throw new Error( + `Unable to obtain credentials: ${respObj.ErrorResponse?.Error?.Code} ${respObj.ErrorResponse?.Error?.Message}`, + { cause: respObj }, + ) + } + + const { + AssumeRoleResponse: { + AssumeRoleResult: { + Credentials: { + AccessKeyId: accessKey, + SecretAccessKey: secretKey, + SessionToken: sessionToken, + Expiration: expiresAt, + }, + }, + }, + } = respObj + + this.accessExpiresAt = expiresAt + + return new Credentials({ accessKey, secretKey, sessionToken }) + } + + async refreshCredentials(): Promise { + try { + const assumeRoleCredentials = await this.performRequest() + this._credentials = this.parseCredentials(assumeRoleCredentials) + } catch (err) { + throw new Error(`Failed to get Credentials: ${err}`, { cause: err }) + } + + return this._credentials + } + + async getCredentials(): Promise { + if (this._credentials && !this.isAboutToExpire()) { + return this._credentials + } + + this._credentials = await this.refreshCredentials() + return this._credentials + } + + isAboutToExpire() { + const expiresAt = new Date(this.accessExpiresAt) + const provisionalExpiry = new Date(Date.now() + 1000 * 10) // check before 10 seconds. + return provisionalExpiry > expiresAt + } +} + +// deprecated default export, please use named exports. +// keep for backward compatibility. +// eslint-disable-next-line import/no-default-export +export default AssumeRoleProvider diff --git a/src/CredentialProvider.ts b/src/CredentialProvider.ts index d45e4820..ba224b33 100644 --- a/src/CredentialProvider.ts +++ b/src/CredentialProvider.ts @@ -3,7 +3,7 @@ import { Credentials } from './Credentials.ts' export class CredentialProvider { private credentials: Credentials - constructor({ accessKey, secretKey, sessionToken }: { accessKey: string; secretKey: string; sessionToken: string }) { + constructor({ accessKey, secretKey, sessionToken }: { accessKey: string; secretKey: string; sessionToken?: string }) { this.credentials = new Credentials({ accessKey, secretKey, @@ -11,7 +11,7 @@ export class CredentialProvider { }) } - async getCredentials(): Promise { + async getCredentials(): Promise { return this.credentials.get() } diff --git a/src/Credentials.ts b/src/Credentials.ts index 7aef5847..b92fe275 100644 --- a/src/Credentials.ts +++ b/src/Credentials.ts @@ -1,9 +1,9 @@ export class Credentials { public accessKey: string public secretKey: string - public sessionToken: string + public sessionToken?: string - constructor({ accessKey, secretKey, sessionToken }: { accessKey: string; secretKey: string; sessionToken: string }) { + constructor({ accessKey, secretKey, sessionToken }: { accessKey: string; secretKey: string; sessionToken?: string }) { this.accessKey = accessKey this.secretKey = secretKey this.sessionToken = sessionToken diff --git a/src/internal/request.ts b/src/internal/request.ts new file mode 100644 index 00000000..ceb071c0 --- /dev/null +++ b/src/internal/request.ts @@ -0,0 +1,35 @@ +import type * as http from 'node:http' +import type * as https from 'node:https' +import type * as stream from 'node:stream' +import { pipeline } from 'node:stream' + +import type { Transport } from './type.ts' + +export async function request( + transport: Transport, + opt: https.RequestOptions, + body: Buffer | string | stream.Readable | null = null, +): Promise { + return new Promise((resolve, reject) => { + const requestObj = transport.request(opt, (resp) => { + resolve(resp) + }) + + if (!body || Buffer.isBuffer(body) || typeof body === 'string') { + requestObj + .on('error', (e: unknown) => { + reject(e) + }) + .end(body) + + return + } + + // pump readable stream + pipeline(body, requestObj, (err) => { + if (err) { + reject(err) + } + }) + }) +} diff --git a/src/internal/response.ts b/src/internal/response.ts new file mode 100644 index 00000000..bb3a0b15 --- /dev/null +++ b/src/internal/response.ts @@ -0,0 +1,26 @@ +import type http from 'node:http' +import type stream from 'node:stream' + +export async function readAsBuffer(res: stream.Readable): Promise { + return new Promise((resolve, reject) => { + const body: Buffer[] = [] + res + .on('data', (chunk: Buffer) => body.push(chunk)) + .on('error', (e) => reject(e)) + .on('end', () => resolve(Buffer.concat(body))) + }) +} + +export async function readAsString(res: http.IncomingMessage): Promise { + const body = await readAsBuffer(res) + return body.toString() +} + +export async function drainResponse(res: stream.Readable): Promise { + return new Promise((resolve, reject) => { + res + .on('data', () => {}) + .on('error', (e) => reject(e)) + .on('end', () => resolve()) + }) +} diff --git a/src/signing.ts b/src/signing.ts index bacd0158..a4bb7015 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -255,7 +255,7 @@ export function presignSignatureV4( request: IRequest, accessKey: string, secretKey: string, - sessionToken: string, + sessionToken: string | undefined, region: string, requestDate: Date, expires: number, diff --git a/tests/functional/functional-tests.js b/tests/functional/functional-tests.js index 21221a04..daa0f564 100644 --- a/tests/functional/functional-tests.js +++ b/tests/functional/functional-tests.js @@ -30,7 +30,7 @@ import splitFile from 'split-file' import superagent from 'superagent' import * as uuid from 'uuid' -import { AssumeRoleProvider } from '../../src/AssumeRoleProvider.js' +import { AssumeRoleProvider } from '../../src/AssumeRoleProvider.ts' import { CopyDestinationOptions, CopySourceOptions, DEFAULT_REGION, removeDirAndFiles } from '../../src/helpers.ts' import { getVersionId } from '../../src/internal/helper.ts' import * as minio from '../../src/minio.js'