UNPKG

@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
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