@acusti/aws-signature-v4
Version:
A lightweight isomorphic module to generate request headers that fulfill the AWS SigV4 signing process
330 lines (293 loc) • 9.6 kB
text/typescript
import webcrypto from '@acusti/webcrypto';
import {
type AWSOptions as _AWSOptions,
type FetchHeaders,
type FetchOptions,
} from './types.js';
export type AWSOptions = _AWSOptions;
type FetchOptionsWithHeaders = { headers: FetchHeaders } & Omit<FetchOptions, 'headers'>;
const universalBtoa = (text: string) => {
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: string = typeof process === 'undefined' ? '' : process.env.REGION;
const decodeArrayBuffer = (buffer: ArrayBuffer, encoding: string) => {
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: {
algorithm?: string;
data: string;
encoding?: string;
key: string | Uint8Array;
}) => {
const keyArray =
typeof payload.key === 'string' ? encoder.encode(payload.key) : payload.key;
const algorithm = { hash: payload.algorithm ?? 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,
}: {
algorithm?: string;
data: string;
encoding: string;
}) => {
const _hash = await subtleCrypto.digest({ name: algorithm }, encoder.encode(data));
return decodeArrayBuffer(_hash, encoding);
};
const getNormalizedHeaders = (headers: FetchHeaders) =>
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: FetchHeaders) => {
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: FetchHeaders) =>
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: string,
fetchOptions: FetchOptionsWithHeaders,
) => {
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: string) => {
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 ?? '';
};
const getCredentialScope = ({
dateString,
region,
service,
}: {
dateString: string;
region: string;
service: string;
}) => `${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: string;
canonicalString: string;
dateTimeString: string;
scope: string;
}) =>
[
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,
}: {
dateString: string;
region: string;
secretAccessKey: string;
service: string;
}) => {
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: string | Uint8Array, stringToSign: string) =>
(await encrypt({ data: stringToSign, encoding: 'hex', key: signingKey })) as string;
/**
* @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,
}: {
accessKeyId: string;
algorithm: string;
scope: string;
signature: string;
signedHeaders: string;
}) =>
[
algorithm + ' Credential=' + accessKeyId + '/' + scope,
'SignedHeaders=' + signedHeaders,
'Signature=' + signature,
].join(', ');
const getHeadersWithAuthorization = async (
resource: string,
fetchOptions: FetchOptions,
{
accessKeyId,
region = REGION || getRegionFromResource(resource),
secretAccessKey,
service = DEFAULT_SERVICE,
sessionToken,
}: AWSOptions,
) => {
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: FetchHeaders = fetchOptions.headers ?? {};
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, {
...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,
};