Skip to content

Commit

Permalink
arp
Browse files Browse the repository at this point in the history
  • Loading branch information
trim21 committed May 12, 2023
1 parent 0229893 commit 568311e
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 74 deletions.
176 changes: 107 additions & 69 deletions src/AssumeRoleProvider.js → src/AssumeRoleProvider.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,73 @@
import * as Http from 'node:http'
import * as Https from 'node:https'
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 { 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 | https.Agent
}

export class AssumeRoleProvider extends CredentialProvider {
private readonly stsEndpoint: string
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 expirySeconds: number | null
private accessExpiresAt = ''
private readonly transportAgent?: http.Agent

constructor({
stsEndpoint,
accessKey,
Expand All @@ -23,8 +83,8 @@ export class AssumeRoleProvider extends CredentialProvider {
webIdentityToken,
action = 'AssumeRole',
transportAgent = undefined,
}) {
super({})
}: AssumeRoleProviderOptions) {
super({ accessKey, secretKey, sessionToken })

this.stsEndpoint = stsEndpoint
this.accessKey = accessKey
Expand All @@ -38,7 +98,7 @@ export class AssumeRoleProvider extends CredentialProvider {
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.
Expand All @@ -47,28 +107,29 @@ export class AssumeRoleProvider extends CredentialProvider {
/**
* Internal Tracking variables
*/
this.credentials = null
this._credentials = null
this.expirySeconds = null
this.accessExpiresAt = null
}

getRequestConfig() {
getRequestConfig(): {
isHttp: boolean
requestOptions: http.RequestOptions
requestData: string
} {
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 isHttp = url.protocol === 'http:'
const qryParams = new URLSearchParams({ Action: this.action, Version: '2011-06-15' })

const defaultExpiry = 900
let expirySeconds = parseInt(this.durationSeconds)
let expirySeconds = parseInt(this.durationSeconds as unknown as string)
if (expirySeconds < defaultExpiry) {
expirySeconds = defaultExpiry
}
this.expirySeconds = expirySeconds // for calculating refresh of credentials.

qryParams.set('DurationSeconds', this.expirySeconds)
qryParams.set('DurationSeconds', this.expirySeconds.toString())

if (this.policy) {
qryParams.set('Policy', this.policy)
Expand Down Expand Up @@ -97,9 +158,6 @@ export class AssumeRoleProvider extends CredentialProvider {

const date = new Date()

/**
* Nodejs's Request Configuration.
*/
const requestOptions = {
hostname: hostValue,
port: portValue,
Expand All @@ -108,15 +166,15 @@ export class AssumeRoleProvider extends CredentialProvider {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'content-length': urlParams.length,
'content-length': urlParams.length.toString(),
host: hostValue,
'x-amz-date': makeDateLong(date),
'x-amz-content-sha256': contentSha256,
},
} as Record<string, string>,
agent: this.transportAgent,
}
} satisfies http.RequestOptions

const authorization = signV4ByServiceName(
requestOptions.headers.authorization = signV4ByServiceName(
requestOptions,
this.accessKey,
this.secretKey,
Expand All @@ -125,7 +183,6 @@ export class AssumeRoleProvider extends CredentialProvider {
contentSha256,
'sts',
)
requestOptions.headers.authorization = authorization

return {
requestOptions,
Expand All @@ -134,42 +191,28 @@ export class AssumeRoleProvider extends CredentialProvider {
}
}

async performRequest() {
async performRequest(): Promise<CredentialResponse> {
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

const res = await request(isHttp ? http : https, requestOptions, requestData)

const body = await readAsString(res)

return parseXml(body)
}

parseCredentials(respObj = {}) {
parseCredentials(respObj: CredentialResponse) {
if (respObj.ErrorResponse) {
throw new Error('Unable to obtain credentials:', respObj)
throw new Error(
`Unable to obtain credentials: ${respObj.ErrorResponse?.Error?.Code} ${respObj.ErrorResponse?.Error?.Message}`,
{ cause: respObj },
)
}

const {
AssumeRoleResponse: {
AssumeRoleResult: {
Expand All @@ -178,48 +221,43 @@ export class AssumeRoleProvider extends CredentialProvider {
SecretAccessKey: secretKey,
SessionToken: sessionToken,
Expiration: expiresAt,
} = {},
} = {},
} = {},
},
},
},
} = respObj

this.accessExpiresAt = expiresAt

const newCreds = new Credentials({
accessKey,
secretKey,
sessionToken,
})
const credentials = new Credentials({ accessKey, secretKey, sessionToken })

this.setCredentials(newCreds)
return this.credentials
this.setCredentials(credentials)
return this._credentials
}

async refreshCredentials() {
async refreshCredentials(): Promise<Credentials | null> {
try {
const assumeRoleCredentials = await this.performRequest()
this.credentials = this.parseCredentials(assumeRoleCredentials)
this._credentials = this.parseCredentials(assumeRoleCredentials)
} catch (err) {
this.credentials = null
this._credentials = null
}
return this.credentials
return this._credentials
}

async getCredentials() {
let credConfig
if (!this.credentials || (this.credentials && this.isAboutToExpire())) {
async getCredentials(): Promise<Credentials | null> {
let credConfig: Credentials | null
if (!this._credentials || (this._credentials && this.isAboutToExpire())) {
credConfig = await this.refreshCredentials()
} else {
credConfig = this.credentials
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
return provisionalExpiry > expiresAt
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/CredentialProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/Credentials.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/internal/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type * as http from 'node:http'
import type * as https from 'node:https'
import type * as stream from 'node:stream'

export async function request(
transport: typeof http | typeof https,
opt: https.RequestOptions,
body: Buffer | string | stream.Readable | null = null,
): Promise<http.IncomingMessage> {
return new Promise<http.IncomingMessage>((resolve, reject) => {
const requestObj = transport.request(opt, (resp) => {
resolve(resp)
})

requestObj.on('error', (e: unknown) => {
reject(e)
})

if (body) {
if (!Buffer.isBuffer(body) && typeof body !== 'string') {
body.on('error', reject)
}

requestObj.end(body)
} else {
requestObj.end(null)
}
})
}
26 changes: 26 additions & 0 deletions src/internal/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type http from 'node:http'
import type stream from 'node:stream'

export async function readAsBuffer(res: stream.Readable): Promise<Buffer> {
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<string> {
const body = await readAsBuffer(res)
return body.toString()
}

export async function drainResponse(res: stream.Readable): Promise<void> {
return new Promise((resolve, reject) => {
res
.on('data', () => {})
.on('error', (e) => reject(e))
.on('end', () => resolve())
})
}
2 changes: 1 addition & 1 deletion src/signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ export function presignSignatureV4(
request: IRequest,
accessKey: string,
secretKey: string,
sessionToken: string,
sessionToken: string | undefined,
region: string,
requestDate: Date,
expires: number,
Expand Down
2 changes: 1 addition & 1 deletion tests/functional/functional-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit 568311e

Please sign in to comment.