UNPKG

14 kBJavaScriptView Raw
1const fs = require('fs');
2const path = require('path');
3const { promisify } = require('util');
4const handlers = require('./handlers');
5const { status, States } = require('./status');
6
7const readFileAsync = promisify(fs.readFile);
8const statAsync = promisify(fs.stat);
9
10const REFRESH_POLICY_BEFOREEXPIRY = 'beforeexpiry';
11const REFRESH_POLICY_ONEXPIRY = 'onexpiry';
12const REFRESH_POLICY_PERIODIC = 'periodic';
13const DEFAULT_BEFOREEXPIRY_SECONDS = 60;
14const DEFAULT_PERIODIC_SECONDS = 3600;
15
16const RETRY_INTERVAL = 15000;
17
18// Asynchronously load the authorization templates
19const authTemplates = {};
20Object.keys(handlers).forEach(handler => {
21 authTemplates[`${handler}Callback`] = loadTemplate(`${handler}Callback`, '');
22 authTemplates[`${handler}Error`] = loadTemplate(`${handler}Error`, '');
23});
24
25/**
26 * Load the authorization templates required by the middleware.
27 * @param {string} template The name of the template file to load.
28 * @param {string} defText The default if the file does not exist.
29 * @return {Promise} A promise that resolves with the template content.
30 */
31function loadTemplate(template, defText) {
32 const templatePath = path.join(__dirname, 'template', `${template}.html`);
33 return statAsync(templatePath)
34 .then(() => readFileAsync(templatePath))
35 .then((buf) => buf.toString())
36 .catch(() => Promise.resolve(defText));
37}
38
39/**
40 * @module axway-flow-authorization
41 */
42/**
43* Singleton wrapper.
44* @private
45*/
46class Singleton {
47 static declare(key, Clazz) {
48 const globalSymbols = Object.getOwnPropertySymbols(global);
49 const symbol = Symbol.for(key);
50 const exists = (globalSymbols.indexOf(symbol) > -1);
51 // no dupes
52 /* istanbul ignore else */
53 if (!exists) {
54 global[symbol] = new Clazz();
55 }
56 Object.defineProperty(Clazz, 'instance', {
57 get: () => {
58 return global[symbol];
59 }
60 });
61 Object.freeze(Clazz);
62 }
63
64 static get(key) {
65 const globalSymbols = Object.getOwnPropertySymbols(global);
66 const symbol = Symbol.for(key);
67 const exists = (globalSymbols.indexOf(symbol) > -1);
68
69 if (!exists) {
70 throw new Error(`failed to find singleton: ${key}`);
71 }
72 return global[symbol];
73 }
74}
75
76/**
77* The credential manager service.
78* Manages credentials and attempts to keep them evergreen.
79*
80* Shape of an OAuth2 credential:
81* onedrive: {
82* access_token: <currentaccess token>,
83* type: 'oauth2',
84* token_url: <token url>,
85* client_id: <client id>,
86* client_secret: <client secret>,
87* redirect_uri: <redirect uri>,
88* refresh_token: <refresh token>
89* }
90*
91* Shape of apikey/unmanaged auth:
92* myservice: 'mykey'
93*
94* Shape of basic auth:
95* myservice: {
96* username: 'username',
97* password: 'password'
98* }
99*
100* @public
101* @class
102*/
103class CredentialManager {
104 /**
105 * Constructs a CredentialManager. The instance is a singleton.
106 * @private
107 */
108 constructor() {
109 this.credentials = {};
110 this.scheduler = {};
111 this.middleware = this.middleware.bind(this);
112 }
113
114 /**
115 * Set the logger.
116 * @param {object} logger The logger.
117 * @public
118 */
119 setLogger(logger) {
120 this.logger = logger;
121 }
122
123 /**
124 * Return the managed credential config.
125 * @param {string} name The credential name to get.
126 * @returns {Obejct} The managed credential config.
127 * @public
128 */
129 getCredentialConfig(name) {
130 if (!this.credentials.hasOwnProperty(name)) {
131 return;
132 }
133 return JSON.parse(JSON.stringify(this.credentials[name]));
134 }
135
136 /**
137 * Return all the managed credential configs.
138 * @returns {Array} A list of the managed credential configs.
139 * @public
140 */
141 getCredentialConfigs() {
142 return Object.keys(this.credentials)
143 .map(name => this.getCredentialConfig(name))
144 .reduce((credConfigs, credConfig) => {
145 credConfigs[credConfig.name] = credConfig;
146 return credConfigs;
147 }, {});
148 }
149
150 /**
151 * Returns the named credential.
152 * @param {string} name The name of the credential to retrieve.
153 * @returns {*} The named credential or null if no credential exists.
154 * @public
155 */
156 getCredential(name) {
157 const cred = this.credentials.hasOwnProperty(name) && this.credentials[name];
158 if (cred && ((!cred.expiry) || cred.expiry >= Date.now())) {
159 if (cred.type && handlers[cred.type] && handlers[cred.type].token) {
160 return handlers[cred.type].token(cred);
161 } else {
162 // Untyped bare token
163 return cred.token;
164 }
165 }
166 // Credential not found/expired
167 return null;
168 }
169
170 /**
171 * Remove all managed credentials and stop all schedules.
172 * @public
173 */
174 clear() {
175 Object.values(this.scheduler).forEach((timeout) => {
176 timeout.expiry && clearTimeout(timeout.expiry);
177 timeout.refresh && clearTimeout(timeout.refresh);
178 });
179 this.scheduler = {};
180 this.credentials = {};
181 }
182
183 /**
184 * Initialize the config for the service. If the config contains a `credentials` section
185 * those credentials will be added to the CredentialManager.
186 *
187 * @param {object} config The credential manager config.
188 * @param {object|string} config.credentials The credential to managa.
189 * @public
190 */
191 set config(config) {
192 const { credentials, ...options } = config;
193 this.options = options || {};
194 this.clear();
195 Object.entries(credentials || {}).forEach(([ name, credential ]) => {
196 if (typeof credential === 'string') {
197 credential = {
198 name,
199 token: credential
200 };
201 } else if (typeof credential === 'object') {
202 credential = JSON.parse(JSON.stringify(credential));
203 credential.name = name;
204 } else {
205 this.logger && this.logger
206 .error(`Credential ${name} is not a supported type ${typeof credential}`);
207 credential = null;
208 }
209 credential && this.addCredential(credential);
210 });
211 }
212
213 /**
214 * Add a credential to be managed.
215 * @param {object} credential The credential to manage.
216 * @param {string} credential.name The name of the credential.
217 * @param {long} credential.expiry The expiration time of the current credential.
218 * @param {string} credential.type The type of the credential.
219 * @param {*} credential.token The current credential value (e.g. access token for oauth).
220 * @param {string} credential.refreshPolicy The refresh policy of the credential.
221 *
222 * @public
223 */
224 addCredential(credential) {
225 this.logger && this.logger.debug(`Adding credential: ${credential.name}`);
226 credential.status = status.ok(); // Unless proven otherwise.
227
228 // Allow type specific credential initialization (e.g. allow oauth to
229 // determine the status of the credential config).
230 if (handlers[credential.type]) {
231 const { initCredential } = handlers[credential.type];
232 initCredential && initCredential(this.options, credential);
233 }
234
235 // Clear any schedules for the credential if overwriting an existing credential
236 if (this.scheduler[credential.name]) {
237 const { refresh, expiry } = this.scheduler[credential.name];
238 expiry && clearTimeout(expiry);
239 refresh && clearTimeout(refresh);
240 }
241 this.scheduler[credential.name] = {};
242 this.credentials[credential.name] = credential;
243
244 // Schedule the status update on credential expiry
245 this.scheduleExpiry(credential);
246
247 // Refresh the credential to ensure we have valid credential
248 this.refresh(credential);
249 }
250
251 /**
252 * Remove the named credential from the store.
253 * @param {string} name The name of the credential to remove.
254 */
255 removeCredential(name) {
256 this.logger && this.logger.debug(`Removing credential: ${name}`);
257 if (this.scheduler && this.scheduler[name]) {
258 const { expiry, refresh } = this.scheduler[name];
259 expiry && clearTimeout(expiry);
260 refresh && clearTimeout(refresh);
261 }
262 delete this.scheduler[name];
263 delete this.credentials[name];
264 }
265
266 /**
267 * Is the credential refreshable.
268 *
269 * @param {object} credential The credential to check refreshability on.
270 * @returns {boolean} true if the credential supports refresh, false otherwise.
271 * @public
272 */
273 isRefreshableCredential(credential) {
274 let refreshable = false;
275 if (credential && credential.type && handlers[credential.type]
276 && handlers[credential.type].refresh && handlers[credential.type].refreshable) {
277 refreshable = handlers[credential.type].refreshable(credential);
278 }
279 return refreshable;
280 }
281
282 /**
283 * Refresh the credential and schedule the next refresh.
284 * @param {object} credential The credential to refresh.
285 * @private
286 */
287 refresh(credential) {
288 if (!this.isRefreshableCredential(credential)) {
289 return;
290 }
291
292 this.logger && this.logger.debug(`Credential: ${credential.name} refreshing`);
293 handlers[credential.type].refresh(credential).then(
294 (newCred) => {
295 this.credentials[newCred.name] = newCred;
296 this.scheduleExpiry(newCred);
297
298 if (this.isRefreshableCredential(newCred)) {
299 const next = this.scheduleNextRefresh(newCred);
300 this.logger && this.logger
301 .debug(`Credential: ${newCred.name} refreshed. Next refresh in ${next}ms.`);
302 } else {
303 this.logger && this.logger.debug(`Credential: ${newCred.name} refreshed.`);
304 }
305 },
306 (err) => {
307 if (!this.credentials[credential.name]) {
308 // Credential was already removed, so we don't care about any errors
309 // and should not try to refresh it again either.
310 return;
311 }
312 // TODO: This probably needs exponential back off and the ability to identify
313 // terminal errors.
314 credential.status = status(credential.status, {
315 action: States.action.refreshError
316 });
317 const next = this.scheduleNextRefresh(
318 this.credentials[credential.name], RETRY_INTERVAL);
319 this.logger && this.logger
320 .error(`Credential: ${credential.name} refresh failed. Next refresh in ${next}ms. ${err ? (err.message || err) : ''}`);
321 }
322 ).catch((err) => {
323 this.logger && this.logger
324 .error(`Credential: ${credential.name} scheduled refresh failed. ${err.message || err}`);
325 });
326 }
327
328 /**
329 * Schedule the next refresh of the credential.
330 *
331 * @param {object} credential The credential to schedule refresh for.
332 * @param {number} wait The number of ms to schedule retry in, if not set then
333 * it is calculated from the credential's refresh policy.
334 * @returns {number} The number of milliseconds until the next refresh.
335 * @private
336 */
337 scheduleNextRefresh(credential, wait = -1) {
338 if (wait < 0) {
339 if (!credential.refreshPolicy
340 || credential.refreshPolicy === REFRESH_POLICY_BEFOREEXPIRY) {
341 if (credential.expiry) {
342 // Refresh N seconds before expiry
343 const now = Date.now();
344 const offset = (credential.refreshOffset
345 || DEFAULT_BEFOREEXPIRY_SECONDS) * 1000;
346 wait = ((credential.expiry - offset) || now) - now;
347 }
348 } else if (credential.refreshPolicy === REFRESH_POLICY_ONEXPIRY) {
349 // Refresh on expiry
350 if (credential.expiry) {
351 const now = Date.now();
352 wait = (credential.expiry || now) - now;
353 }
354 } else if (credential.refreshPolicy === REFRESH_POLICY_PERIODIC) {
355 // Refresh every N seconds
356 wait = (credential.refreshPeriod || DEFAULT_PERIODIC_SECONDS) * 1000;
357 } else {
358 // Unknown policy
359 throw new Error(`Invalid refresh policy: ${credential.refreshPolicy}`);
360 }
361 }
362
363 // Cleanup any pending schedules.
364 this.scheduler[credential.name].refresh
365 && clearTimeout(this.scheduler[credential.name].refresh);
366 this.scheduler[credential.name].refresh = null;
367
368 // Schedule the next refresh
369 if (wait >= 0) {
370 this.scheduler[credential.name].refresh = setTimeout(() => {
371 this.scheduler[credential.name].refresh = null;
372 this.refresh(credential);
373 }, wait);
374 this.scheduler[credential.name].refresh.unref();
375 }
376
377 return wait;
378 }
379
380 /**
381 * Actively update the status when the credential expires.
382 *
383 * @param {object} credential The credential to monitor expiry on.
384 */
385 scheduleExpiry(credential) {
386 this.scheduler[credential.name].expiry
387 && clearTimeout(this.scheduler[credential.name].expiry);
388 if (credential.expiry) {
389 this.scheduler[credential.name].expiry = setTimeout(() => {
390 const newstatus = {
391 credential: States.credential.expired
392 };
393 if (this.isRefreshableCredential(credential)
394 && credential.status
395 && credential.status.action === States.action.none) {
396 newstatus.action = States.action.needsRefresh;
397 }
398 credential.status = status(credential.status, newstatus);
399 }, credential.expiry - Date.now());
400
401 this.scheduler[credential.name].expiry.unref();
402 } else {
403 this.scheduler[credential.name].expiry = null;
404 }
405 }
406
407 /**
408 * Middleware to handle credential callbacks exposed by credential manager.
409 *
410 * @param {object} req The request.
411 * @param {object} res The response.
412 * @returns {object} Middleware response.
413 */
414 async middleware(req, res) {
415 const name = req.query.state;
416
417 if (!this.credentials.hasOwnProperty(name)
418 || !this.credentials[name].type
419 || !handlers[this.credentials[name].type].authorize) {
420 return res.status(404).send();
421 }
422
423 try {
424 const credential = this.credentials[name];
425 const newCred = await handlers[credential.type].authorize(credential, { ...req.query });
426 this.credentials[newCred.name] = newCred;
427 this.scheduleExpiry(newCred);
428 if (this.isRefreshableCredential(newCred)) {
429 const next = this.scheduleNextRefresh(newCred);
430 this.logger && this.logger
431 .debug(`Credential: ${newCred.name} acquired. Next refresh in ${next}ms.`);
432 } else {
433 this.logger && this.logger
434 .debug(`Credential: ${newCred.name} acquired.`);
435 }
436
437 const respText = await authTemplates[`${credential.type}Callback`];
438 res.status(200)
439 .set('Content-Type', 'text/html')
440 .send(respText);
441 } catch (ex) {
442 this.credentials[name].status = status(this.credentials[name].status, {
443 action: States.action.authError
444 });
445 this.logger && this.logger.error(ex);
446 const respText = await authTemplates[`${this.credentials[name].type}Error`];
447 res.status(401)
448 .set('Content-Type', 'text/html')
449 .send(respText);
450 }
451 }
452}
453
454Singleton.declare('axway-flow-authorization-credential-manager', CredentialManager);
455
456exports = module.exports = Singleton.get('axway-flow-authorization-credential-manager');