diff --git a/lib/requester.js b/lib/requester.js index 0dec0d99..f4111650 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -47,6 +47,18 @@ function getAuthenticatorKerberos(client) { function getAccessToken(operation){ const postData = `${encodeURI('grant_type')}=${encodeURI('apikey')}&${encodeURI('key')}=${encodeURI(operation.client.connectionParams.apiKey)}`; const path = operation.client.connectionParams.accessTokenDuration?'/token?duration='+encodeURIComponent(operation.client.connectionParams.accessTokenDuration):'/token'; + function handleTokenError(error, contextMessage) { + if (!operation.lockAccessToken) { + return; + } + + operation.lockAccessToken = false; + + const baseMessage = (contextMessage == null) ? 'Failed to obtain access token' : contextMessage; + const errorMessage = (error && error.message) ? `${baseMessage}: ${error.message}` : baseMessage; + operation.errorListener(new Error(errorMessage)); + } + const options = { hostname: operation.client.connectionParams.host, port: 443, @@ -58,26 +70,51 @@ function getAccessToken(operation){ }; const req = https.request(options, (res) => { + let responseBody = ''; + res.on('data', (d) => { + responseBody += d.toString(); + }); + + res.on('end', () => { if(res.statusCode === 400){ - throw new Error(d.toString()); + handleTokenError(null, 'Token endpoint returned 400: ' + responseBody); + return; } - const responseValue = JSON.parse(d.toString()); - operation.accessToken = responseValue.access_token; - operation.expiration = new Date(responseValue['.expires']); - if(operation.lockAccessToken){ + + try { + const responseValue = JSON.parse(responseBody); + if (!responseValue.access_token || !responseValue['.expires']) { + throw new Error('missing required token fields'); + } + + const expiration = new Date(responseValue['.expires']); + if (Number.isNaN(expiration.getTime())) { + throw new Error('invalid .expires value'); + } + + operation.accessToken = responseValue.access_token; + operation.expiration = expiration; operation.lockAccessToken = false; + } catch (error) { + handleTokenError(error, 'Invalid token endpoint response'); + return; } + authenticatedRequest(operation); }); + res.on('error', (e) => { - operation.accessToken = new Error(e); - throw new Error(operation.accessToken); + handleTokenError(e, 'Failed to obtain access token'); }); }); - req.write(postData); - req.end(); + req.on('error', (e) => { + handleTokenError(e, 'Failed to obtain access token'); + }); + + req.write(postData); + req.end(); } function startRequest(operation) { @@ -296,14 +333,10 @@ function authenticatedRequest(operation) { break; case 'CLOUD': operation.logger.debug('cloud authentication'); - if(operation.accessToken instanceof Error){ - throw new Error(operation.accessToken); - } else { - request.setHeader( - 'authorization', - 'bearer ' +operation.accessToken - ); - } + request.setHeader( + 'authorization', + 'bearer ' + operation.accessToken + ); break; case 'OAUTH': request.setHeader( diff --git a/test-basic/cloud_authentication-test.js b/test-basic/cloud_authentication-test.js index 08098460..d6527a51 100644 --- a/test-basic/cloud_authentication-test.js +++ b/test-basic/cloud_authentication-test.js @@ -7,6 +7,7 @@ let assert = require('assert'); const testconfig = require("../etc/test-config"); const mlutil = require("../lib/mlutil"); const expect = require('chai').expect; +const EventEmitter = require('events'); describe('cloud-authentication tests', function() { it('should throw error without apiKey.', function(done){ @@ -99,4 +100,90 @@ describe('cloud-authentication tests', function() { 'Error message should mention accessTokenDuration validation'); } }); + + it('should route token request network errors to operation.errorListener', function(done) { + this.timeout(500); + const requester = require('../lib/requester'); + const https = require('https'); + const originalRequest = https.request; + + const operation = { + client: { + connectionParams: { + host: 'example.marklogic.cloud', + apiKey: 'test-key' + } + }, + lockAccessToken: true, + errorListener: (error) => { + https.request = originalRequest; + try { + assert(error instanceof Error); + assert(error.message.includes('Failed to obtain access token')); + assert(error.message.includes('ECONNRESET')); + assert.strictEqual(operation.lockAccessToken, false); + done(); + } catch (assertionError) { + done(assertionError); + } + } + }; + + https.request = () => { + const req = new EventEmitter(); + req.write = () => {}; + req.end = () => { + process.nextTick(() => req.emit('error', new Error('ECONNRESET'))); + }; + return req; + }; + + requester.getAccessToken(operation); + }); + + it('should route token endpoint 400 responses to operation.errorListener', function(done) { + this.timeout(500); + const requester = require('../lib/requester'); + const https = require('https'); + const originalRequest = https.request; + + const operation = { + client: { + connectionParams: { + host: 'example.marklogic.cloud', + apiKey: 'test-key' + } + }, + lockAccessToken: true, + errorListener: (error) => { + https.request = originalRequest; + try { + assert(error instanceof Error); + assert(error.message.includes('Token endpoint returned 400')); + assert(error.message.includes('invalid key payload')); + done(); + } catch (assertionError) { + done(assertionError); + } + } + }; + + https.request = (options, callback) => { + const req = new EventEmitter(); + req.write = () => {}; + req.end = () => { + process.nextTick(() => { + const res = new EventEmitter(); + res.statusCode = 400; + callback(res); + res.emit('data', Buffer.from('invalid key ')); + res.emit('data', Buffer.from('payload')); + res.emit('end'); + }); + }; + return req; + }; + + requester.getAccessToken(operation); + }); });