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

Commit 4019721

Browse files
authored
Merge pull request #403 from cloudant/feature-token-manager
Feature Branch: Add Token Manager
2 parents 05b5c7d + a6c6dd8 commit 4019721

22 files changed

+634
-374
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
temporary.
66
- [FIXED] Ensure IAM API key can be correctly changed.
77
- [FIXED] Callback with an error when a user cannot be authenticated using IAM.
8+
- [FIXED] Ensure authorization tokens are not unnecessarily requested.
9+
- [IMPROVED] Preemptively renew authorization tokens that are due to expire.
810

911
# 4.2.1 (2019-08-29)
1012
- [FIXED] Include all built-in plugin modules in webpack bundle.

Jenkinsfile

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!groovy
2-
// Copyright © 2017 IBM Corp. All rights reserved.
2+
// Copyright © 2017, 2019 IBM Corp. All rights reserved.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -101,16 +101,14 @@ stage('Publish') {
101101

102102
// Upload using the NPM creds
103103
withCredentials([string(credentialsId: 'npm-mail', variable: 'NPM_EMAIL'),
104-
usernamePassword(credentialsId: 'npm-creds', passwordVariable: 'NPM_PASS', usernameVariable: 'NPM_USER')]) {
104+
usernamePassword(credentialsId: 'npm-creds', passwordVariable: 'NPM_TOKEN', usernameVariable: 'NPM_USER')]) {
105105
// Actions:
106-
// 1. add the build ID to any snapshot version for uniqueness
107-
// 2. install login helper
108-
// 3. login to npm, using environment variables specified above
109-
// 4. publish the build to NPM adding a snapshot tag if pre-release
106+
// 1. create .npmrc file for publishing
107+
// 2. add the build ID to any snapshot version for uniqueness
108+
// 3. publish the build to NPM adding a snapshot tag if pre-release
110109
sh """
110+
echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}' > .npmrc
111111
${isReleaseVersion ? '' : ('npm version --no-git-tag-version ' + version + '.' + env.BUILD_ID)}
112-
npm install --no-save npm-cli-login
113-
./node_modules/.bin/npm-cli-login
114112
npm publish ${isReleaseVersion ? '' : '--tag snapshot'}
115113
"""
116114
}

