@acusti/aws-signature-v4
Version:
A lightweight isomorphic module to generate request headers that fulfill the AWS SigV4 signing process
207 lines • 8.49 kB
JavaScript
import webcrypto from '@acusti/webcrypto';
const universalBtoa = (text) => {
try {
return btoa(text);
}
catch (err) {
return Buffer.from(text).toString('base64');
}
};
const subtleCrypto = webcrypto.subtle;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const DEFAULT_ALGORITHM = 'SHA-256';
const DEFAULT_SERVICE = 'appsync';
// @ts-expect-error expected type error from this simple browser/node-agnostic check
const REGION = typeof process === 'undefined' ? '' : process.env.REGION;
const decodeArrayBuffer = (buffer, encoding) => {
const uint8Array = new Uint8Array(buffer);
switch (encoding) {
case 'base64':
return universalBtoa(String.fromCharCode(...uint8Array));
case 'hex':
// https://stackoverflow.com/a/70790307/333625
return uint8Array.reduce((a, b) => a + b.toString(16).padStart(2, '0'), '');
default:
return decoder.decode(uint8Array);
}
};
const encrypt = async (payload) => {
var _a;
const keyArray = typeof payload.key === 'string' ? encoder.encode(payload.key) : payload.key;
const algorithm = { hash: (_a = payload.algorithm) !== null && _a !== void 0 ? _a : DEFAULT_ALGORITHM, name: 'HMAC' };
const key = await subtleCrypto.importKey('raw', keyArray, algorithm, false, ['sign']);
const signature = await subtleCrypto.sign('hmac', key, encoder.encode(payload.data));
if (!payload.encoding)
return new Uint8Array(signature);
return decodeArrayBuffer(signature, payload.encoding);
};
const hash = async ({ algorithm = DEFAULT_ALGORITHM, data, encoding, }) => {
const _hash = await subtleCrypto.digest({ name: algorithm }, encoder.encode(data));
return decodeArrayBuffer(_hash, encoding);
};
const getNormalizedHeaders = (headers) => Object.keys(headers)
.map((key) => ({ key: key.toLowerCase(), value: headers[key] }))
.sort((a, b) => (a.key < b.key ? -1 : 1));
/**
* @private
* Create canonical headers
*
<pre>
CanonicalHeaders =
CanonicalHeadersEntry0 + CanonicalHeadersEntry1 + ... + CanonicalHeadersEntryN
CanonicalHeadersEntry =
Lowercase(HeaderName) + ':' + Trimall(HeaderValue) + '\n'
</pre>
*/
const getCanonicalHeaders = (headers) => {
const normalizedHeaders = getNormalizedHeaders(headers);
if (!normalizedHeaders.length)
return '';
return normalizedHeaders.reduce((acc, { key, value }) => {
value = value ? value.trim().replace(/\s{2,}/g, ' ') : '';
return acc + key + ':' + value + '\n';
}, '');
};
/**
* @private
* The list of headers that were included in the canonical headers
* For HTTP/1.1 requests, the host header must be included as a signed header.
* For HTTP/2 requests that include the :authority header instead of the host header,
* you must include the :authority header as a signed header. If you include a date or
* x-amz-date header, you must also include that header in the list of signed headers.
*/
const getSignedHeaders = (headers) => getNormalizedHeaders(headers)
.map(({ key }) => key)
.join(';');
/**
* @private
* Create a canonical request
* Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html|Create a Canonical Request}
*
<pre>
CanonicalRequest =
HTTPRequestMethod + '\n' +
CanonicalURI + '\n' +
CanonicalQueryString + '\n' +
CanonicalHeaders + '\n' +
SignedHeaders + '\n' +
HexEncode(Hash(RequestPayload))
</pre>
*/
const getCanonicalString = async (resource, fetchOptions) => {
const url = new URL(resource);
// Canonical query string parameter names must be sorted
url.searchParams.sort();
let bodyHash = '';
if (fetchOptions.headers['x-amz-content-sha256'] === 'UNSIGNED-PAYLOAD') {
bodyHash = 'UNSIGNED-PAYLOAD';
}
else {
// If this is *not* an unsigned payload, than sign it with a hexencoded hash
const body = typeof fetchOptions.body === 'string' ? fetchOptions.body : '';
bodyHash = await hash({ data: body, encoding: 'hex' });
}
return [
fetchOptions.method,
url.pathname,
url.searchParams.toString(),
getCanonicalHeaders(fetchOptions.headers),
getSignedHeaders(fetchOptions.headers),
bodyHash,
].join('\n');
};
const getRegionFromResource = (resource) => {
const { host } = new URL(resource);
const matched = host.match(/([^.]+)\.(?:([^.]*)\.)?amazonaws\.com$/);
// The region will be the third subdomain within the URL host
const region = matched && matched[2];
return region !== null && region !== void 0 ? region : '';
};
const getCredentialScope = ({ dateString, region, service, }) => `${dateString}/${region}/${service}/aws4_request`;
/**
* @private
* Create a string to sign
* Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html|Create String to Sign}
*/
const getStringToSign = async ({ algorithm, canonicalString, dateTimeString, scope, }) => [
algorithm,
dateTimeString,
scope,
await hash({ data: canonicalString, encoding: 'hex' }),
].join('\n');
/**
* @private
* Create signing key
* Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html|Calculate Signature}
*/
const getSigningKey = async ({ dateString, region, secretAccessKey, service, }) => {
const key = 'AWS4' + secretAccessKey;
const keyDate = await encrypt({ data: dateString, key });
const keyRegion = await encrypt({ data: region, key: keyDate });
const keyService = await encrypt({ data: service, key: keyRegion });
return await encrypt({ data: 'aws4_request', key: keyService });
};
const getSignature = async (signingKey, stringToSign) => (await encrypt({ data: stringToSign, encoding: 'hex', key: signingKey }));
/**
* @private
* Create authorization headers to include in the HTTP request
* Refer to {@link http://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html|Add signature to request}
*/
const getAuthorizationHeader = ({ accessKeyId, algorithm, scope, signature, signedHeaders, }) => [
algorithm + ' Credential=' + accessKeyId + '/' + scope,
'SignedHeaders=' + signedHeaders,
'Signature=' + signature,
].join(', ');
const getHeadersWithAuthorization = async (resource, fetchOptions, { accessKeyId, region = REGION || getRegionFromResource(resource), secretAccessKey, service = DEFAULT_SERVICE, sessionToken, }) => {
var _a;
const date = new Date();
const dateTimeString = date.toISOString().replace(/[:-]|\.\d{3}/g, '');
const dateString = dateTimeString.substring(0, 8);
const algorithm = 'AWS4-HMAC-SHA256';
const { host } = new URL(resource);
const headers = (_a = fetchOptions.headers) !== null && _a !== void 0 ? _a : {};
headers.host = host;
headers['x-amz-date'] = dateTimeString;
if (!headers.accept) {
headers.accept = '*/*';
}
if (!headers['content-type']) {
headers['content-type'] = 'application/json; charset=UTF-8';
}
if (sessionToken) {
headers['x-amz-security-token'] = sessionToken;
}
if (typeof fetchOptions.body !== 'string') {
headers['x-amz-content-sha256'] = 'UNSIGNED-PAYLOAD';
}
// Ensure there is no redundant authorization or date header
delete headers.authorization;
delete headers.date;
const scope = getCredentialScope({ dateString, region, service });
const signingKey = await getSigningKey({
dateString,
region,
secretAccessKey,
service,
});
const canonicalString = await getCanonicalString(resource, Object.assign(Object.assign({}, fetchOptions), { headers }));
const stringToSign = await getStringToSign({
algorithm,
canonicalString,
dateTimeString,
scope,
});
headers.authorization = getAuthorizationHeader({
accessKeyId,
algorithm,
scope,
signature: await getSignature(signingKey, stringToSign),
signedHeaders: getSignedHeaders(headers),
});
// Need to use host to sign, but don’t return it (it’s a forbidden header name)
delete headers.host;
return headers;
};
export { getAuthorizationHeader, getCanonicalHeaders, getCanonicalString, getCredentialScope, getHeadersWithAuthorization, getRegionFromResource, getSignature, getSignedHeaders, getSigningKey, getStringToSign, };
//# sourceMappingURL=index.js.map