Skip to content
This repository was archived by the owner on Mar 11, 2022. It is now read-only.

Commit 0892a98

Browse files
authored
Merge pull request #386 from cloudant/385-retry-iam-token-request
Retry bad IAM token requests.
2 parents 0148fff + 66810db commit 0892a98

File tree

6 files changed

+201
-50
lines changed

6 files changed

+201
-50
lines changed

CHANGES.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
# 4.1.2 (2019-07-29)
2-
- [FIXED] Plugins can now be loaded from outside of the 'plugins/' directory
1+
# UNRELEASED
2+
- [NEW] Added option to set new IAM API key.
3+
- [FIXED] Allow plugins to be loaded from outside the 'plugins/' directory.
4+
- [FIXED] Retry bad IAM token requests.
35

46
# 4.1.1 (2019-06-17)
57
- [FIXED] Remove unnecessary `npm-cli-login` dependency.

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ All other configuration is plugin specific. It must be passed within an object
355355
to the `plugins` parameter in the client constructor. For example:
356356

357357
```js
358-
var cloudant = new Cloudant({ url: myurl, maxAttempt: 5, plugins: [ 'iamauth', { retry: { retryDelayMultiplier: 4 } } ]);
358+
var cloudant = new Cloudant({ url: myurl, maxAttempt: 5, plugins: [ { iamauth: { iamApiKey: 'abcxyz' } }, { retry: { retryDelayMultiplier: 4 } } ]);
359359
```
360360
361361
`maxAttempt` can _not_ be overridden by plugin specific configuration.
@@ -397,9 +397,31 @@ var cloudant = new Cloudant({ url: myurl, maxAttempt: 5, plugins: [ 'iamauth', {
397397
override this. To authenticate with the IAM token service set `iamClientId`
398398
and `iamClientSecret` in your plugin configuration.
399399
400+
The plugin will retry failed requests to the token service (specifically
401+
`429` and `5xx` responses) until the number of retry requests reaches
402+
`maxAttempt`. Be aware that retrying requests to the token service delays the
403+
client request. It also increases the number of token exchange attempts and
404+
therefore may result in rate limiting by the IAM token service.
405+
406+
The retry behavior can be configured using the following options:
407+
408+
- `retryDelayMultiplier`
409+
410+
The multiplication factor used for increasing the timeout after each
411+
subsequent attempt _(default: 2)_.
412+
413+
- `retryInitialDelayMsecs`
414+
415+
The initial retry delay in milliseconds _(default: 500)_.
416+
417+
If the IAM token cannot be retrieved (either because the IAM token service is
418+
down or the IAM API key is incorrect) then the request will continue without
419+
IAM authentication allowing the database to return a `401` response to the
420+
caller so that it may be handled appropriately.
421+
400422
For example:
401423
```js
402-
var cloudant = new Cloudant({ url: 'https://examples.cloudant.com', plugins: { iamauth: { iamApiKey: 'xxxxxxxxxx' } } });
424+
var cloudant = new Cloudant({ url: 'https://examples.cloudant.com', plugins: { iamauth: { iamApiKey: 'xxxxxxxxxx', retryDelayMultiplier: 4 } } });
403425
```
404426
405427
See [IBM Cloud Identity and Access Management](https://console.bluemix.net/docs/services/Cloudant/guides/iam.html#ibm-cloud-identity-and-access-management) for more information.

lib/client.js

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright © 2017, 2018 IBM Corp. All rights reserved.
1+
// Copyright © 2017, 2019 IBM Corp. All rights reserved.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -233,6 +233,15 @@ class CloudantClient {
233233

234234
// public
235235

236+
/**
237+
* Get a client plugin instance.
238+
*
239+
* @param {string} pluginId
240+
*/
241+
getPlugin(pluginId) {
242+
return this._plugins[this._pluginIds.indexOf(pluginId)];
243+
}
244+
236245
/**
237246
* Perform a request using this Cloudant client.
238247
*
@@ -312,21 +321,19 @@ class CloudantClient {
312321
request.state.sending = false;
313322

314323
debug(`Request attempt: ${request.state.attempt}`);
315-
316-
utils.runHooks('onRequest', request, request.options, function(err) {
317-
utils.processState(request, function(stop) {
318-
if (request.state.retry) {
319-
debug('The onRequest hook issued retry.');
320-
return done();
321-
}
322-
if (stop) {
323-
debug(`The onRequest hook issued abort: ${stop}`);
324-
return done(stop);
325-
}
326-
327-
debug(`Delaying request for ${request.state.retryDelayMsecs} Msecs.`);
328-
329-
setTimeout(function() {
324+
debug(`Delaying request for ${request.state.retryDelayMsecs} Msecs.`);
325+
326+
setTimeout(function() {
327+
utils.runHooks('onRequest', request, request.options, function(err) {
328+
utils.processState(request, function(stop) {
329+
if (request.state.retry) {
330+
debug('The onRequest hook issued retry.');
331+
return done();
332+
}
333+
if (stop) {
334+
debug(`The onRequest hook issued abort: ${stop}`);
335+
return done(stop);
336+
}
330337
if (request.abort) {
331338
debug('Client issued abort during plugin execution.');
332339
return done(new Error('Client issued abort'));
@@ -354,9 +361,9 @@ class CloudantClient {
354361
.pipe(concatStream);
355362
}
356363
}
357-
}, request.state.retryDelayMsecs);
364+
});
358365
});
359-
});
366+
}, request.state.retryDelayMsecs);
360367
}, function(err) { debug(err.message); });
361368

362369
return request.clientStream; // return stream to client

plugins/iamauth.js

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,33 +25,33 @@ const BasePlugin = require('./base.js');
2525
*/
2626
class IAMPlugin extends BasePlugin {
2727
constructor(client, cfg) {
28-
super(client, cfg);
28+
if (typeof cfg.iamApiKey === 'undefined') {
29+
throw new Error('Missing IAM API key from configuration');
30+
}
2931

30-
var self = this;
31-
self.iamApiKey = null;
32-
self.baseUrl = cfg.baseUrl || null;
33-
self.cookieJar = request.jar();
34-
self.tokenUrl = cfg.iamTokenUrl || 'https://iam.cloud.ibm.com/identity/token';
32+
// token service retry configuration
33+
cfg = Object.assign({
34+
retryDelayMultiplier: 2,
35+
retryInitialDelayMsecs: 500
36+
}, cfg);
3537

36-
// Specifies whether IAM authentication should be applied to the request being intercepted.
37-
self.shouldApplyIAMAuth = true;
38-
self.refreshRequired = true;
38+
super(client, cfg);
3939

40-
if (typeof cfg.iamApiKey === 'undefined') {
41-
debug('Missing IAM API key. Skipping IAM authentication.');
42-
self.shouldApplyIAMAuth = false;
43-
}
40+
this.currentIamApiKey = null;
41+
this.baseUrl = cfg.baseUrl || null;
42+
this.cookieJar = null;
43+
this.tokenUrl = cfg.iamTokenUrl || 'https://iam.cloud.ibm.com/identity/token';
44+
this.shouldApplyIAMAuth = true;
45+
this.refreshRequired = true;
4446
}
4547

4648
onRequest(state, req, callback) {
4749
var self = this;
4850

49-
if (typeof self._cfg.iamApiKey === 'undefined') {
50-
throw new Error('Missing IAM API key from configuration');
51-
}
52-
53-
if (self._cfg.iamApiKey !== self.iamApiKey) {
54-
debug('New credentials identified. Renewing session cookie...');
51+
if (self._cfg.iamApiKey !== self.currentIamApiKey) {
52+
debug('New IAM API key identified.');
53+
this.cookieJar = request.jar(); // new jar
54+
self.currentIamApiKey = self._cfg.iamApiKey;
5555
self.shouldApplyIAMAuth = self.refreshRequired = true;
5656
}
5757

@@ -85,6 +85,13 @@ class IAMPlugin extends BasePlugin {
8585
debug(error.message);
8686
if (self.shouldApplyIAMAuth) {
8787
state.retry = true;
88+
if (state.attempt === 1) {
89+
state.retryDelayMsecs = self._cfg.retryInitialDelayMsecs;
90+
} else {
91+
state.retryDelayMsecs *= self._cfg.retryDelayMultiplier;
92+
}
93+
} else {
94+
console.warn(`Disabling IAM authentication: ${error.message}`);
8895
}
8996
} else {
9097
req.jar = self.cookieJar; // add jar
@@ -153,8 +160,10 @@ class IAMPlugin extends BasePlugin {
153160
callback(new Error('Invalid response from IAM token service'));
154161
}
155162
} else {
156-
self.shouldApplyIAMAuth = false;
157-
callback(new Error('Failed to access token'));
163+
if (response.statusCode < 500 && response.statusCode !== 429) {
164+
self.shouldApplyIAMAuth = false; // no retry
165+
}
166+
callback(new Error(`Failed to acquire access token. Status code: ${response.statusCode}`));
158167
}
159168
});
160169
},
@@ -170,13 +179,14 @@ class IAMPlugin extends BasePlugin {
170179
if (error) {
171180
callback(error);
172181
} else if (response.statusCode === 200) {
173-
self.iamApiKey = cfg.iamApiKey;
174182
self.refreshRequired = false;
175183
debug('Successfully renewed IAM session.');
176184
callback();
177185
} else {
178-
self.shouldApplyIAMAuth = false;
179-
callback(new Error('Failed to exchange IAM token with Cloudant'));
186+
if (response.statusCode < 500) {
187+
self.shouldApplyIAMAuth = false; // no retry
188+
}
189+
callback(new Error(`Failed to exchange IAM token with Cloudant. Status code: ${response.statusCode}`));
180190
}
181191
});
182192
}
@@ -187,6 +197,11 @@ class IAMPlugin extends BasePlugin {
187197
});
188198
});
189199
}
200+
201+
setIamApiKey(iamApiKey) {
202+
debug('Setting new IAM API key.');
203+
this._cfg.iamApiKey = iamApiKey;
204+
}
190205
}
191206