README.md

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,11 @@ var cloudant = Cloudant({ url: myurl, maxAttempt: 5, plugins: [ { iamauth: { iam
392392
This plugin will automatically exchange your IAM API key for a token. It will
393393
handle the authentication and ensure that the token is refreshed as required.
394394
395+
For example:
396+
```js
397+
var cloudant = Cloudant({ url: 'https://examples.cloudant.com', plugins: { iamauth: { iamApiKey: 'xxxxxxxxxx' } } });
398+
```
399+
395400
The production IAM token service at https://iam.cloud.ibm.com/identity/token is
396401
used by default. You can set `iamTokenUrl` in your plugin configuration to
397402
override this. To authenticate with the IAM token service set `iamClientId`
@@ -403,22 +408,6 @@ var cloudant = Cloudant({ url: myurl, maxAttempt: 5, plugins: [ { iamauth: { iam
403408
client request. It also increases the number of token exchange attempts and
404409
therefore may result in rate limiting by the IAM token service.
405410
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-
For example:
418-
```js
419-
var cloudant = Cloudant({ url: 'https://examples.cloudant.com', plugins: { iamauth: { iamApiKey: 'xxxxxxxxxx', retryDelayMultiplier: 4, retryInitialDelayMsecs: 100 } } });
420-
```
421-
422411
If the IAM token cannot be retrieved after the configured number of retries
423412
(either because the IAM token service is down or the IAM API key is
424413
incorrect) then an error is returned to the client.

lib/client.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,10 @@ class CloudantClient {
129129
debug(`Not adding duplicate plugin: '${Plugin.id}'`);
130130
} else {
131131
debug(`Adding plugin: '${Plugin.id}'`);
132+
var creds = self._cfg.creds || {};
132133
self._plugins.push(
133134
// instantiate plugin
134-
new Plugin(self._client, Object.assign({}, cfg))
135+
new Plugin(self._client, Object.assign({ serverUrl: creds.outUrl }, cfg))
135136
);
136137
self._pluginIds.push(Plugin.id);
137138
}

lib/tokens/CookieTokenManager.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright © 2019 IBM Corp. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
'use strict';
15+
16+
const debug = require('debug')('cloudant:tokens:cookietokenmanager');
17+
const TokenManager = require('./TokenManager');
18+
19+
class CookieTokenManager extends TokenManager {
20+
constructor(client, jar, sessionUrl, username, password) {
21+
super(client, jar, sessionUrl);
22+
23+
this._username = username;
24+
this._password = password;
25+
}
26+
27+
_getToken(callback) {
28+
debug('Submitting cookie token request.');
29+
this._client({
30+
url: this._sessionUrl,
31+
method: 'POST',
32+
form: {
33+
name: this._username,
34+
password: this._password
35+
},
36+
jar: this._jar
37+
}, (error, response, body) => {
38+
if (error) {
39+
debug(error);
40+
callback(error);
41+
} else if (response.statusCode === 200) {
42+
debug('Successfully renewed session cookie.');
43+
callback(null, response);
44+
} else {
45+
let msg = `Failed to get cookie. Status code: ${response.statusCode}`;
46+
debug(msg);
47+
callback(new Error(msg), response);
48+
}
49+
});
50+
}
51+
}
52+
53+
module.exports = CookieTokenManager;

lib/tokens/IamTokenManager.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright © 2019 IBM Corp. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
'use strict';
15+
16+
const a = require('async');
17+
const debug = require('debug')('cloudant:tokens:iamtokenmanager');
18+
const TokenManager = require('./TokenManager');
19+
20+
class IAMTokenManager extends TokenManager {
21+
constructor(client, jar, sessionUrl, iamTokenUrl, iamApiKey, iamClientId, iamClientSecret) {
22+
super(client, jar, sessionUrl);
23+
24+
this._iamTokenUrl = iamTokenUrl;
25+
this._iamApiKey = iamApiKey;
26+
this._iamClientId = iamClientId;
27+
this._iamClientSecret = iamClientSecret;
28+
}
29+
30+
_getToken(done) {
31+
var self = this;
32+
33+
debug('Making IAM session request.');
34+
let accessToken;
35+
a.series([
36+
(callback) => {
37+
let accessTokenAuth;
38+
if (self._iamClientId && self._iamClientSecret) {
39+
accessTokenAuth = { user: self._iamClientId, pass: self._iamClientSecret };
40+
}
41+
debug('Getting access token.');
42+
self._client({
43+
url: self._iamTokenUrl,
44+
method: 'POST',
45+
auth: accessTokenAuth,
46+
headers: { 'Accepts': 'application/json' },
47+
form: {
48+
'grant_type': 'urn:ibm:params:oauth:grant-type:apikey',
49+
'response_type': 'cloud_iam',
50+
'apikey': self._iamApiKey
51+
},
52+
json: true
53+
}, (error, response, body) => {
54+
if (error) {
55+
callback(error);
56+
} else if (response.statusCode === 200) {
57+
if (body.access_token) {
58+
accessToken = body.access_token;
59+
debug('Retrieved access token from IAM token service.');
60+
callback(null, response);
61+
} else {
62+
callback(new Error('Invalid response from IAM token service'), response);
63+
}
64+
} else {
65+
let msg = `Failed to acquire access token. Status code: ${response.statusCode}`;
66+
callback(new Error(msg), response);
67+
}
68+
});
69+
},
70+
(callback) => {
71+
debug('Perform IAM cookie based user login.');
72+
self._client({
73+
url: self._sessionUrl,
74+
method: 'POST',
75+
form: { 'access_token': accessToken },
76+
jar: self._jar,
77+
json: true
78+
}, (error, response, body) => {
79+
if (error) {
80+
callback(error);
81+
} else if (response.statusCode === 200) {
82+
debug('Successfully renewed IAM session.');
83+
callback(null, response);
84+
} else {
85+
let msg = `Failed to exchange IAM token with Cloudant. Status code: ${response.statusCode}`;
86+
callback(new Error(msg), response);
87+
}
88+
});
89+
}
90+
], (error, responses) => {
91+
done(error, responses[responses.length - 1]);
92+
});
93+
}
94+
95+
setIamApiKey(newIamApiKey) {
96+
this._iamApiKey = newIamApiKey;
97+
this.attemptTokenRenewal = true;
98+
}
99+
}
100+
101+
module.exports = IAMTokenManager;

lib/tokens/TokenManager.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright © 2019 IBM Corp. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
'use strict';
15+
16+
const debug = require('debug')('cloudant:tokens:tokenmanager');
17+
const cookie = require('cookie');
18+
const EventEmitter = require('events');
19+
20+
class TokenManager {
21+
constructor(client, jar, sessionUrl) {
22+
this._client = client;
23+
this._jar = jar;
24+
this._sessionUrl = sessionUrl;
25+
26+
this._attemptTokenRenewal = true;
27+
this._isTokenRenewing = false;
28+
29+
this._tokenExchangeEE = new EventEmitter().setMaxListeners(Infinity);
30+
}
31+
32+
_autoRenew(defaultMaxAgeSecs) {
33+
debug('Auto renewing token now...');
34+
this._renew().then((response) => {
35+
let maxAgeSecs = cookie.parse(response.headers['set-cookie'][0])['Max-Age'] || defaultMaxAgeSecs;
36+
let delayMSecs = maxAgeSecs / 2 * 1000;
37+
debug(`Renewing token in ${delayMSecs} milliseconds.`);
38+
setTimeout(this._autoRenew.bind(this), delayMSecs).unref();
39+
}).catch((error) => {
40+
debug(`Failed to auto renew token - ${error}. Retrying in 60 seconds.`);
41+
setTimeout(this._autoRenew.bind(this), 60000).unref();
42+
});
43+
}
44+
45+
_getToken(callback) {
46+
// ** Method to be implemented by _all_ subclasses of `TokenManager` **
47+
throw new Error('Not implemented.');
48+
}
49+
50+
// Renew the token.
51+
_renew() {
52+
if (!this._isTokenRenewing) {
53+
this._isTokenRenewing = true;
54+
this._tokenExchangeEE.removeAllListeners();
55+
debug('Starting token renewal.');
56+
this._getToken((error, response) => {
57+
if (error) {
58+
this._tokenExchangeEE.emit('error', error, response);
59+
} else {
60+
this._tokenExchangeEE.emit('success', response);
61+
this._attemptTokenRenewal = false;
62+
}
63+
debug('Finished token renewal.');
64+
this._isTokenRenewing = false;
65+
});
66+
}
67+
return new Promise((resolve, reject) => {
68+
this._tokenExchangeEE.once('success', resolve);
69+
this._tokenExchangeEE.once('error', (error, response) => {
70+
error.response = response;
71+
reject(error);
72+
});
73+
});
74+
}
75+
76+
// Getter for `attemptTokenRenewal`.
77+
// - `true` A renewal attempt will be made on the next `renew` request.
78+
// - `false` No renewal attempt will be made on the next `renew`
79+
// request. Instead the last good renewal response will be returned
80+
// to the client.
81+
get attemptTokenRenewal() {
82+
return this._attemptTokenRenewal;
83+
}
84+
85+
// Getter for `isTokenRenewing`.
86+
// - `true` A renewal attempt is in progress.
87+
// - `false` There are no in progress renewal attempts.
88+
get isTokenRenewing() {
89+
return this._isTokenRenewing;
90+
}
91+
92+
// Settter for `attemptTokenRenewal`.
93+
set attemptTokenRenewal(newAttemptTokenRenewal) {
94+
this._attemptTokenRenewal = newAttemptTokenRenewal;
95+
}
96+
97+
// Renew the token if `attemptTokenRenewal` is `true`. Otherwise this is a
98+
// no-op so just resolve the promise.
99+
renewIfRequired() {
100+
if (this._attemptTokenRenewal) {
101+
return this._renew();
102+
} else {
103+
return new Promise((resolve) => {
104+
resolve();
105+
});
106+
}
107+
}
108+
109+
// Start the auto renewal timer.
110+
startAutoRenew(defaultMaxAgeSecs) {
111+
this._autoRenew(defaultMaxAgeSecs || 3600);
112+
}
113+
}
114+
115+
module.exports = TokenManager;

package-lock.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@types/request": "^2.47.0",
2727
"async": "2.1.2",
2828
"concat-stream": "^1.6.0",
29+
"cookie": "^0.4.0",
2930
"debug": "^3.1.0",
3031
"lockfile": "1.0.3",
3132
"nano": "^8.1.0",

0 commit comments

Comments
 (0)