diff --git a/.github/workflows/bundler.yml b/.github/workflows/bundler.yml index fee280303..d40f16884 100644 --- a/.github/workflows/bundler.yml +++ b/.github/workflows/bundler.yml @@ -52,15 +52,20 @@ jobs: npm install --prefix test/bundlers/parcel-test npm install --prefix test/bundlers/rollup-test npm install --prefix test/bundlers/webpack-test + npm install --prefix test/bundlers/esbuild-test - name: Build run: | npm run build --prefix test/bundlers/parcel-test npm run build --prefix test/bundlers/rollup-test npm run build --prefix test/bundlers/webpack-test + npm run build:aws --prefix test/bundlers/esbuild-test + npm run build:awsv3 --prefix test/bundlers/esbuild-test - name: Run bundle run: | npm start --prefix test/bundlers/parcel-test npm start --prefix test/bundlers/rollup-test npm start --prefix test/bundlers/webpack-test + npm run start:aws --prefix test/bundlers/esbuild-test + npm run start:awsv3 --prefix test/bundlers/esbuild-test diff --git a/.gitignore b/.gitignore index 1f15093f3..6de610f52 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ test/benchmarks/macro/fixtures/* test/bundlers/**/bundle.js test/bundlers/parcel-test/.parcel-cache +test/bundlers/esbuild-test/bundle-*.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f0a3544..474aa4086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] ### Added +- Added `@opensearch-project/opensearch/aws-v3` import ([758](https://github.com/opensearch-project/opensearch-js/pull/758)). ### Dependencies - Bumps `@types/node` from 20.12.5 to 20.12.7 - Bumps `semver` from 5.7.1 to 7.6.0 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8545cc8d8..5aab1b79f 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -90,7 +90,9 @@ const client = new Client({ ```javascript const { defaultProvider } = require('@aws-sdk/credential-provider-node'); // V3 SDK. const { Client } = require('@opensearch-project/opensearch'); -const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws'); +const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws-v3'); // use aws-v3 import path if you are using aws-sdk v3 +// Unlike the import path in the v2 example above that lazy loads both aws-sdk v3 credential providers & entire aws-sdk v2 if available +// This will only lazy load the aws-sdk v3 credential providers const client = new Client({ ...AwsSigv4Signer({ diff --git a/lib/aws/AwsSigv4Signer-sdk-v3.js b/lib/aws/AwsSigv4Signer-sdk-v3.js new file mode 100644 index 000000000..a840ea59d --- /dev/null +++ b/lib/aws/AwsSigv4Signer-sdk-v3.js @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ + +'use strict'; +const AwsSigv4SignerError = require('./errors'); +const { giveAwsV4Signer, giveAwsCredentialProviderLoader } = require('./shared'); + +const getAwsSDKCredentialsProvider = async () => { + try { + const awsV3 = await import('@aws-sdk/credential-provider-node'); + if (typeof awsV3.defaultProvider === 'function') { + return awsV3.defaultProvider(); + } + } catch (err) { + throw new AwsSigv4SignerError( + "Missing '@aws-sdk/credential-provider-node' module. Install it as a dependency." + ); + } +}; + +const AwsSigv4Signer = giveAwsV4Signer( + giveAwsCredentialProviderLoader(getAwsSDKCredentialsProvider) +); + +module.exports = AwsSigv4Signer; diff --git a/lib/aws/AwsSigv4Signer.js b/lib/aws/AwsSigv4Signer.js index 8e6ae8e33..a26570026 100644 --- a/lib/aws/AwsSigv4Signer.js +++ b/lib/aws/AwsSigv4Signer.js @@ -9,12 +9,8 @@ */ 'use strict'; -const Connection = require('../Connection'); -const Transport = require('../Transport'); -const aws4 = require('aws4'); const AwsSigv4SignerError = require('./errors'); -const crypto = require('crypto'); -const { toMs } = Transport.internals; +const { giveAwsV4Signer, giveAwsCredentialProviderLoader } = require('./shared'); const getAwsSDKCredentialsProvider = async () => { // First try V3 @@ -49,150 +45,8 @@ const getAwsSDKCredentialsProvider = async () => { ); }; -const awsDefaultCredentialsProvider = () => - new Promise((resolve, reject) => { - getAwsSDKCredentialsProvider() - .then((provider) => { - provider().then(resolve).catch(reject); - }) - .catch((err) => { - reject(err); - }); - }); +const AwsSigv4Signer = giveAwsV4Signer( + giveAwsCredentialProviderLoader(getAwsSDKCredentialsProvider) +); -function AwsSigv4Signer(opts = {}) { - const credentialsState = { - credentials: null, - }; - if (!opts.region) { - throw new AwsSigv4SignerError('Region cannot be empty'); - } - if (!opts.service) { - opts.service = 'es'; - } - if (typeof opts.getCredentials !== 'function') { - opts.getCredentials = awsDefaultCredentialsProvider; - } - - function buildSignedRequestObject(request = {}) { - request.service = opts.service; - request.region = opts.region; - request.headers = request.headers || {}; - request.headers['host'] = request.hostname; - - if (request['auth']) { - const awssigv4Cred = request['auth']; - credentialsState.credentials = { - accessKeyId: awssigv4Cred.credentials.accessKeyId, - secretAccessKey: awssigv4Cred.credentials.secretAccessKey, - sessionToken: awssigv4Cred.credentials.sessionToken, - }; - request.region = awssigv4Cred.region; - request.service = awssigv4Cred.service; - delete request['auth']; - } - const signed = aws4.sign(request, credentialsState.credentials); - signed.headers['x-amz-content-sha256'] = crypto - .createHash('sha256') - .update(request.body || '', 'utf8') - .digest('hex'); - return signed; - } - - class AwsSigv4SignerConnection extends Connection { - buildRequestObject(params) { - const request = super.buildRequestObject(params); - return buildSignedRequestObject(request); - } - } - - class AwsSigv4SignerTransport extends Transport { - request(params, options = {}, callback = undefined) { - // options is optional so if options is a function, it's the callback. - if (typeof options === 'function') { - callback = options; - options = {}; - } - - const currentCredentials = credentialsState.credentials; - /** - * For AWS SDK V3 - * Make sure token will expire no earlier than `expiryBufferMs` milliseconds in the future. - */ - const expiryBufferMs = toMs(options.requestTimeout || this.requestTimeout); - - let expired = false; - if (!currentCredentials) { - // Credentials haven't been acquired yet. - expired = true; - } - // AWS SDK V2, needsRefresh should be available. - else if (typeof currentCredentials.needsRefresh === 'function') { - expired = currentCredentials.needsRefresh(); - } - // AWS SDK V2, alternative to needsRefresh. - else if (currentCredentials.expired === true) { - expired = true; - } - // AWS SDK V2, alternative to needsRefresh and expired. - else if (currentCredentials.expireTime && currentCredentials.expireTime < new Date()) { - expired = true; - } - // AWS SDK V3, Credentials.expiration is a Date object - else if ( - currentCredentials.expiration && - currentCredentials.expiration.getTime() - Date.now() < expiryBufferMs - ) { - expired = true; - } - - if (!expired) { - if (typeof callback === 'undefined') { - return super.request(params, options); - } - super.request(params, options, callback); - return; - } - - // In AWS SDK V2 Credentials.refreshPromise should be available. - if (currentCredentials && typeof currentCredentials.refreshPromise === 'function') { - if (typeof callback === 'undefined') { - return currentCredentials.refreshPromise().then(() => { - return super.request(params, options); - }); - } else { - currentCredentials - .refreshPromise() - .then(() => { - super.request(params, options, callback); - }) - .catch(callback); - return; - } - } - - // For AWS SDK V3 or when the client has not acquired credentials yet. - if (typeof callback === 'undefined') { - return opts.getCredentials().then((credentials) => { - credentialsState.credentials = credentials; - return super.request(params, options); - }); - } else { - opts - .getCredentials() - .then((credentials) => { - credentialsState.credentials = credentials; - super.request(params, options, callback); - }) - .catch(callback); - } - } - } - - return { - Transport: AwsSigv4SignerTransport, - Connection: AwsSigv4SignerConnection, - buildSignedRequestObject, - }; -} module.exports = AwsSigv4Signer; diff --git a/lib/aws/index-v3.js b/lib/aws/index-v3.js new file mode 100644 index 000000000..078929a11 --- /dev/null +++ b/lib/aws/index-v3.js @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ + +'use strict'; + +const AwsSigv4Signer = require('./AwsSigv4Signer-sdk-v3'); +const AwsSigv4SignerError = require('./errors'); + +module.exports = { + AwsSigv4Signer, + AwsSigv4SignerError, +}; diff --git a/lib/aws/shared.js b/lib/aws/shared.js new file mode 100644 index 000000000..2d79d3fbd --- /dev/null +++ b/lib/aws/shared.js @@ -0,0 +1,172 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ + +'use strict'; +const Connection = require('../Connection'); +const Transport = require('../Transport'); +const aws4 = require('aws4'); +const AwsSigv4SignerError = require('./errors'); +const crypto = require('crypto'); +const { toMs } = Transport.internals; + +function giveAwsCredentialProviderLoader(getAwsSDKCredentialsProvider) { + return function loadAwsCredentialProvider() { + return new Promise((resolve, reject) => { + getAwsSDKCredentialsProvider() + .then((provider) => { + provider().then(resolve).catch(reject); + }) + .catch((err) => { + reject(err); + }); + }); + }; +} + +function giveAwsV4Signer(awsDefaultCredentialsProvider) { + return function AwsSigv4Signer(opts = {}) { + const credentialsState = { + credentials: null, + }; + if (!opts.region) { + throw new AwsSigv4SignerError('Region cannot be empty'); + } + if (!opts.service) { + opts.service = 'es'; + } + if (typeof opts.getCredentials !== 'function') { + opts.getCredentials = awsDefaultCredentialsProvider; + } + + function buildSignedRequestObject(request = {}) { + request.service = opts.service; + request.region = opts.region; + request.headers = request.headers || {}; + request.headers['host'] = request.hostname; + + if (request['auth']) { + const awssigv4Cred = request['auth']; + credentialsState.credentials = { + accessKeyId: awssigv4Cred.credentials.accessKeyId, + secretAccessKey: awssigv4Cred.credentials.secretAccessKey, + sessionToken: awssigv4Cred.credentials.sessionToken, + }; + request.region = awssigv4Cred.region; + request.service = awssigv4Cred.service; + delete request['auth']; + } + const signed = aws4.sign(request, credentialsState.credentials); + signed.headers['x-amz-content-sha256'] = crypto + .createHash('sha256') + .update(request.body || '', 'utf8') + .digest('hex'); + return signed; + } + + class AwsSigv4SignerConnection extends Connection { + buildRequestObject(params) { + const request = super.buildRequestObject(params); + return buildSignedRequestObject(request); + } + } + + class AwsSigv4SignerTransport extends Transport { + request(params, options = {}, callback = undefined) { + // options is optional so if options is a function, it's the callback. + if (typeof options === 'function') { + callback = options; + options = {}; + } + + const currentCredentials = credentialsState.credentials; + /** + * For AWS SDK V3 + * Make sure token will expire no earlier than `expiryBufferMs` milliseconds in the future. + */ + const expiryBufferMs = toMs(options.requestTimeout || this.requestTimeout); + + let expired = false; + if (!currentCredentials) { + // Credentials haven't been acquired yet. + expired = true; + } + // AWS SDK V2, needsRefresh should be available. + else if (typeof currentCredentials.needsRefresh === 'function') { + expired = currentCredentials.needsRefresh(); + } + // AWS SDK V2, alternative to needsRefresh. + else if (currentCredentials.expired === true) { + expired = true; + } + // AWS SDK V2, alternative to needsRefresh and expired. + else if (currentCredentials.expireTime && currentCredentials.expireTime < new Date()) { + expired = true; + } + // AWS SDK V3, Credentials.expiration is a Date object + else if ( + currentCredentials.expiration && + currentCredentials.expiration.getTime() - Date.now() < expiryBufferMs + ) { + expired = true; + } + + if (!expired) { + if (typeof callback === 'undefined') { + return super.request(params, options); + } + super.request(params, options, callback); + return; + } + + // In AWS SDK V2 Credentials.refreshPromise should be available. + if (currentCredentials && typeof currentCredentials.refreshPromise === 'function') { + if (typeof callback === 'undefined') { + return currentCredentials.refreshPromise().then(() => { + return super.request(params, options); + }); + } else { + currentCredentials + .refreshPromise() + .then(() => { + super.request(params, options, callback); + }) + .catch(callback); + return; + } + } + + // For AWS SDK V3 or when the client has not acquired credentials yet. + if (typeof callback === 'undefined') { + return opts.getCredentials().then((credentials) => { + credentialsState.credentials = credentials; + return super.request(params, options); + }); + } else { + opts + .getCredentials() + .then((credentials) => { + credentialsState.credentials = credentials; + super.request(params, options, callback); + }) + .catch(callback); + } + } + } + + return { + Transport: AwsSigv4SignerTransport, + Connection: AwsSigv4SignerConnection, + buildSignedRequestObject, + }; + }; +} + +module.exports.giveAwsCredentialProviderLoader = giveAwsCredentialProviderLoader; +module.exports.giveAwsV4Signer = giveAwsV4Signer; diff --git a/package.json b/package.json index 7e48c0c04..aea14efd5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "types": "./api/types.d.ts" }, "./aws": "./lib/aws/index.js", + "./aws-v3": "./lib/aws/index-v3.js", "./*": "./*" }, "typesVersions": { @@ -53,7 +54,7 @@ "test:coverage-ui": "tap test/{unit,acceptance}/{*,**/*,**/**/*}.test.js --coverage --coverage-report=html --nyc-arg=\"--exclude=api\"", "lint": "eslint .", "lint:fix": "eslint . --fix", - "license-checker": "license-checker --production --onlyAllow='MIT;Apache-2.0;Apache1.1;ISC;BSD-3-Clause;BSD-2-Clause'", + "license-checker": "license-checker --production --onlyAllow='MIT;Apache-2.0;Apache1.1;0BSD;ISC;BSD-3-Clause;BSD-2-Clause'", "build-esm": "npx gen-esm-wrapper . index.mjs && eslint --fix index.mjs" }, "author": "opensearch-project", diff --git a/test/bundlers/esbuild-test/aws-v3.js b/test/bundlers/esbuild-test/aws-v3.js new file mode 100644 index 000000000..d78455591 --- /dev/null +++ b/test/bundlers/esbuild-test/aws-v3.js @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ + +'use strict'; + +const { AwsSigv4Signer } = require('../../../lib/aws/index-v3'); +const { Client } = require('../../../index'); + +new Client({ + ...AwsSigv4Signer({ + region: 'us-east-1', + service: 'es', + }), + node: 'https://search-xxx.region.es.amazonaws.com', +}); diff --git a/test/bundlers/esbuild-test/aws.js b/test/bundlers/esbuild-test/aws.js new file mode 100644 index 000000000..cad2c6b21 --- /dev/null +++ b/test/bundlers/esbuild-test/aws.js @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ + +'use strict'; + +const { AwsSigv4Signer } = require('../../../lib/aws/index'); +const { Client } = require('../../../index'); + +new Client({ + ...AwsSigv4Signer({ + region: 'us-east-1', + service: 'es', + }), + node: 'https://search-xxx.region.es.amazonaws.com', +}); diff --git a/test/bundlers/esbuild-test/package.json b/test/bundlers/esbuild-test/package.json new file mode 100644 index 000000000..64f605658 --- /dev/null +++ b/test/bundlers/esbuild-test/package.json @@ -0,0 +1,19 @@ +{ + "name": "esbuild-test-v3", + "version": "1.0.0", + "description": "", + "main": "aws-v3.js", + "scripts": { + "start:aws": "node bundle-aws.js", + "start:awsv3": "node bundle-aws-v3.js", + "build:aws": "./node_modules/esbuild/bin/esbuild aws.js --bundle --platform=node --outfile=./bundle-aws.js --allow-overwrite", + "build:awsv3": "./node_modules/esbuild/bin/esbuild aws-v3.js --bundle --platform=node --outfile=./bundle-aws-v3.js --allow-overwrite" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@aws-sdk/credential-provider-node": "^3.556.0", + "aws-sdk": "^2.1601.0", + "esbuild": "^0.20.2" + } +} diff --git a/test/unit/lib/aws/awssignv4signer-versions.test.js b/test/unit/lib/aws/awssignv4signer-versions.test.js new file mode 100644 index 000000000..e0b37c751 --- /dev/null +++ b/test/unit/lib/aws/awssignv4signer-versions.test.js @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +const { test } = require('tap'); + +const AwsSigv4Signer = require('../../../../lib/aws/AwsSigv4Signer'); +const AwsSigv4SignerV3 = require('../../../../lib/aws/AwsSigv4Signer-sdk-v3'); +const AwsSigv4SignerError = require('../../../../lib/aws/errors'); + +const { Client, buildServer } = require('../../../utils'); + +test('Sign with SigV4 using default getCredentials provider', (t) => { + t.plan(2); + + function handler(req, res) { + res.setHeader('Content-Type', 'application/json;utf=8'); + res.end(JSON.stringify({ hello: 'world' })); + } + + buildServer(handler, ({ port }, server) => { + const mockRegion = 'us-east-1'; + + const AwsSigv4SignerOptions = { + region: mockRegion, + }; + + const client = new Client({ + ...AwsSigv4Signer(AwsSigv4SignerOptions), + node: `http://localhost:${port}`, + }); + + client + .search({ + index: 'test', + q: 'foo:bar', + }) + .catch((err) => { + t.ok(err instanceof AwsSigv4SignerError); + t.same( + err.message, + 'Unable to find a valid AWS SDK, please provide a valid getCredentials function to AwsSigv4Signer options.' + ); + }) + .finally(() => { + server.stop(); + }); + }); +}); + +test('Sign with SigV4 using default getCredentials provider aws sdk v3', (t) => { + t.plan(2); + + function handler(req, res) { + res.setHeader('Content-Type', 'application/json;utf=8'); + res.end(JSON.stringify({ hello: 'world' })); + } + + buildServer(handler, ({ port }, server) => { + const mockRegion = 'us-east-1'; + + const AwsSigv4SignerOptions = { + region: mockRegion, + }; + const client = new Client({ + ...AwsSigv4SignerV3(AwsSigv4SignerOptions), + node: `http://localhost:${port}`, + }); + + client + .search({ + index: 'test', + q: 'foo:bar', + }) + .catch((err) => { + t.ok(err instanceof AwsSigv4SignerError); + t.same( + err.message, + "Missing '@aws-sdk/credential-provider-node' module. Install it as a dependency." + ); + }) + .finally(() => { + server.stop(); + }); + }); +});