From 7e70de4af19d7471b0f5a5674b6096891b1b85c1 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 29 Nov 2022 16:32:34 +0100 Subject: [PATCH] feat: support injecting globals for supporting platforms that do not have whatwg fetch as globals (e.g. Node.js) --- dist/aws4fetch.cjs.js | 46 ++++++++++++++++++++-------------- dist/aws4fetch.esm.js | 46 ++++++++++++++++++++-------------- dist/aws4fetch.esm.mjs | 46 ++++++++++++++++++++-------------- dist/aws4fetch.umd.js | 46 ++++++++++++++++++++-------------- dist/main.d.ts | 54 ++++++++++++++++++++++++++++++++++++++-- package.json | 2 +- src/main.js | 56 ++++++++++++++++++++++++++++-------------- 7 files changed, 202 insertions(+), 94 deletions(-) diff --git a/dist/aws4fetch.cjs.js b/dist/aws4fetch.cjs.js index d3c4b0b..60b8597 100644 --- a/dist/aws4fetch.cjs.js +++ b/dist/aws4fetch.cjs.js @@ -6,7 +6,6 @@ Object.defineProperty(exports, '__esModule', { value: true }); * @license MIT * @copyright Michael Hart 2022 */ -const encoder = new TextEncoder(); const HOST_SERVICES = { appstream2: 'appstream', cloudhsmv2: 'cloudhsm', @@ -19,6 +18,13 @@ const HOST_SERVICES = { 'mturk-requester-sandbox': 'mturk-requester', 'personalize-runtime': 'personalize', }; +const DEFAULT_API = { + fetch: globalThis.fetch, + Request: globalThis.Request, + Headers: globalThis.Headers, + crypto: globalThis.crypto, + TextEncoder: globalThis.TextEncoder, +}; const UNSIGNABLE_HEADERS = new Set([ 'authorization', 'content-type', @@ -31,7 +37,7 @@ const UNSIGNABLE_HEADERS = new Set([ 'connection', ]); class AwsClient { - constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) { + constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs, api }) { if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') this.accessKeyId = accessKeyId; @@ -42,9 +48,11 @@ class AwsClient { this.cache = cache || new Map(); this.retries = retries != null ? retries : 10; this.initRetryMs = initRetryMs || 50; + this.api = api || DEFAULT_API; + this.textEncoder = new this.api.TextEncoder(); } async sign(input, init) { - if (input instanceof Request) { + if (input instanceof this.api.Request) { const { method, url, headers, body } = input; init = Object.assign({ method, url, headers }, init); if (init.body == null && headers.has('Content-Type')) { @@ -52,21 +60,21 @@ class AwsClient { } input = url; } - const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init && init.aws)); + const signer = new AwsV4Signer(Object.assign({ url: input, api: this.api, textEncoder: this.textEncoder }, init, this, init && init.aws)); const signed = Object.assign({}, init, await signer.sign()); delete signed.aws; try { - return new Request(signed.url.toString(), signed) + return new this.api.Request(signed.url.toString(), signed) } catch (e) { if (e instanceof TypeError) { - return new Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) + return new this.api.Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) } throw e } } async fetch(input, init) { for (let i = 0; i <= this.retries; i++) { - const fetched = fetch(await this.sign(input, init)); + const fetched = this.api.fetch(await this.sign(input, init)); if (i === this.retries) { return fetched } @@ -80,13 +88,15 @@ class AwsClient { } } class AwsV4Signer { - constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) { + constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode, api, textEncoder }) { if (url == null) throw new TypeError('url is a required option') if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') + this.api = api ?? DEFAULT_API; + this.textEncoder = textEncoder || new DEFAULT_API.TextEncoder(); this.method = method || (body ? 'POST' : 'GET'); this.url = new URL(url); - this.headers = new Headers(headers || {}); + this.headers = new this.api.Headers(headers || {}); this.body = body; this.accessKeyId = accessKeyId; this.secretAccessKey = secretAccessKey; @@ -182,20 +192,20 @@ class AwsV4Signer { const cacheKey = [this.secretAccessKey, date, this.region, this.service].join(); let kCredentials = this.cache.get(cacheKey); if (!kCredentials) { - const kDate = await hmac('AWS4' + this.secretAccessKey, date); - const kRegion = await hmac(kDate, this.region); - const kService = await hmac(kRegion, this.service); - kCredentials = await hmac(kService, 'aws4_request'); + const kDate = await hmac(this.api.crypto, this.textEncoder, 'AWS4' + this.secretAccessKey, date); + const kRegion = await hmac(this.api.crypto, this.textEncoder, kDate, this.region); + const kService = await hmac(this.api.crypto, this.textEncoder, kRegion, this.service); + kCredentials = await hmac(this.api.crypto, this.textEncoder, kService, 'aws4_request'); this.cache.set(cacheKey, kCredentials); } - return buf2hex(await hmac(kCredentials, await this.stringToSign())) + return buf2hex(await hmac(this.api.crypto, this.textEncoder, kCredentials, await this.stringToSign())) } async stringToSign() { return [ 'AWS4-HMAC-SHA256', this.datetime, this.credentialString, - buf2hex(await hash(await this.canonicalString())), + buf2hex(await hash(this.api.crypto, this.textEncoder, await this.canonicalString())), ].join('\n') } async canonicalString() { @@ -214,12 +224,12 @@ class AwsV4Signer { if (this.body && typeof this.body !== 'string' && !('byteLength' in this.body)) { throw new Error('body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header') } - hashHeader = buf2hex(await hash(this.body || '')); + hashHeader = buf2hex(await hash(this.api.crypto, this.textEncoder, this.body || '')); } return hashHeader } } -async function hmac(key, string) { +async function hmac(crypto, encoder, key, string) { const cryptoKey = await crypto.subtle.importKey( 'raw', typeof key === 'string' ? encoder.encode(key) : key, @@ -229,7 +239,7 @@ async function hmac(key, string) { ); return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(string)) } -async function hash(content) { +async function hash(crypto, encoder, content) { return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content) } function buf2hex(buffer) { diff --git a/dist/aws4fetch.esm.js b/dist/aws4fetch.esm.js index 9a47c42..7088d17 100644 --- a/dist/aws4fetch.esm.js +++ b/dist/aws4fetch.esm.js @@ -2,7 +2,6 @@ * @license MIT * @copyright Michael Hart 2022 */ -const encoder = new TextEncoder(); const HOST_SERVICES = { appstream2: 'appstream', cloudhsmv2: 'cloudhsm', @@ -15,6 +14,13 @@ const HOST_SERVICES = { 'mturk-requester-sandbox': 'mturk-requester', 'personalize-runtime': 'personalize', }; +const DEFAULT_API = { + fetch: globalThis.fetch, + Request: globalThis.Request, + Headers: globalThis.Headers, + crypto: globalThis.crypto, + TextEncoder: globalThis.TextEncoder, +}; const UNSIGNABLE_HEADERS = new Set([ 'authorization', 'content-type', @@ -27,7 +33,7 @@ const UNSIGNABLE_HEADERS = new Set([ 'connection', ]); class AwsClient { - constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) { + constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs, api }) { if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') this.accessKeyId = accessKeyId; @@ -38,9 +44,11 @@ class AwsClient { this.cache = cache || new Map(); this.retries = retries != null ? retries : 10; this.initRetryMs = initRetryMs || 50; + this.api = api || DEFAULT_API; + this.textEncoder = new this.api.TextEncoder(); } async sign(input, init) { - if (input instanceof Request) { + if (input instanceof this.api.Request) { const { method, url, headers, body } = input; init = Object.assign({ method, url, headers }, init); if (init.body == null && headers.has('Content-Type')) { @@ -48,21 +56,21 @@ class AwsClient { } input = url; } - const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init && init.aws)); + const signer = new AwsV4Signer(Object.assign({ url: input, api: this.api, textEncoder: this.textEncoder }, init, this, init && init.aws)); const signed = Object.assign({}, init, await signer.sign()); delete signed.aws; try { - return new Request(signed.url.toString(), signed) + return new this.api.Request(signed.url.toString(), signed) } catch (e) { if (e instanceof TypeError) { - return new Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) + return new this.api.Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) } throw e } } async fetch(input, init) { for (let i = 0; i <= this.retries; i++) { - const fetched = fetch(await this.sign(input, init)); + const fetched = this.api.fetch(await this.sign(input, init)); if (i === this.retries) { return fetched } @@ -76,13 +84,15 @@ class AwsClient { } } class AwsV4Signer { - constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) { + constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode, api, textEncoder }) { if (url == null) throw new TypeError('url is a required option') if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') + this.api = api ?? DEFAULT_API; + this.textEncoder = textEncoder || new DEFAULT_API.TextEncoder(); this.method = method || (body ? 'POST' : 'GET'); this.url = new URL(url); - this.headers = new Headers(headers || {}); + this.headers = new this.api.Headers(headers || {}); this.body = body; this.accessKeyId = accessKeyId; this.secretAccessKey = secretAccessKey; @@ -178,20 +188,20 @@ class AwsV4Signer { const cacheKey = [this.secretAccessKey, date, this.region, this.service].join(); let kCredentials = this.cache.get(cacheKey); if (!kCredentials) { - const kDate = await hmac('AWS4' + this.secretAccessKey, date); - const kRegion = await hmac(kDate, this.region); - const kService = await hmac(kRegion, this.service); - kCredentials = await hmac(kService, 'aws4_request'); + const kDate = await hmac(this.api.crypto, this.textEncoder, 'AWS4' + this.secretAccessKey, date); + const kRegion = await hmac(this.api.crypto, this.textEncoder, kDate, this.region); + const kService = await hmac(this.api.crypto, this.textEncoder, kRegion, this.service); + kCredentials = await hmac(this.api.crypto, this.textEncoder, kService, 'aws4_request'); this.cache.set(cacheKey, kCredentials); } - return buf2hex(await hmac(kCredentials, await this.stringToSign())) + return buf2hex(await hmac(this.api.crypto, this.textEncoder, kCredentials, await this.stringToSign())) } async stringToSign() { return [ 'AWS4-HMAC-SHA256', this.datetime, this.credentialString, - buf2hex(await hash(await this.canonicalString())), + buf2hex(await hash(this.api.crypto, this.textEncoder, await this.canonicalString())), ].join('\n') } async canonicalString() { @@ -210,12 +220,12 @@ class AwsV4Signer { if (this.body && typeof this.body !== 'string' && !('byteLength' in this.body)) { throw new Error('body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header') } - hashHeader = buf2hex(await hash(this.body || '')); + hashHeader = buf2hex(await hash(this.api.crypto, this.textEncoder, this.body || '')); } return hashHeader } } -async function hmac(key, string) { +async function hmac(crypto, encoder, key, string) { const cryptoKey = await crypto.subtle.importKey( 'raw', typeof key === 'string' ? encoder.encode(key) : key, @@ -225,7 +235,7 @@ async function hmac(key, string) { ); return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(string)) } -async function hash(content) { +async function hash(crypto, encoder, content) { return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content) } function buf2hex(buffer) { diff --git a/dist/aws4fetch.esm.mjs b/dist/aws4fetch.esm.mjs index 9a47c42..7088d17 100644 --- a/dist/aws4fetch.esm.mjs +++ b/dist/aws4fetch.esm.mjs @@ -2,7 +2,6 @@ * @license MIT * @copyright Michael Hart 2022 */ -const encoder = new TextEncoder(); const HOST_SERVICES = { appstream2: 'appstream', cloudhsmv2: 'cloudhsm', @@ -15,6 +14,13 @@ const HOST_SERVICES = { 'mturk-requester-sandbox': 'mturk-requester', 'personalize-runtime': 'personalize', }; +const DEFAULT_API = { + fetch: globalThis.fetch, + Request: globalThis.Request, + Headers: globalThis.Headers, + crypto: globalThis.crypto, + TextEncoder: globalThis.TextEncoder, +}; const UNSIGNABLE_HEADERS = new Set([ 'authorization', 'content-type', @@ -27,7 +33,7 @@ const UNSIGNABLE_HEADERS = new Set([ 'connection', ]); class AwsClient { - constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) { + constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs, api }) { if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') this.accessKeyId = accessKeyId; @@ -38,9 +44,11 @@ class AwsClient { this.cache = cache || new Map(); this.retries = retries != null ? retries : 10; this.initRetryMs = initRetryMs || 50; + this.api = api || DEFAULT_API; + this.textEncoder = new this.api.TextEncoder(); } async sign(input, init) { - if (input instanceof Request) { + if (input instanceof this.api.Request) { const { method, url, headers, body } = input; init = Object.assign({ method, url, headers }, init); if (init.body == null && headers.has('Content-Type')) { @@ -48,21 +56,21 @@ class AwsClient { } input = url; } - const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init && init.aws)); + const signer = new AwsV4Signer(Object.assign({ url: input, api: this.api, textEncoder: this.textEncoder }, init, this, init && init.aws)); const signed = Object.assign({}, init, await signer.sign()); delete signed.aws; try { - return new Request(signed.url.toString(), signed) + return new this.api.Request(signed.url.toString(), signed) } catch (e) { if (e instanceof TypeError) { - return new Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) + return new this.api.Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) } throw e } } async fetch(input, init) { for (let i = 0; i <= this.retries; i++) { - const fetched = fetch(await this.sign(input, init)); + const fetched = this.api.fetch(await this.sign(input, init)); if (i === this.retries) { return fetched } @@ -76,13 +84,15 @@ class AwsClient { } } class AwsV4Signer { - constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) { + constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode, api, textEncoder }) { if (url == null) throw new TypeError('url is a required option') if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') + this.api = api ?? DEFAULT_API; + this.textEncoder = textEncoder || new DEFAULT_API.TextEncoder(); this.method = method || (body ? 'POST' : 'GET'); this.url = new URL(url); - this.headers = new Headers(headers || {}); + this.headers = new this.api.Headers(headers || {}); this.body = body; this.accessKeyId = accessKeyId; this.secretAccessKey = secretAccessKey; @@ -178,20 +188,20 @@ class AwsV4Signer { const cacheKey = [this.secretAccessKey, date, this.region, this.service].join(); let kCredentials = this.cache.get(cacheKey); if (!kCredentials) { - const kDate = await hmac('AWS4' + this.secretAccessKey, date); - const kRegion = await hmac(kDate, this.region); - const kService = await hmac(kRegion, this.service); - kCredentials = await hmac(kService, 'aws4_request'); + const kDate = await hmac(this.api.crypto, this.textEncoder, 'AWS4' + this.secretAccessKey, date); + const kRegion = await hmac(this.api.crypto, this.textEncoder, kDate, this.region); + const kService = await hmac(this.api.crypto, this.textEncoder, kRegion, this.service); + kCredentials = await hmac(this.api.crypto, this.textEncoder, kService, 'aws4_request'); this.cache.set(cacheKey, kCredentials); } - return buf2hex(await hmac(kCredentials, await this.stringToSign())) + return buf2hex(await hmac(this.api.crypto, this.textEncoder, kCredentials, await this.stringToSign())) } async stringToSign() { return [ 'AWS4-HMAC-SHA256', this.datetime, this.credentialString, - buf2hex(await hash(await this.canonicalString())), + buf2hex(await hash(this.api.crypto, this.textEncoder, await this.canonicalString())), ].join('\n') } async canonicalString() { @@ -210,12 +220,12 @@ class AwsV4Signer { if (this.body && typeof this.body !== 'string' && !('byteLength' in this.body)) { throw new Error('body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header') } - hashHeader = buf2hex(await hash(this.body || '')); + hashHeader = buf2hex(await hash(this.api.crypto, this.textEncoder, this.body || '')); } return hashHeader } } -async function hmac(key, string) { +async function hmac(crypto, encoder, key, string) { const cryptoKey = await crypto.subtle.importKey( 'raw', typeof key === 'string' ? encoder.encode(key) : key, @@ -225,7 +235,7 @@ async function hmac(key, string) { ); return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(string)) } -async function hash(content) { +async function hash(crypto, encoder, content) { return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content) } function buf2hex(buffer) { diff --git a/dist/aws4fetch.umd.js b/dist/aws4fetch.umd.js index 4bbbd41..c7b03cb 100644 --- a/dist/aws4fetch.umd.js +++ b/dist/aws4fetch.umd.js @@ -8,7 +8,6 @@ * @license MIT * @copyright Michael Hart 2022 */ - const encoder = new TextEncoder(); const HOST_SERVICES = { appstream2: 'appstream', cloudhsmv2: 'cloudhsm', @@ -21,6 +20,13 @@ 'mturk-requester-sandbox': 'mturk-requester', 'personalize-runtime': 'personalize', }; + const DEFAULT_API = { + fetch: globalThis.fetch, + Request: globalThis.Request, + Headers: globalThis.Headers, + crypto: globalThis.crypto, + TextEncoder: globalThis.TextEncoder, + }; const UNSIGNABLE_HEADERS = new Set([ 'authorization', 'content-type', @@ -33,7 +39,7 @@ 'connection', ]); class AwsClient { - constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) { + constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs, api }) { if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') this.accessKeyId = accessKeyId; @@ -44,9 +50,11 @@ this.cache = cache || new Map(); this.retries = retries != null ? retries : 10; this.initRetryMs = initRetryMs || 50; + this.api = api || DEFAULT_API; + this.textEncoder = new this.api.TextEncoder(); } async sign(input, init) { - if (input instanceof Request) { + if (input instanceof this.api.Request) { const { method, url, headers, body } = input; init = Object.assign({ method, url, headers }, init); if (init.body == null && headers.has('Content-Type')) { @@ -54,21 +62,21 @@ } input = url; } - const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init && init.aws)); + const signer = new AwsV4Signer(Object.assign({ url: input, api: this.api, textEncoder: this.textEncoder }, init, this, init && init.aws)); const signed = Object.assign({}, init, await signer.sign()); delete signed.aws; try { - return new Request(signed.url.toString(), signed) + return new this.api.Request(signed.url.toString(), signed) } catch (e) { if (e instanceof TypeError) { - return new Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) + return new this.api.Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) } throw e } } async fetch(input, init) { for (let i = 0; i <= this.retries; i++) { - const fetched = fetch(await this.sign(input, init)); + const fetched = this.api.fetch(await this.sign(input, init)); if (i === this.retries) { return fetched } @@ -82,13 +90,15 @@ } } class AwsV4Signer { - constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) { + constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode, api, textEncoder }) { if (url == null) throw new TypeError('url is a required option') if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') + this.api = api ?? DEFAULT_API; + this.textEncoder = textEncoder || new DEFAULT_API.TextEncoder(); this.method = method || (body ? 'POST' : 'GET'); this.url = new URL(url); - this.headers = new Headers(headers || {}); + this.headers = new this.api.Headers(headers || {}); this.body = body; this.accessKeyId = accessKeyId; this.secretAccessKey = secretAccessKey; @@ -184,20 +194,20 @@ const cacheKey = [this.secretAccessKey, date, this.region, this.service].join(); let kCredentials = this.cache.get(cacheKey); if (!kCredentials) { - const kDate = await hmac('AWS4' + this.secretAccessKey, date); - const kRegion = await hmac(kDate, this.region); - const kService = await hmac(kRegion, this.service); - kCredentials = await hmac(kService, 'aws4_request'); + const kDate = await hmac(this.api.crypto, this.textEncoder, 'AWS4' + this.secretAccessKey, date); + const kRegion = await hmac(this.api.crypto, this.textEncoder, kDate, this.region); + const kService = await hmac(this.api.crypto, this.textEncoder, kRegion, this.service); + kCredentials = await hmac(this.api.crypto, this.textEncoder, kService, 'aws4_request'); this.cache.set(cacheKey, kCredentials); } - return buf2hex(await hmac(kCredentials, await this.stringToSign())) + return buf2hex(await hmac(this.api.crypto, this.textEncoder, kCredentials, await this.stringToSign())) } async stringToSign() { return [ 'AWS4-HMAC-SHA256', this.datetime, this.credentialString, - buf2hex(await hash(await this.canonicalString())), + buf2hex(await hash(this.api.crypto, this.textEncoder, await this.canonicalString())), ].join('\n') } async canonicalString() { @@ -216,12 +226,12 @@ if (this.body && typeof this.body !== 'string' && !('byteLength' in this.body)) { throw new Error('body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header') } - hashHeader = buf2hex(await hash(this.body || '')); + hashHeader = buf2hex(await hash(this.api.crypto, this.textEncoder, this.body || '')); } return hashHeader } } - async function hmac(key, string) { + async function hmac(crypto, encoder, key, string) { const cryptoKey = await crypto.subtle.importKey( 'raw', typeof key === 'string' ? encoder.encode(key) : key, @@ -231,7 +241,7 @@ ); return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(string)) } - async function hash(content) { + async function hash(crypto, encoder, content) { return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content) } function buf2hex(buffer) { diff --git a/dist/main.d.ts b/dist/main.d.ts index ef18119..3cbc1cb 100644 --- a/dist/main.d.ts +++ b/dist/main.d.ts @@ -1,5 +1,5 @@ export class AwsClient { - constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }: { + constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs, api }: { accessKeyId: string; secretAccessKey: string; sessionToken?: string; @@ -8,6 +8,7 @@ export class AwsClient { cache?: Map; retries?: number; initRetryMs?: number; + api?: typeof DEFAULT_API; }); accessKeyId: string; secretAccessKey: string; @@ -17,6 +18,23 @@ export class AwsClient { cache: Map; retries: number; initRetryMs: number; + api: { + fetch: typeof fetch; + Request: { + new (input: RequestInfo | URL, init?: RequestInit | undefined): Request; + prototype: Request; + }; + Headers: { + new (init?: HeadersInit | undefined): Headers; + prototype: Headers; + }; + crypto: Crypto; + TextEncoder: { + new (): TextEncoder; + prototype: TextEncoder; + }; + }; + textEncoder: TextEncoder; sign(input: RequestInfo, init?: (RequestInit & { aws?: { accessKeyId?: string | undefined; @@ -49,7 +67,7 @@ export class AwsClient { }) | null | undefined): Promise; } export class AwsV4Signer { - constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }: { + constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode, api, textEncoder }: { method?: string; url: string; headers?: HeadersInit; @@ -65,7 +83,17 @@ export class AwsV4Signer { appendSessionToken?: boolean; allHeaders?: boolean; singleEncode?: boolean; + textEncoder?: TextEncoder; + api?: { + Headers: typeof Headers; + crypto: Crypto; + }; }); + api: { + Headers: typeof Headers; + crypto: Crypto; + }; + textEncoder: TextEncoder; method: string; url: URL; headers: Headers; @@ -97,3 +125,25 @@ export class AwsV4Signer { canonicalString(): Promise; hexBodyHash(): Promise; } +declare namespace DEFAULT_API { + const fetch_1: typeof globalThis.fetch; + export { fetch_1 as fetch }; + const Request_1: { + new (input: RequestInfo | URL, init?: RequestInit | undefined): Request; + prototype: Request; + }; + export { Request_1 as Request }; + const Headers_1: { + new (init?: HeadersInit | undefined): Headers; + prototype: Headers; + }; + export { Headers_1 as Headers }; + const crypto_1: Crypto; + export { crypto_1 as crypto }; + const TextEncoder_1: { + new (): TextEncoder; + prototype: TextEncoder; + }; + export { TextEncoder_1 as TextEncoder }; +} +export {}; diff --git a/package.json b/package.json index 4ea1e45..5c2008c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "scripts": { "declaration": "tsc -p declaration.tsconfig.json", - "build": "npm run declaration && rollup -c", + "build": "npm run declaration && npx rollup -c", "prepare": "npm run build", "lint": "eslint --ext .js,.cjs,.mjs .", "format": "eslint --ext .js,.cjs,.mjs --fix .", diff --git a/src/main.js b/src/main.js index 2a69741..a946116 100644 --- a/src/main.js +++ b/src/main.js @@ -5,8 +5,6 @@ * @copyright Michael Hart 2022 */ -const encoder = new TextEncoder() - /** @type {Object.} */ const HOST_SERVICES = { appstream2: 'appstream', @@ -21,6 +19,14 @@ const HOST_SERVICES = { 'personalize-runtime': 'personalize', } +const DEFAULT_API = { + fetch: globalThis.fetch, + Request: globalThis.Request, + Headers: globalThis.Headers, + crypto: globalThis.crypto, + TextEncoder: globalThis.TextEncoder, +} + // https://github.com/aws/aws-sdk-js/blob/cc29728c1c4178969ebabe3bbe6b6f3159436394/lib/signers/v4.js#L190-L198 const UNSIGNABLE_HEADERS = new Set([ 'authorization', @@ -45,9 +51,10 @@ export class AwsClient { * cache?: Map * retries?: number * initRetryMs?: number + * api?: typeof DEFAULT_API * }} options */ - constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) { + constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs, api }) { if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') this.accessKeyId = accessKeyId @@ -58,6 +65,8 @@ export class AwsClient { this.cache = cache || new Map() this.retries = retries != null ? retries : 10 // Up to 25.6 secs this.initRetryMs = initRetryMs || 50 + this.api = api || DEFAULT_API + this.textEncoder = new this.api.TextEncoder() } /** @@ -82,7 +91,7 @@ export class AwsClient { * @returns {Promise} */ async sign(input, init) { - if (input instanceof Request) { + if (input instanceof this.api.Request) { const { method, url, headers, body } = input init = Object.assign({ method, url, headers }, init) if (init.body == null && headers.has('Content-Type')) { @@ -90,15 +99,15 @@ export class AwsClient { } input = url } - const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init && init.aws)) + const signer = new AwsV4Signer(Object.assign({ url: input, api: this.api, textEncoder: this.textEncoder }, init, this, init && init.aws)) const signed = Object.assign({}, init, await signer.sign()) delete signed.aws try { - return new Request(signed.url.toString(), signed) + return new this.api.Request(signed.url.toString(), signed) } catch (e) { if (e instanceof TypeError) { // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943 - return new Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) + return new this.api.Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed)) } throw e } @@ -111,7 +120,7 @@ export class AwsClient { */ async fetch(input, init) { for (let i = 0; i <= this.retries; i++) { - const fetched = fetch(await this.sign(input, init)) + const fetched = this.api.fetch(await this.sign(input, init)) if (i === this.retries) { return fetched // No need to await if we're returning anyway } @@ -143,16 +152,21 @@ export class AwsV4Signer { * appendSessionToken?: boolean * allHeaders?: boolean * singleEncode?: boolean + * textEncoder?: TextEncoder + * api?: { Headers: typeof Headers; crypto: Crypto; } * }} options */ - constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) { + constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode, api, textEncoder }) { if (url == null) throw new TypeError('url is a required option') if (accessKeyId == null) throw new TypeError('accessKeyId is a required option') if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option') + this.api = api ?? DEFAULT_API + this.textEncoder = textEncoder || new DEFAULT_API.TextEncoder() + this.method = method || (body ? 'POST' : 'GET') this.url = new URL(url) - this.headers = new Headers(headers || {}) + this.headers = new this.api.Headers(headers || {}) this.body = body this.accessKeyId = accessKeyId @@ -285,13 +299,13 @@ export class AwsV4Signer { const cacheKey = [this.secretAccessKey, date, this.region, this.service].join() let kCredentials = this.cache.get(cacheKey) if (!kCredentials) { - const kDate = await hmac('AWS4' + this.secretAccessKey, date) - const kRegion = await hmac(kDate, this.region) - const kService = await hmac(kRegion, this.service) - kCredentials = await hmac(kService, 'aws4_request') + const kDate = await hmac(this.api.crypto, this.textEncoder, 'AWS4' + this.secretAccessKey, date) + const kRegion = await hmac(this.api.crypto, this.textEncoder, kDate, this.region) + const kService = await hmac(this.api.crypto, this.textEncoder, kRegion, this.service) + kCredentials = await hmac(this.api.crypto, this.textEncoder, kService, 'aws4_request') this.cache.set(cacheKey, kCredentials) } - return buf2hex(await hmac(kCredentials, await this.stringToSign())) + return buf2hex(await hmac(this.api.crypto, this.textEncoder, kCredentials, await this.stringToSign())) } /** @@ -302,7 +316,7 @@ export class AwsV4Signer { 'AWS4-HMAC-SHA256', this.datetime, this.credentialString, - buf2hex(await hash(await this.canonicalString())), + buf2hex(await hash(this.api.crypto, this.textEncoder, await this.canonicalString())), ].join('\n') } @@ -329,18 +343,20 @@ export class AwsV4Signer { if (this.body && typeof this.body !== 'string' && !('byteLength' in this.body)) { throw new Error('body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header') } - hashHeader = buf2hex(await hash(this.body || '')) + hashHeader = buf2hex(await hash(this.api.crypto, this.textEncoder, this.body || '')) } return hashHeader } } /** + * @param {Crypto} crypto + * @param {TextEncoder} encoder * @param {string | ArrayBufferView | ArrayBuffer} key * @param {string} string * @returns {Promise} */ -async function hmac(key, string) { +async function hmac(crypto, encoder, key, string) { // @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715 const cryptoKey = await crypto.subtle.importKey( 'raw', @@ -353,10 +369,12 @@ async function hmac(key, string) { } /** + * @param {Crypto} crypto + * @param {TextEncoder} encoder * @param {string | ArrayBufferView | ArrayBuffer} content * @returns {Promise} */ -async function hash(content) { +async function hash(crypto, encoder, content) { // @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715 return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content) }