diff --git a/README.md b/README.md index 608dcae..d2a02d8 100644 --- a/README.md +++ b/README.md @@ -251,10 +251,10 @@ const vault = require('node-vault')({ }); ``` -The `requestOptions` object is passed through to the underlying HTTP library -([postman-request](https://www.npmjs.com/package/postman-request)) for every request. You can -use it to configure any supported request option, including `agentOptions`, custom `headers`, -or a custom `agent`. +The `requestOptions` object supports TLS/SSL options (`ca`, `cert`, `key`, `passphrase`, +`agentOptions`, `strictSSL`) as well as `timeout`, `httpsAgent`, and `httpAgent`. TLS options +are mapped to an `https.Agent` and applied to every request. You can also pass native +[axios](https://axios-http.com/) request options such as custom `headers`. You can also pass request options per-call to any method: diff --git a/example/auth.js b/example/auth.js index 03b8149..96d32ed 100644 --- a/example/auth.js +++ b/example/auth.js @@ -4,11 +4,9 @@ process.env.DEBUG = 'node-vault'; // switch on debug mode const vault = require('./../src/index')(); -const options = { - requestOptions: { - followAllRedirects: true, - }, -}; +// Note: axios follows redirects by default (up to 5). +// Use maxRedirects in requestOptions to customise this behaviour. +const options = {}; vault.auths(options) .then(console.log) diff --git a/example/pass_request_options.js b/example/pass_request_options.js index c48b1a1..7452570 100644 --- a/example/pass_request_options.js +++ b/example/pass_request_options.js @@ -3,7 +3,8 @@ process.env.DEBUG = 'node-vault'; // switch on debug mode // Pass request options at initialization time. -// These options are forwarded to postman-request for every request. +// TLS options (ca, cert, key, passphrase, agentOptions) are mapped to an +// https.Agent and forwarded to axios for every request. const vault = require('./../src/index')({ requestOptions: { agentOptions: { diff --git a/index.d.ts b/index.d.ts index 9c24d5b..0a0d2fa 100644 --- a/index.d.ts +++ b/index.d.ts @@ -11,6 +11,18 @@ declare namespace NodeVault { [p: string]: any; } + /** Backward-compatible TLS options from the former request/postman-request API. */ + interface TlsOptions { + ca?: string | Buffer | Array; + cert?: string | Buffer | Array; + key?: string | Buffer | Array; + passphrase?: string; + pfx?: string | Buffer | Array; + strictSSL?: boolean; + agentOptions?: { [p: string]: any }; + timeout?: number; + } + interface RequestOption extends Option { path: string; method: string; @@ -149,7 +161,7 @@ declare namespace NodeVault { noCustomHTTPVerbs?: boolean; pathPrefix?: string; token?: string; - requestOptions?: AxiosRequestConfig; + requestOptions?: AxiosRequestConfig & TlsOptions; } } diff --git a/package-lock.json b/package-lock.json index 49a5ccc..65f791e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "node-vault", - "version": "0.10.10", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "node-vault", - "version": "0.10.10", + "version": "0.11.0", "license": "MIT", "dependencies": { "axios": "^1.13.6", diff --git a/package.json b/package.json index cd9dce7..dfd6e7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-vault", - "version": "0.11.0", + "version": "0.11.1", "description": "Javascript client for HashiCorp's Vault", "main": "./src/index.js", "scripts": { diff --git a/src/index.js b/src/index.js index fd3d43d..923b7ba 100644 --- a/src/index.js +++ b/src/index.js @@ -43,18 +43,60 @@ module.exports = (config = {}) => { if (config['request-promise']) return config['request-promise'].defaults(rpDefaults); - const httpsAgent = rpDefaults.strictSSL === false - ? new https.Agent({ rejectUnauthorized: false }) + const baseAgentOptions = {}; + if (rpDefaults.strictSSL === false) { + baseAgentOptions.rejectUnauthorized = false; + } + + // Properties that map to https.Agent options for backward compatibility + // with the former postman-request / request library API. + const tlsOptionKeys = ['ca', 'cert', 'key', 'passphrase', 'pfx']; + + // Build the default agent from base options + config.requestOptions TLS + // settings so the common case (no per-call overrides) reuses one agent. + const configReqOpts = config.requestOptions || {}; + const defaultAgentOpts = { ...baseAgentOptions }; + let hasDefaultTls = Object.keys(baseAgentOptions).length > 0; + + tlsOptionKeys.forEach((prop) => { + if (configReqOpts[prop] !== undefined) { + defaultAgentOpts[prop] = configReqOpts[prop]; + hasDefaultTls = true; + } + }); + if (configReqOpts.agentOptions !== undefined) { + Object.assign(defaultAgentOpts, configReqOpts.agentOptions); + hasDefaultTls = true; + } + if (configReqOpts.strictSSL !== undefined) { + defaultAgentOpts.rejectUnauthorized = configReqOpts.strictSSL !== false; + hasDefaultTls = true; + } + + const defaultHttpsAgent = hasDefaultTls + ? new https.Agent(defaultAgentOpts) : undefined; const instance = axios.create({ // Accept all HTTP status codes (equivalent to request's simple: false) // so that vault response handling logic can process non-2xx responses. validateStatus: () => true, - ...(httpsAgent ? { httpsAgent } : {}), + ...(defaultHttpsAgent ? { httpsAgent: defaultHttpsAgent } : {}), ...(rpDefaults.timeout ? { timeout: rpDefaults.timeout } : {}), }); + // Snapshot config-level TLS references so we can detect per-call overrides. + const configTlsSnapshot = {}; + tlsOptionKeys.forEach((prop) => { + if (configReqOpts[prop] !== undefined) configTlsSnapshot[prop] = configReqOpts[prop]; + }); + if (configReqOpts.agentOptions !== undefined) { + configTlsSnapshot.agentOptions = configReqOpts.agentOptions; + } + if (configReqOpts.strictSSL !== undefined) { + configTlsSnapshot.strictSSL = configReqOpts.strictSSL; + } + return function requestWrapper(options) { const axiosOptions = { method: options.method, @@ -66,6 +108,46 @@ module.exports = (config = {}) => { axiosOptions.data = options.json; } + // Forward axios-native options when provided directly. + if (options.timeout !== undefined) { + axiosOptions.timeout = options.timeout; + } + if (options.httpAgent !== undefined) { + axiosOptions.httpAgent = options.httpAgent; + } + + // Only create a per-request httpsAgent when per-call TLS options + // differ from the config defaults already baked into the instance. + let hasOverride = false; + const perRequestAgentOpts = {}; + + tlsOptionKeys.forEach((prop) => { + if (options[prop] !== undefined) { + perRequestAgentOpts[prop] = options[prop]; + if (options[prop] !== configTlsSnapshot[prop]) hasOverride = true; + } + }); + + if (options.agentOptions !== undefined) { + Object.assign(perRequestAgentOpts, options.agentOptions); + if (options.agentOptions !== configTlsSnapshot.agentOptions) hasOverride = true; + } + + if (options.strictSSL !== undefined) { + perRequestAgentOpts.rejectUnauthorized = options.strictSSL !== false; + if (options.strictSSL !== configTlsSnapshot.strictSSL) hasOverride = true; + } + + if (hasOverride) { + axiosOptions.httpsAgent = new https.Agent({ + ...defaultAgentOpts, + ...perRequestAgentOpts, + }); + } else if (options.httpsAgent !== undefined) { + // Allow passing a pre-built httpsAgent directly (e.g. for proxies). + axiosOptions.httpsAgent = options.httpsAgent; + } + return instance(axiosOptions).then((response) => { let requestPath; try { diff --git a/test/unit.js b/test/unit.js index a43e90f..e214419 100644 --- a/test/unit.js +++ b/test/unit.js @@ -856,4 +856,179 @@ describe('node-vault', () => { }); }); }); + + describe('axios TLS options forwarding', () => { + const https = require('https'); + const axios = require('axios'); + let axiosInstanceStub; + let axiosCreateStub; + let agentSpy; + + beforeEach(() => { + // Stub axios.create to return a controllable instance stub + axiosInstanceStub = sinon.stub().resolves({ + status: 200, + data: {}, + }); + axiosCreateStub = sinon.stub(axios, 'create').returns(axiosInstanceStub); + agentSpy = sinon.spy(https, 'Agent'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should create default httpsAgent with ca option from config.requestOptions', () => { + index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + ca: 'my-custom-ca-cert', + }, + }); + agentSpy.should.have.been.called(); + const agentArgs = agentSpy.lastCall.args[0]; + expect(agentArgs).to.have.property('ca', 'my-custom-ca-cert'); + expect(axiosCreateStub.lastCall.args[0]).to.have.property('httpsAgent'); + }); + + it('should create default httpsAgent with cert and key from config.requestOptions', () => { + index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + cert: 'client-cert', + key: 'client-key', + passphrase: 'secret', + }, + }); + agentSpy.should.have.been.called(); + const agentArgs = agentSpy.lastCall.args[0]; + expect(agentArgs).to.have.property('cert', 'client-cert'); + expect(agentArgs).to.have.property('key', 'client-key'); + expect(agentArgs).to.have.property('passphrase', 'secret'); + }); + + it('should create default httpsAgent from agentOptions in config.requestOptions', () => { + index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + agentOptions: { + securityOptions: 'SSL_OP_NO_SSLv3', + cert: 'agent-cert', + }, + }, + }); + agentSpy.should.have.been.called(); + const agentArgs = agentSpy.lastCall.args[0]; + expect(agentArgs).to.have.property('securityOptions', 'SSL_OP_NO_SSLv3'); + expect(agentArgs).to.have.property('cert', 'agent-cert'); + }); + + it('should allow per-call TLS options to override config.requestOptions', () => { + const vault = index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + ca: 'default-ca', + }, + }); + return vault.read('secret/hello', { ca: 'override-ca' }).then(() => { + // Per-call override creates a new per-request agent + const axiosCallArg = axiosInstanceStub.firstCall.args[0]; + expect(axiosCallArg).to.have.property('httpsAgent'); + expect(axiosCallArg.httpsAgent).to.be.an.instanceOf(https.Agent); + // The last agent created should have the override value + const agentArgs = agentSpy.lastCall.args[0]; + expect(agentArgs).to.have.property('ca', 'override-ca'); + }); + }); + + it('should not create per-request httpsAgent when no TLS options are present', () => { + const vault = index({ + endpoint: 'http://localhost:8200', + token: '123', + }); + return vault.read('secret/hello').then(() => { + const axiosCallArg = axiosInstanceStub.firstCall.args[0]; + expect(axiosCallArg).to.not.have.property('httpsAgent'); + }); + }); + + it('should reuse default httpsAgent when config TLS options are unchanged', () => { + const vault = index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + ca: 'my-ca', + }, + }); + const agentCountAfterInit = agentSpy.callCount; + return vault.read('secret/hello').then(() => { + // No new agent should be created for request with same config options + const axiosCallArg = axiosInstanceStub.firstCall.args[0]; + expect(axiosCallArg).to.not.have.property('httpsAgent'); + expect(agentSpy.callCount).to.equal(agentCountAfterInit); + }); + }); + + it('should handle strictSSL: false in requestOptions', () => { + index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + strictSSL: false, + }, + }); + agentSpy.should.have.been.called(); + const agentArgs = agentSpy.lastCall.args[0]; + expect(agentArgs).to.have.property('rejectUnauthorized', false); + }); + + it('should forward timeout from requestOptions to axios', () => { + const vault = index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + timeout: 5000, + }, + }); + return vault.read('secret/hello').then(() => { + const axiosCallArg = axiosInstanceStub.firstCall.args[0]; + expect(axiosCallArg).to.have.property('timeout', 5000); + }); + }); + + it('should forward httpsAgent from requestOptions to axios', () => { + const customAgent = new https.Agent({ keepAlive: true }); + const vault = index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + httpsAgent: customAgent, + }, + }); + return vault.read('secret/hello').then(() => { + const axiosCallArg = axiosInstanceStub.firstCall.args[0]; + expect(axiosCallArg).to.have.property('httpsAgent', customAgent); + }); + }); + + it('should forward httpAgent from requestOptions to axios', () => { + const http = require('http'); + const customAgent = new http.Agent(); + const vault = index({ + endpoint: 'http://localhost:8200', + token: '123', + requestOptions: { + httpAgent: customAgent, + }, + }); + return vault.read('secret/hello').then(() => { + const axiosCallArg = axiosInstanceStub.firstCall.args[0]; + expect(axiosCallArg).to.have.property('httpAgent', customAgent); + }); + }); + }); });