-
Notifications
You must be signed in to change notification settings - Fork 5
/
index.js
265 lines (245 loc) · 8.04 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
const needle = require('needle')
const qs = require('querystring')
module.exports = class TokenManager {
/**
* IAM Token Manager Service
*
* Retreives, stores, and refreshes IAM tokens.
*
* @param {Object} options
* @param {String} options.iamApikey
* @param {String} options.iamUrl - url of the iam api to retrieve tokens from
* @constructor
*/
constructor (options) {
this.tokenInfo = {}
// while a token is being loaded, this promise will be defined, yet unsettled
this.tokenLoadingPromise = undefined
this.iamUrl = options.iamUrl || 'https://iam.cloud.ibm.com/identity/token'
if (options.iamApikey) {
this.iamApikey = options.iamApikey
} else {
throw new Error('Missing iamApikey parameter.')
}
}
// wraps a promise with a promise that supports timing out after a given amount of
// milliseconds. Wrapping promise throws, when the timeout occurs.
getPromiseWithExpiration (prom, timeout) {
let timeoutHandle
return new Promise((resolve, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error('Promise timed out after ' + timeout + ' milliseconds.'))
}, timeout)
prom.then((result) => {
clearTimeout(timeoutHandle)
resolve(result)
})
.catch((error) => {
clearTimeout(timeoutHandle)
reject(error)
})
})
}
/**
* This function sends an access token back through a Promise. The source of the token
* is determined by the following logic:
* 1. If the token is expired (that is, we already have one, but it is no longer valid, or about to time out), we
* load a new one
* 2. If the token is not expired, we obviously have a valid token, so just resolve with it's value
* 3. If we haven't got a token at all, but a loading is already in process, we wait for the loading promise to settle
* and depending on the result
* 3a) use the newly returned and cached token
* 3b) in case of error, trigger a fresh loading attempt
* 4. If there is no token available and also no loading in progress, trigger the token loading
*
* @returns {Promise} - resolved with token value
*/
getToken () {
return new Promise((resolve, reject) => {
const loadToken = () => {
this.loadToken()
.then(() => {
resolve(this.tokenInfo.access_token)
})
.catch(error => reject(error))
}
if (this.isTokenExpired()) {
// 1. load a new token
loadToken()
} else if (this.tokenInfo.access_token) {
// 2. return the cached valid token
resolve(this.tokenInfo.access_token)
} else if (this.tokenLoadingPromise) {
// 3. a token loading operation is already running
this.tokenLoadingPromise
.then(() => {
// 3a) it was successful, so return the fresh token
resolve(this.tokenInfo.access_token)
})
.catch(() => {
// 3b) give it one more try - obviously, we hoped for a Promise triggered by another invocation to
// return the token for us, but it didn't work out. So we need to trigger another attempt.
loadToken()
})
} else {
// 4. just trigger the token loading
loadToken()
}
})
}
/**
* This function returns the Authorization header value including the token
* @returns {Promise}
*/
getAuthHeader () {
return this.getToken().then(token => {
return `Bearer ${token}`
})
}
/**
* Triggers the remote IAM API token call, saves the response and resolves the loading promise
* with the access_token
*
* @returns {Promise}
*/
loadToken () {
// reset buffered tokenInfo, as we're about to load a new token
this.tokenInfo = {}
// let other callers know that we're currently loading a new token
this.tokenLoadingPromise = this.requestToken().then(tokenResponse => {
this.saveTokenInfo(tokenResponse)
return this.tokenInfo.access_token
})
return this.tokenLoadingPromise
}
/**
* Request an IAM token using an API key and IAM URL.
*
* @private
* @returns {Promise}
*/
requestToken () {
const options = {
url: this.iamUrl,
method: 'POST',
headers: {
'Content-type': 'application/x-www-form-urlencoded',
Authorization: 'Basic Yng6Yng='
},
form: {
grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
apikey: this.iamApikey
}
}
return this.sendRequest(options)
}
/**
* Refresh an IAM token using a refresh token.
*
* @private
* @returns {Promise}
*/
refreshToken () {
const options = {
url: this.iamUrl,
method: 'POST',
headers: {
'Content-type': 'application/x-www-form-urlencoded',
Authorization: 'Basic Yng6Yng='
},
form: {
grant_type: 'refresh_token',
refresh_token: this.tokenInfo.refresh_token
}
}
return this.sendRequest(options)
}
/**
* Check if currently stored token is expired.
*
* Using a buffer to prevent the edge case of the
* token expiring before the request could be made.
*
* The buffer will be a fraction of the total TTL. Using 80%.
*
* @private
* @returns {boolean}
*/
isTokenExpired () {
// the token cannot be considered expired, if we don't have one (yet)
if (!this.tokenInfo || !this.tokenInfo.access_token) {
return false
}
if (!this.tokenInfo.expires_in || !this.tokenInfo.expiration) {
return true
}
const fractionOfTtl = 0.8
const timeToLive = this.tokenInfo.expires_in
const expireTime = this.tokenInfo.expiration
const currentTime = Math.floor(Date.now() / 1000)
const refreshTime = expireTime - (timeToLive * (1.0 - fractionOfTtl))
return refreshTime < currentTime
}
/**
* Used as a fail-safe to prevent the condition of a refresh token expiring,
* which could happen after around 30 days. This function will return true
* if it has been at least 7 days and 1 hour since the last token was
* retrieved.
*
* @private
* @returns {boolean}
*/
isRefreshTokenExpired () {
if (!this.tokenInfo.expiration) {
return true
}
const sevenDays = 7 * 24 * 3600
const currentTime = Math.floor(Date.now() / 1000)
const newTokenTime = this.tokenInfo.expiration + sevenDays
return newTokenTime < currentTime
}
/**
* Save the response from the IAM service request to the object's state.
*
* @param {IamTokenData} tokenResponse - Response object from IAM service request
* @private
* @returns {void}
*/
saveTokenInfo (tokenResponse) {
this.tokenInfo = tokenResponse
}
/**
* Creates the request.
* @param options - method, url, form
* @private
* @returns {Promise}
*/
sendRequest (options) {
options.response_timeout = 60000 // 1 minute max. response time allowed
options.read_timeout = 30000 // 30 seconds read time limit after headers were received
const needleProm = needle(options.method.toLowerCase(),
options.url,
options.body || qs.stringify(options.form),
options)
.then(resp => {
if (resp.statusCode >= 400) {
// we turn >=400 statusCode responses into exceptions
const error = new Error(resp.body.error || resp.statusMessage)
error.statusCode = resp.statusCode // the http status code
error.error = resp.body // the error body
error.options = options
if (typeof error.error === 'object') {
error.error.error = error.error.errorMessage
}
throw error // this will trigger the next available catch() in the Promise chain
} else {
// otherwise, the response body is the expected return value
return resp.body
}
})
// wrap the actual loading promise which allows the HTTP request to take 60 seconds
// with a promise that times out after 90 seconds, so we don't get stuck with an
// unsettled promise
return this.getPromiseWithExpiration(needleProm, 90000)
}
}