192207
IAMPlugin.id = 'iamauth';

test/plugins/iamauth.js

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ describe('#db IAMAuth Plugin', function() {
353353
'response_type': 'cloud_iam',
354354
'apikey': IAM_API_KEY
355355
})
356+
.times(3)
356357
.reply(500, 'Internal Error 500\nThe server encountered an unexpected condition which prevented it from fulfilling the request.');
357358

358359
var cloudantMocks = nock(SERVER)
@@ -418,10 +419,12 @@ describe('#db IAMAuth Plugin', function() {
418419
'response_type': 'cloud_iam',
419420
'apikey': IAM_API_KEY
420421
})
422+
.times(3)
421423
.reply(200, MOCK_IAM_TOKEN_RESPONSE);
422424

423425
var cloudantMocks = nock(SERVER)
424426
.post('/_iam_session', {access_token: MOCK_ACCESS_TOKEN})
427+
.times(3)
425428
.reply(500, {error: 'internal_server_error', reason: 'Internal Server Error'})
426429
.get(DBNAME)
427430
.reply(401, {error: 'unauthorized', reason: 'Unauthorized'});
@@ -510,10 +513,10 @@ describe('#db IAMAuth Plugin', function() {
510513
});
511514

512515
it('throws error for unspecified IAM API key', function() {
513-
var cloudantClient = new Client({ plugins: 'iamauth' });
514516
assert.throws(
515517
() => {
516-
cloudantClient.request({ url: SERVER + DBNAME });
518+
/* eslint-disable no-new */
519+
new Client({ plugins: 'iamauth' });
517520
},
518521
/Missing IAM API key from configuration/,
519522
'did not throw with expected message'
@@ -554,4 +557,105 @@ describe('#db IAMAuth Plugin', function() {
554557
assert.fail(`Unexpected reject: ${err}`);
555558
});
556559
});
560+
561+
it('successfully retries request on 500 IAM token service response and returns 200 response', function(done) {
562+
if (process.env.NOCK_OFF) {
563+
this.skip();
564+
}
565+
566+
var iamMocks = nock(TOKEN_SERVER)
567+
.post('/identity/token', {
568+
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
569+
'response_type': 'cloud_iam',
570+
'apikey': IAM_API_KEY
571+
})
572+
.times(2)
573+
.reply(500, {error: 'internal_server_error', reason: 'Internal Server Error'})
574+
.post('/identity/token', {
575+
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
576+
'response_type': 'cloud_iam',
577+
'apikey': IAM_API_KEY
578+
})
579+
.reply(200, MOCK_IAM_TOKEN_RESPONSE);
580+
581+
var cloudantMocks = nock(SERVER)
582+
.post('/_iam_session', {access_token: MOCK_ACCESS_TOKEN})
583+
.reply(200, {ok: true}, MOCK_SET_IAM_SESSION_HEADER)
584+
.get(DBNAME)
585+
.reply(200, {doc_count: 0});
586+
587+
var cloudantClient = new Client({ maxAttempt: 3, plugins: { iamauth: { iamApiKey: IAM_API_KEY } } });
588+
var req = { url: SERVER + DBNAME, method: 'GET' };
589+
590+
var startTs = (new Date()).getTime();
591+
592+
cloudantClient.request(req, function(err, resp, data) {
593+
assert.equal(err, null);
594+
assert.equal(resp.request.headers.cookie, MOCK_IAM_SESSION);
595+
assert.equal(resp.statusCode, 200);
596+
assert.ok(data.indexOf('"doc_count":0') > -1);
597+
598+
// validate retry delay
599+
var now = (new Date()).getTime();
600+
assert.ok(now - startTs > (500 + 1000));
601+
602+
iamMocks.done();
603+
cloudantMocks.done();
604+
done();
605+
});
606+
});
607+
608+
it('supports changing the IAM API key', function(done) {
609+
if (process.env.NOCK_OFF) {
610+
this.skip();
611+
}
612+
613+
var iamMocks = nock(TOKEN_SERVER)
614+
.post('/identity/token', {
615+
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
616+
'response_type': 'cloud_iam',
617+
'apikey': 'bad_key'
618+
})
619+
.reply(400, {
620+
errorCode: 'BXNIM0415E',
621+
errorMessage: 'Provided API key could not be found'
622+
})
623+
.post('/identity/token', {
624+
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
625+
'response_type': 'cloud_iam',
626+
'apikey': IAM_API_KEY
627+
})
628+
.reply(200, MOCK_IAM_TOKEN_RESPONSE);
629+
630+
var cloudantMocks = nock(SERVER)
631+
.get(DBNAME)
632+
.reply(401, {error: 'unauthorized', reason: 'Unauthorized'})
633+
.post('/_iam_session', {access_token: MOCK_ACCESS_TOKEN})
634+
.reply(200, {ok: true}, MOCK_SET_IAM_SESSION_HEADER)
635+
.get(DBNAME)
636+
.reply(200, {doc_count: 0});
637+
638+
var cloudantClient = new Client({ plugins: { iamauth: { iamApiKey: 'bad_key' } } });
639+
var req = { url: SERVER + DBNAME, method: 'GET' };
640+
cloudantClient.request(req, function(err, resp, data) {
641+
assert.equal(err, null);
642+
assert.equal(resp.request.headers.cookie, null);
643+
assert.equal(resp.statusCode, 401);
644+
assert.ok(data.indexOf('"error":"unauthorized"') > -1);
645+
646+
// update IAM API key
647+
cloudantClient.getPlugin('iamauth').setIamApiKey(IAM_API_KEY);
648+
649+
cloudantClient.request(req, function(err, resp, data) {
650+
assert.equal(err, null);
651+
assert.equal(resp.request.headers.cookie, MOCK_IAM_SESSION);
652+
assert.equal(resp.statusCode, 200);
653+
assert.ok(data.indexOf('"doc_count":0') > -1);
654+
655+
iamMocks.done();
656+
cloudantMocks.done();
657+
done();
658+
});
659+
});
660+
});
557661
});

test/readmeexamples.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright © 2018 IBM Corp. All rights reserved.
1+
// Copyright © 2018, 2019 IBM Corp. All rights reserved.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -260,10 +260,11 @@ describe('#db README Examples', function() {
260260
var cloudant = new Cloudant({
261261
url: `https://${ME}.cloudant.com`,
262262
maxAttempt: 5,
263-
plugins: [ 'iamauth', { retry: { retryDelayMultiplier: 4 } } ]
263+
plugins: [ { iamauth: { iamApiKey: 'abcxyz' } }, { retry: { retryDelayMultiplier: 4 } } ]
264264
});
265265
assert.equal(cloudant.cc._plugins.length, 2);
266266
assert.equal(cloudant.cc._plugins[0].constructor.name, 'IAMPlugin');
267+
assert.equal(cloudant.cc._plugins[0]._cfg.iamApiKey, 'abcxyz');
267268
assert.equal(cloudant.cc._plugins[1].constructor.name, 'RetryPlugin');
268269
assert.equal(cloudant.cc._plugins[1]._cfg.retryDelayMultiplier, 4);
269270
});

0 commit comments

Comments
 (0)