diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index f2882e0..e2de4ae 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -15,8 +15,9 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [8.x, 10.x, 12.x, 14.x, 16.x] + node-version: [8.x, 10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 22.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: @@ -27,3 +28,8 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm install - run: npm run ci + + - name: Upload Coverage Report + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} # required diff --git a/lib/roa.js b/lib/roa.js index 050f700..ab44860 100644 --- a/lib/roa.js +++ b/lib/roa.js @@ -114,15 +114,30 @@ class ROAClient { throw new Error(`"config.endpoint" must starts with 'https://' or 'http://'.`); } assert(config.apiVersion, 'must pass "config.apiVersion"'); - assert(config.accessKeyId, 'must pass "config.accessKeyId"'); - assert(config.accessKeySecret, 'must pass "config.accessKeySecret"'); + if (config.credentialsProvider) { + if (typeof config.credentialsProvider.getCredentials !== 'function') { + throw new Error(`must pass "config.credentialsProvider" with function "getCredentials()"`); + } + this.credentialsProvider = config.credentialsProvider; + } else { + assert(config.accessKeyId, 'must pass "config.accessKeyId"'); + assert(config.accessKeySecret, 'must pass "config.accessKeySecret"'); + this.accessKeyId = config.accessKeyId; + this.accessKeySecret = config.accessKeySecret; + this.securityToken = config.securityToken; + this.credentialsProvider = { + getCredentials: async () => { + return { + accessKeyId: config.accessKeyId, + accessKeySecret: config.accessKeySecret, + securityToken: config.securityToken, + }; + } + }; + } this.endpoint = config.endpoint; - this.apiVersion = config.apiVersion; - this.accessKeyId = config.accessKeyId; - this.accessKeySecret = config.accessKeySecret; - this.securityToken = config.securityToken; this.host = url.parse(this.endpoint).hostname; this.opts = config.opts; var httpModule = this.endpoint.startsWith('https://') ? require('https') : require('http'); @@ -163,9 +178,31 @@ class ROAClient { } async request(method, uriPattern, query = {}, body = '', headers = {}, opts = {}) { + const credentials = await this.credentialsProvider.getCredentials(); + + const now = new Date(); + var defaultHeaders = { + accept: 'application/json', + date: now.toGMTString(), + host: this.host, + 'x-acs-signature-nonce': kitx.makeNonce(), + 'x-acs-version': this.apiVersion, + 'user-agent': helper.DEFAULT_UA, + 'x-sdk-client': helper.DEFAULT_CLIENT + }; + if (credentials && credentials.accessKeyId && credentials.accessKeySecret) { + defaultHeaders['x-acs-signature-method'] = 'HMAC-SHA1'; + defaultHeaders['x-acs-signature-version'] = '1.0'; + if (credentials.securityToken) { + defaultHeaders['x-acs-accesskey-id'] = credentials.accessKeyId; + defaultHeaders['x-acs-security-token'] = credentials.securityToken; + } + } + + var mixHeaders = Object.assign(defaultHeaders, keyLowerify(headers)); + var postBody = null; - var mixHeaders = Object.assign(this.buildHeaders(), keyLowerify(headers)); postBody = Buffer.from(body, 'utf8'); mixHeaders['content-md5'] = kitx.md5(postBody, 'base64'); mixHeaders['content-length'] = postBody.length; @@ -175,9 +212,13 @@ class ROAClient { url += `?${querystring.stringify(query)}`; } - const stringToSign = buildStringToSign(method, uriPattern, mixHeaders, query); - debug('stringToSign: %s', stringToSign); - mixHeaders['authorization'] = this.buildAuthorization(stringToSign); + if (credentials && credentials.accessKeyId && credentials.accessKeySecret) { + const stringToSign = buildStringToSign(method, uriPattern, mixHeaders, query); + debug('stringToSign: %s', stringToSign); + const utf8Buff = Buffer.from(stringToSign, 'utf8'); + const signature = kitx.sha1(utf8Buff, credentials.accessKeySecret, 'base64'); + mixHeaders['authorization'] = `acs ${credentials.accessKeyId}:${signature}`; + } const options = Object.assign({ method, diff --git a/lib/rpc.js b/lib/rpc.js index c0702df..bfe58b3 100644 --- a/lib/rpc.js +++ b/lib/rpc.js @@ -111,9 +111,29 @@ class RPCClient { throw new Error(`"config.endpoint" must starts with 'https://' or 'http://'.`); } assert(config.apiVersion, 'must pass "config.apiVersion"'); - assert(config.accessKeyId, 'must pass "config.accessKeyId"'); - var accessKeySecret = config.secretAccessKey || config.accessKeySecret; - assert(accessKeySecret, 'must pass "config.accessKeySecret"'); + if (config.credentialsProvider) { + if (typeof config.credentialsProvider.getCredentials !== 'function') { + throw new Error(`must pass "config.credentialsProvider" with function "getCredentials()"`); + } + this.credentialsProvider = config.credentialsProvider; + } else { + assert(config.accessKeyId, 'must pass "config.accessKeyId"'); + var accessKeySecret = config.secretAccessKey || config.accessKeySecret; + assert(accessKeySecret, 'must pass "config.accessKeySecret"'); + this.accessKeyId = config.accessKeyId; + this.accessKeySecret = accessKeySecret; + this.securityToken = config.securityToken; + this.credentialsProvider = { + getCredentials: async () => { + return { + accessKeyId: config.accessKeyId, + accessKeySecret: accessKeySecret, + securityToken: config.securityToken, + }; + } + }; + } + if (config.endpoint.endsWith('/')) { config.endpoint = config.endpoint.slice(0, -1); @@ -121,9 +141,6 @@ class RPCClient { this.endpoint = config.endpoint; this.apiVersion = config.apiVersion; - this.accessKeyId = config.accessKeyId; - this.accessKeySecret = accessKeySecret; - this.securityToken = config.securityToken; this.verbose = verbose === true; // 非 codes 里的值,将抛出异常 this.codes = new Set([200, '200', 'OK', 'Success', 'success']); @@ -145,6 +162,7 @@ class RPCClient { } async request(action, params = {}, opts = {}) { + const credentials = await this.credentialsProvider.getCredentials(); // 1. compose params and opts opts = Object.assign({ headers: { @@ -164,20 +182,36 @@ class RPCClient { if (opts.formatParams !== false) { params = formatParams(params); } - const defaults = this._buildParams(); - params = Object.assign({Action: action}, defaults, params); + const defaultParams = { + Format: 'JSON', + Timestamp: timestamp(), + Version: this.apiVersion, + }; + if (credentials && credentials.accessKeyId && credentials.accessKeySecret) { + defaultParams.SignatureMethod = 'HMAC-SHA1'; + defaultParams.SignatureVersion = '1.0'; + defaultParams.SignatureNonce = kitx.makeNonce(); + defaultParams.AccessKeyId = credentials.accessKeyId; + if (credentials.securityToken) { + defaultParams.SecurityToken = credentials.securityToken; + } + } + params = Object.assign({ Action: action }, defaultParams, params); - // 2. caculate signature const method = (opts.method || 'GET').toUpperCase(); const normalized = normalize(params); - const canonicalized = canonicalize(normalized); - // 2.1 get string to sign - const stringToSign = `${method}&${encode('/')}&${encode(canonicalized)}`; - // 2.2 get signature - const key = this.accessKeySecret + '&'; - const signature = kitx.sha1(stringToSign, key, 'base64'); - // add signature - normalized.push(['Signature', encode(signature)]); + // 2. caculate signature + if (credentials && credentials.accessKeyId && credentials.accessKeySecret) { + const canonicalized = canonicalize(normalized); + // 2.1 get string to sign + const stringToSign = `${method}&${encode('/')}&${encode(canonicalized)}`; + // 2.2 get signature + const key = credentials.accessKeySecret + '&'; + const signature = kitx.sha1(stringToSign, key, 'base64'); + // add signature + normalized.push(['Signature', encode(signature)]); + } + // 3. generate final url const url = opts.method === 'POST' ? `${this.endpoint}/` : `${this.endpoint}/?${canonicalize(normalized)}`; // 4. send request diff --git a/package.json b/package.json index 12bc5b8..31bea1a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test": "mocha -R spec test/*.test.js", "test-cov": "nyc -r=html -r=text -r=lcov mocha -t 3000 -R spec test/*.test.js", "test-integration": "mocha -R spec test/*.integration.js", - "ci": "npm run lint && npm run test-cov && codecov" + "ci": "npm run lint && npm run test-cov" }, "keywords": [ "Aliyun", @@ -33,7 +33,6 @@ "index.js" ], "devDependencies": { - "codecov": "^3.0.4", "eslint": "^6.6.0", "expect.js": "^0.3.1", "mocha": "^4", diff --git a/test/roa.test.js b/test/roa.test.js index b6c6c70..e7f9b8b 100644 --- a/test/roa.test.js +++ b/test/roa.test.js @@ -44,6 +44,13 @@ describe('roa core', function () { apiVersion: '1.0' }); }).to.throwException(/must pass "config\.accessKeyId"/); + expect(function () { + new ROAClient({ + endpoint: 'http://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: null + }); + }).to.throwException(/must pass "config\.accessKeyId"/); }); it('should pass into "config.accessKeySecret"', function () { @@ -56,6 +63,19 @@ describe('roa core', function () { }).to.throwException(/must pass "config\.accessKeySecret"/); }); + it('should pass into "config.credentialsProvider" with getCredentials()', function () { + expect(function () { + new ROAClient({ + endpoint: 'http://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: { + accessKeyId: 'test', + accessKeySecret: 'test', + } + }); + }).to.throwException(/must pass "config\.credentialsProvider" with function "getCredentials\(\)"/); + }); + it('should ok with http protocol', function () { const client = new ROAClient({ endpoint: 'http://ecs.aliyuncs.com/', @@ -184,6 +204,25 @@ describe('roa core', function () { const result = await client.get('/', {}, {}, { rawBody: true }); expect(result).to.be('raw body'); }); + + it('request with credentialsProvider should ok', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + const result = await client.request('GET', '/', {}, '', {}, { rawBody: true }); + expect(result).to.be('raw body'); + }); }); describe('request with json response should ok', function () { @@ -207,6 +246,27 @@ describe('roa core', function () { ok: true }); }); + + it('json response with credentialsProvider should ok', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + const result = await client.request('GET', '/'); + expect(result).to.be.eql({ + ok: true + }); + }); }); describe('request(204) with json response should ok', function () { @@ -228,6 +288,25 @@ describe('roa core', function () { const result = await client.request('GET', '/'); expect(result).to.be(''); }); + + it('json response with credentialsProvider should ok', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + const result = await client.request('GET', '/'); + expect(result).to.be(''); + }); }); describe('request(400) with json response should ok', function () { @@ -262,6 +341,34 @@ describe('roa core', function () { // should never be executed expect(false).to.be.ok(); }); + + it('json response with credentialsProvider should ok', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + try { + await client.request('GET', '/'); + } catch (ex) { + expect(ex.message).to.be('code: 400, error message, requestid: requestid'); + expect(ex.name).to.be('errorcodeError'); + expect(ex.statusCode).to.be(400); + expect(ex.code).to.be('errorcode'); + return; + } + // should never be executed + expect(false).to.be.ok(); + }); }); describe('request(400) with json response and errorMsg should ok', function () { @@ -295,6 +402,34 @@ describe('roa core', function () { // should never be executed expect(false).to.be.ok(); }); + + it('json response with credentialsProvider should ok', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + try { + await client.request('GET', '/'); + } catch (ex) { + expect(ex.message).to.be('code: 400, RAM/STS verification error, requestid: '); + expect(ex.name).to.be('10007Error'); + expect(ex.statusCode).to.be(400); + expect(ex.code).to.be(10007); + return; + } + // should never be executed + expect(false).to.be.ok(); + }); }); describe('request with unexpect json string response should ok', function () { @@ -323,6 +458,32 @@ describe('roa core', function () { // should never be executed expect(false).to.be.ok(); }); + + it('json response with credentialsProvider should ok', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + try { + await client.request('GET', '/'); + } catch (ex) { + expect(ex.message).to.be('parse response to json error'); + expect(ex.name).to.be('FormatError'); + return; + } + // should never be executed + expect(false).to.be.ok(); + }); }); describe('request with xml response should ok', function () { @@ -438,6 +599,25 @@ describe('roa core', function () { expect(result).to.be.eql({ 'ok': true }); }); + it('should ok with credentialsProvider', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + const result = await client.post('/', {}, 'text', {}, {}); + expect(result).to.be.eql({ 'ok': true }); + }); + it('should ok with query', async function () { const client = new ROAClient({ endpoint: 'https://ecs.aliyuncs.com/', @@ -470,6 +650,25 @@ describe('roa core', function () { const result = await client.put('/', {}, 'text', {}, {}); expect(result).to.be.eql({ 'ok': true }); }); + + it('should ok with credentialsProvider', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + const result = await client.put('/', {}, 'text', {}, {}); + expect(result).to.be.eql({ 'ok': true }); + }); }); describe('delete should ok', function () { @@ -491,6 +690,25 @@ describe('roa core', function () { const result = await client.delete('/', {}, {}, {}); expect(result).to.be.eql({ 'ok': true }); }); + + it('should ok with credentialsProvider', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + securityToken: 'securityToken', + }; + } + }; + const client = new ROAClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + const result = await client.delete('/', {}, {}, {}); + expect(result).to.be.eql({ 'ok': true }); + }); }); describe('ROA private methods', function () { diff --git a/test/rpc.test.js b/test/rpc.test.js index 4db23e0..c482823 100644 --- a/test/rpc.test.js +++ b/test/rpc.test.js @@ -44,6 +44,13 @@ describe('rpc core', function () { apiVersion: '1.0' }); }).to.throwException(/must pass "config\.accessKeyId"/); + expect(function () { + new RPCClient({ + endpoint: 'http://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: null + }); + }).to.throwException(/must pass "config\.accessKeyId"/); }); it('should pass into "config.accessKeySecret"', function () { @@ -56,6 +63,19 @@ describe('rpc core', function () { }).to.throwException(/must pass "config\.accessKeySecret"/); }); + it('should pass into "config.credentialsProvider" with getCredentials()', function () { + expect(function () { + new RPCClient({ + endpoint: 'http://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: { + accessKeyId: 'test', + accessKeySecret: 'test', + } + }); + }).to.throwException(/must pass "config\.credentialsProvider" with function "getCredentials\(\)"/); + }); + it('should ok with http endpoint', function () { const client = new RPCClient({ endpoint: 'http://ecs.aliyuncs.com', @@ -155,6 +175,24 @@ describe('rpc core', function () { const result = await client.request('action', {}); expect(result).to.be.eql({}); }); + + it('get with credentialsProvider should ok', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + }; + } + }; + const client = new RPCClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + const result = await client.request('action', {}); + expect(result).to.be.eql({}); + }); }); describe('request with post', function () { @@ -177,6 +215,24 @@ describe('rpc core', function () { expect(result).to.be.eql({}); }); + it('should ok with credentialsProvider', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + }; + } + }; + const client = new RPCClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + const result = await client.request('action'); + expect(result).to.be.eql({}); + }); + it('should ok with formatAction', async function () { const client = new RPCClient({ endpoint: 'https://ecs.aliyuncs.com/', @@ -203,7 +259,7 @@ describe('rpc core', function () { expect(result).to.be.eql({}); }); - it('should ok with formatParams', async function () { + it('should ok with keepAlive', async function () { const client = new RPCClient({ endpoint: 'https://ecs.aliyuncs.com/', apiVersion: '1.0', @@ -277,6 +333,30 @@ describe('rpc core', function () { // should never be executed expect(false).to.be.ok(); }); + + it('request with 400 should ok', async function () { + const provider = { + getCredentials: async () => { + return { + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + }; + } + }; + const client = new RPCClient({ + endpoint: 'https://ecs.aliyuncs.com/', + apiVersion: '1.0', + credentialsProvider: provider + }); + try { + await client.request('action', {}); + } catch (ex) { + expect(ex.message.startsWith('error message, URL: ')).to.be.ok(); + return; + } + // should never be executed + expect(false).to.be.ok(); + }); }); describe('RPC private methods', function () {