src/auth/awsrequestsigner.ts
Interface defining AWS security credentials. These are either determined from AWS security_credentials endpoint or AWS environment variables.
Properties |
|
accessKeyId |
accessKeyId:
|
Type : string
|
secretAccessKey |
secretAccessKey:
|
Type : string
|
token |
token:
|
Type : string
|
Optional |
import {GaxiosOptions} from 'gaxios';
import {Headers} from './oauth2client';
import {Crypto, createCrypto, fromArrayBufferToHex} from '../crypto/crypto';
type HttpMethod =
| 'GET'
| 'POST'
| 'PUT'
| 'PATCH'
| 'HEAD'
| 'DELETE'
| 'CONNECT'
| 'OPTIONS'
| 'TRACE';
/** Interface defining the AWS authorization header map for signed requests. */
interface AwsAuthHeaderMap {
amzDate?: string;
authorizationHeader: string;
canonicalQuerystring: string;
}
/**
* Interface defining AWS security credentials.
* These are either determined from AWS security_credentials endpoint or
* AWS environment variables.
*/
export interface AwsSecurityCredentials {
accessKeyId: string;
secretAccessKey: string;
token?: string;
}
/**
* Interface defining the parameters needed to compute the AWS
* authentication header map.
*/
interface GenerateAuthHeaderMapOptions {
// The crypto instance used to facilitate cryptographic operations.
crypto: Crypto;
// The AWS service URL hostname.
host: string;
// The AWS service URL path name.
canonicalUri: string;
// The AWS service URL query string.
canonicalQuerystring: string;
// The HTTP method used to call this API.
method: HttpMethod;
// The AWS region.
region: string;
// The AWS security credentials.
securityCredentials: AwsSecurityCredentials;
// The optional request payload if available.
requestPayload?: string;
// The optional additional headers needed for the requested AWS API.
additionalAmzHeaders?: Headers;
}
/** AWS Signature Version 4 signing algorithm identifier. */
const AWS_ALGORITHM = 'AWS4-HMAC-SHA256';
/**
* The termination string for the AWS credential scope value as defined in
* https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
*/
const AWS_REQUEST_TYPE = 'aws4_request';
/**
* Implements an AWS API request signer based on the AWS Signature Version 4
* signing process.
* https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
*/
export class AwsRequestSigner {
private readonly crypto: Crypto;
/**
* Instantiates an AWS API request signer used to send authenticated signed
* requests to AWS APIs based on the AWS Signature Version 4 signing process.
* This also provides a mechanism to generate the signed request without
* sending it.
* @param getCredentials A mechanism to retrieve AWS security credentials
* when needed.
* @param region The AWS region to use.
*/
constructor(
private readonly getCredentials: () => Promise<AwsSecurityCredentials>,
private readonly region: string
) {
this.crypto = createCrypto();
}
/**
* Generates the signed request for the provided HTTP request for calling
* an AWS API. This follows the steps described at:
* https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
* @param amzOptions The AWS request options that need to be signed.
* @return A promise that resolves with the GaxiosOptions containing the
* signed HTTP request parameters.
*/
async getRequestOptions(amzOptions: GaxiosOptions): Promise<GaxiosOptions> {
if (!amzOptions.url) {
throw new Error('"url" is required in "amzOptions"');
}
// Stringify JSON requests. This will be set in the request body of the
// generated signed request.
const requestPayloadData =
typeof amzOptions.data === 'object'
? JSON.stringify(amzOptions.data)
: amzOptions.data;
const url = amzOptions.url;
const method = amzOptions.method || 'GET';
const requestPayload = amzOptions.body || requestPayloadData;
const additionalAmzHeaders = amzOptions.headers;
const awsSecurityCredentials = await this.getCredentials();
const uri = new URL(url);
const headerMap = await generateAuthenticationHeaderMap({
crypto: this.crypto,
host: uri.host,
canonicalUri: uri.pathname,
canonicalQuerystring: uri.search.substr(1),
method,
region: this.region,
securityCredentials: awsSecurityCredentials,
requestPayload,
additionalAmzHeaders,
});
// Append additional optional headers, eg. X-Amz-Target, Content-Type, etc.
const headers: {[key: string]: string} = Object.assign(
// Add x-amz-date if available.
headerMap.amzDate ? {'x-amz-date': headerMap.amzDate} : {},
{
Authorization: headerMap.authorizationHeader,
host: uri.host,
},
additionalAmzHeaders || {}
);
if (awsSecurityCredentials.token) {
Object.assign(headers, {
'x-amz-security-token': awsSecurityCredentials.token,
});
}
const awsSignedReq: GaxiosOptions = {
url,
method: method,
headers,
};
if (typeof requestPayload !== 'undefined') {
awsSignedReq.body = requestPayload;
}
return awsSignedReq;
}
}
/**
* Creates the HMAC-SHA256 hash of the provided message using the
* provided key.
*
* @param crypto The crypto instance used to facilitate cryptographic
* operations.
* @param key The HMAC-SHA256 key to use.
* @param msg The message to hash.
* @return The computed hash bytes.
*/
async function sign(
crypto: Crypto,
key: string | ArrayBuffer,
msg: string
): Promise<ArrayBuffer> {
return await crypto.signWithHmacSha256(key, msg);
}
/**
* Calculates the signing key used to calculate the signature for
* AWS Signature Version 4 based on:
* https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
*
* @param crypto The crypto instance used to facilitate cryptographic
* operations.
* @param key The AWS secret access key.
* @param dateStamp The '%Y%m%d' date format.
* @param region The AWS region.
* @param serviceName The AWS service name, eg. sts.
* @return The signing key bytes.
*/
async function getSigningKey(
crypto: Crypto,
key: string,
dateStamp: string,
region: string,
serviceName: string
): Promise<ArrayBuffer> {
const kDate = await sign(crypto, `AWS4${key}`, dateStamp);
const kRegion = await sign(crypto, kDate, region);
const kService = await sign(crypto, kRegion, serviceName);
const kSigning = await sign(crypto, kService, 'aws4_request');
return kSigning;
}
/**
* Generates the authentication header map needed for generating the AWS
* Signature Version 4 signed request.
*
* @param option The options needed to compute the authentication header map.
* @return The AWS authentication header map which constitutes of the following
* components: amz-date, authorization header and canonical query string.
*/
async function generateAuthenticationHeaderMap(
options: GenerateAuthHeaderMapOptions
): Promise<AwsAuthHeaderMap> {
const additionalAmzHeaders = options.additionalAmzHeaders || {};
const requestPayload = options.requestPayload || '';
// iam.amazonaws.com host => iam service.
// sts.us-east-2.amazonaws.com => sts service.
const serviceName = options.host.split('.')[0];
const now = new Date();
// Format: '%Y%m%dT%H%M%SZ'.
const amzDate = now
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.[0-9]+/, '');
// Format: '%Y%m%d'.
const dateStamp = now.toISOString().replace(/[-]/g, '').replace(/T.*/, '');
// Change all additional headers to be lower case.
const reformattedAdditionalAmzHeaders: Headers = {};
Object.keys(additionalAmzHeaders).forEach(key => {
reformattedAdditionalAmzHeaders[key.toLowerCase()] =
additionalAmzHeaders[key];
});
// Add AWS token if available.
if (options.securityCredentials.token) {
reformattedAdditionalAmzHeaders['x-amz-security-token'] =
options.securityCredentials.token;
}
// Header keys need to be sorted alphabetically.
const amzHeaders = Object.assign(
{
host: options.host,
},
// Previously the date was not fixed with x-amz- and could be provided manually.
// https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req
reformattedAdditionalAmzHeaders.date ? {} : {'x-amz-date': amzDate},
reformattedAdditionalAmzHeaders
);
let canonicalHeaders = '';
const signedHeadersList = Object.keys(amzHeaders).sort();
signedHeadersList.forEach(key => {
canonicalHeaders += `${key}:${amzHeaders[key]}\n`;
});
const signedHeaders = signedHeadersList.join(';');
const payloadHash = await options.crypto.sha256DigestHex(requestPayload);
// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
const canonicalRequest =
`${options.method}\n` +
`${options.canonicalUri}\n` +
`${options.canonicalQuerystring}\n` +
`${canonicalHeaders}\n` +
`${signedHeaders}\n` +
`${payloadHash}`;
const credentialScope = `${dateStamp}/${options.region}/${serviceName}/${AWS_REQUEST_TYPE}`;
// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
const stringToSign =
`${AWS_ALGORITHM}\n` +
`${amzDate}\n` +
`${credentialScope}\n` +
(await options.crypto.sha256DigestHex(canonicalRequest));
// https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
const signingKey = await getSigningKey(
options.crypto,
options.securityCredentials.secretAccessKey,
dateStamp,
options.region,
serviceName
);
const signature = await sign(options.crypto, signingKey, stringToSign);
// https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
const authorizationHeader =
`${AWS_ALGORITHM} Credential=${options.securityCredentials.accessKeyId}/` +
`${credentialScope}, SignedHeaders=${signedHeaders}, ` +
`Signature=${fromArrayBufferToHex(signature)}`;
return {
// Do not return x-amz-date if date is available.
amzDate: reformattedAdditionalAmzHeaders.date ? undefined : amzDate,
authorizationHeader,
canonicalQuerystring: options.canonicalQuerystring,
};
}