UNPKG

13.8 kBJavaScriptView Raw
1const got = require('got')
2const merge = require('got/source/merge')
3const jwt = require('jsonwebtoken')
4const pathToRegexp = require('path-to-regexp')
5const crypto = require('crypto')
6
7const aes256 = require('./fb-jwt-aes256')
8
9const {FBError} = require('@ministryofjustice/fb-utils-node')
10class FBJWTClientError extends FBError {}
11
12// algo to encrypt user data with
13const algorithm = 'HS256'
14
15const getResponseLabels = (response) => {
16 const responseLabels = {
17 status_code: response.statusCode
18 }
19 if (response.statusMessage) {
20 responseLabels.status_message = response.statusMessage
21 }
22 if (response.name) {
23 responseLabels.error_name = response.name
24 }
25 return responseLabels
26}
27
28const getErrorStatusCode = (message) => {
29 let statusCode = 500
30 if (message === 'ENOTFOUND') {
31 // no dns resolution
32 statusCode = 502
33 } else if (message === 'ECONNREFUSED') {
34 // connection rejected
35 statusCode = 503
36 }
37 return statusCode
38}
39
40/**
41 * Creates client using JSON Web Tokens
42 * @class
43 */
44class FBJWTClient {
45 /**
46 * Initialise client
47 *
48 * @param {string} serviceSecret
49 * Service secret
50 *
51 * @param {string} serviceToken
52 * Service token
53 *
54 * @param {string} serviceSlug
55 * Service slug
56 *
57 * @param {string} microserviceUrl
58 * URL of microservice to communicate with
59 *
60 * @param {error} [errorClass]
61 * Error class (defaults to FBJWTClientError)
62 *
63 * @return {object}
64 **/
65 constructor (serviceSecret, serviceToken, serviceSlug, microserviceUrl, errorClass) {
66 if (errorClass) {
67 this.ErrorClass = errorClass
68 }
69 if (!serviceSecret) {
70 this.throwRequestError('ENOSERVICESECRET', 'No service secret passed to client')
71 }
72 if (!serviceToken) {
73 this.throwRequestError('ENOSERVICETOKEN', 'No service token passed to client')
74 }
75 if (!serviceSlug) {
76 this.throwRequestError('ENOSERVICESLUG', 'No service slug passed to client')
77 }
78 if (!microserviceUrl) {
79 this.throwRequestError('ENOMICROSERVICEURL', 'No microservice url passed to client')
80 }
81
82 this.serviceSecret = serviceSecret
83 this.serviceToken = serviceToken
84 this.serviceUrl = microserviceUrl
85 this.serviceSlug = serviceSlug
86
87 // provide default Prometheus startTimer behaviour so as not to have to wrap all instrumentation calls in conditionals
88 const defaultMetrics = {
89 startTimer: () => {
90 return () => {}
91 }
92 }
93 this.apiMetrics = Object.assign({}, defaultMetrics)
94 this.requestMetrics = Object.assign({}, defaultMetrics)
95 }
96
97 /**
98 * Add metrics recorders for requests
99 *
100 * @param {object} apiMetrics
101 * Prometheus histogram instance
102 *
103 * @param {object} requestMetrics
104 * Prometheus histogram instance
105 *
106 * @return {undefined}
107 *
108 **/
109 setMetricsInstrumentation (apiMetrics, requestMetrics) {
110 this.apiMetrics = apiMetrics
111 this.requestMetrics = requestMetrics
112 }
113
114 /**
115 * Generate access token
116 *
117 * @param {string} [data]
118 * Request data
119 *
120 * @return {string}
121 * Access token
122 *
123 **/
124 generateAccessToken (data) {
125 // NB. jsonwebtoken helpfully sets ‘iat’ option by default
126 const checksum = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex')
127 const payload = {checksum}
128 const accessToken = jwt.sign(payload, this.serviceToken, {algorithm})
129 return accessToken
130 }
131
132 /**
133 * Encrypt data with AES 256
134 *
135 * @param {string} token
136 * Token
137 *
138 * @param {any} data
139 * Request data
140 *
141 * @param {string} [ivSeed]
142 * Initialization Vector
143 *
144 * @return {string}
145 * Encrypted data
146 *
147 **/
148 encrypt (token, data, ivSeed) {
149 const dataString = JSON.stringify(data)
150 const encryptedData = aes256.encrypt(token, dataString, ivSeed)
151 return encryptedData
152 }
153
154 /**
155 * Decrypt data
156 *
157 * @param {string} token
158 * Token
159 *
160 * @param {string} encryptedData
161 * Encrypted data
162 *
163 * @return {string}
164 * Decrypted data
165 *
166 **/
167 decrypt (token, encryptedData) {
168 let data
169 try {
170 data = aes256.decrypt(token, encryptedData)
171 data = JSON.parse(data)
172 } catch (e) {
173 this.throwRequestError(500, 'EINVALIDPAYLOAD')
174 }
175 return data
176 }
177
178 /**
179 * Encrypt user ID and token using service secret
180 *
181 * Guaranteed to return the same value
182 *
183 * @param {string} userId
184 * User ID
185 *
186 * @param {string} userToken
187 * User token
188 *
189 * @return {string}
190 *
191 **/
192 encryptUserIdAndToken (userId, userToken) {
193 const serviceSecret = this.serviceSecret
194 const ivSeed = userId + userToken
195 return this.encrypt(serviceSecret, {userId, userToken}, ivSeed)
196 }
197
198 /**
199 * Decrypt user ID and token using service secret
200 *
201 * @param {string} encryptedData
202 * Encrypted user ID and token
203 *
204 * @return {object}
205 *
206 **/
207 decryptUserIdAndToken (encryptedData) {
208 const serviceSecret = this.serviceSecret
209 return this.decrypt(serviceSecret, encryptedData)
210 }
211
212 /**
213 * Create user-specific endpoint
214 *
215 * @param {string} urlPattern
216 * Uncompiled pathToRegexp url pattern
217 *
218 * @param {object} context
219 * Object of values to substitute
220 *
221 * @return {string}
222 * Endpoint URL
223 *
224 **/
225 createEndpointUrl (urlPattern, context = {}) {
226 const toPath = pathToRegexp.compile(urlPattern)
227 const endpointUrl = this.serviceUrl + toPath(context)
228 return endpointUrl
229 }
230
231 /**
232 * Create request options
233 *
234 * @param {string} urlPattern
235 * Uncompiled pathToRegexp url pattern
236 *
237 * @param {string} context
238 * User ID
239 *
240 * @param {object} [data]
241 * Payload
242 *
243 * @param {boolean} [searchParams]
244 * Send payload as query string
245 *
246 * @return {object}
247 * Request options
248 *
249 **/
250 createRequestOptions (urlPattern, context, data = {}, searchParams) {
251 const accessToken = this.generateAccessToken(data)
252 const url = this.createEndpointUrl(urlPattern, context)
253 const hasData = Object.keys(data).length
254 const json = hasData && !searchParams ? data : true
255 const requestOptions = {
256 url,
257 headers: {
258 'x-access-token': accessToken
259 }
260 }
261 if (hasData && !searchParams) {
262 requestOptions.body = json
263 }
264 if (searchParams && hasData) {
265 requestOptions.searchParams = {
266 payload: Buffer.from(JSON.stringify(data)).toString('Base64')
267 }
268 }
269 requestOptions.json = true
270 return requestOptions
271 }
272
273 logError (type, error, labels, logger) {
274 const errorResponse = error.error || error.body
275 const errorResponseObj = typeof errorResponse === 'object' ? JSON.stringify(errorResponse) : ''
276
277 if (error.gotOptions) {
278 error.client_headers = error.gotOptions.headers
279 }
280 const logObject = Object.assign({}, labels, {error})
281
282 logger.error(logObject, `JWT ${type} request error: ${this.constructor.name}: ${labels.method.toUpperCase()} ${labels.base_url}${labels.url} - ${error.name} - ${error.code ? error.code : ''} - ${error.statusCode ? error.statusCode : ''} - ${error.statusMessage ? error.statusMessage : ''} - ${errorResponseObj}`)
283 }
284
285 /**
286 * Handle client requests
287 *
288 * @param {string} method
289 * Method for request
290 *
291 * @param {object} args
292 * Args for request
293 *
294 * @param {string} args.urlPattern
295 * Url pattern for request
296 *
297 * @param {object} args.context
298 * Context for url pattern substitution
299 *
300 * @param {object} [args.payload]
301 * Payload to send as query param to endpoint
302 *
303 * @param {object} [args.sendOptions]
304 * Additional options to pass to got method
305 *
306 * @param {object} [logger]
307 * Bunyan logger instance
308 *
309 * @return {object}
310 * Returns JSON object or handles exception
311 *
312 **/
313 async send (method, args, logger) {
314 const {
315 url,
316 context = {},
317 payload,
318 sendOptions
319 } = args
320 const client = this
321 const client_name = this.constructor.name // eslint-disable-line camelcase
322 const base_url = this.serviceUrl // eslint-disable-line camelcase
323 const options = this.createRequestOptions(url, context, payload, method === 'get')
324
325 const labels = {
326 client_name,
327 base_url,
328 url,
329 method
330 }
331
332 const logError = (type, e) => {
333 const errorType = `jwt_${type.toLowerCase()}_request_error`
334 const logLabels = Object.assign({}, labels, {
335 name: errorType
336 })
337 client.logError(type, e, logLabels, logger)
338 }
339
340 let requestMetricsEnd
341 let retryCounter = 1
342
343 const gotOptions = merge.options(got.defaults.options, {
344 hooks: {
345 beforeRequest: [
346 (options, error, retryCount) => {
347 requestMetricsEnd = this.requestMetrics.startTimer(labels)
348 }
349 ],
350 beforeRetry: [
351 (options, error, retryCount) => {
352 error.retryCount = retryCount
353 retryCounter = retryCount
354 logError('client', error)
355 if (requestMetricsEnd) {
356 requestMetricsEnd(getResponseLabels(error))
357 }
358 requestMetricsEnd = this.requestMetrics.startTimer(labels)
359 }
360 ],
361 beforeError: [
362 (error) => {
363 error.retryCount = retryCounter
364 requestMetricsEnd(getResponseLabels(error))
365 return error
366 }
367 ],
368 afterResponse: [
369 (response, retryWithMergedOptions) => {
370 if (response.statusCode >= 400) {
371 const {statusCode, statusMessage, body, retryCount} = response
372 const error = {
373 statusCode,
374 statusMessage,
375 body,
376 retryCount
377 }
378 logError('client', error)
379 }
380 requestMetricsEnd(getResponseLabels(response))
381 response.body = response.body || '{}'
382 return response
383 }
384 ]
385 }
386 }, sendOptions, options)
387
388 const apiMetricsEnd = this.apiMetrics.startTimer(labels)
389
390 try {
391 const response = await got[method](gotOptions)
392 apiMetricsEnd(getResponseLabels(response))
393 return response.body
394 } catch (error) {
395 // Horrible kludge to handle services returning ' ' as body
396 const response = error.response
397 if (response && response.statusCode < 300) {
398 if (response.body && !response.body.trim()) {
399 requestMetricsEnd(getResponseLabels(response))
400 return {}
401 }
402 }
403 apiMetricsEnd(getResponseLabels(error))
404 if (logger) {
405 logError('API', error)
406 }
407 client.handleRequestError(error)
408 }
409 }
410
411 /**
412 * Handle client get requests
413 *
414 * @param {object} args
415 * Args for request
416 *
417 * @param {string} args.url
418 * Url pattern for request
419 *
420 * @param {object} args.context
421 * Context for url pattern substitution
422 *
423 * @param {object} [args.payload]
424 * Payload to send as query param to endpoint
425 *
426 * @param {object} [args.sendOptions]
427 * Additional options to pass to got method
428 *
429 * @param {object} [logger]
430 * Bunyan logger instance
431 *
432 * @return {promise<object>}
433 * Returns promise resolving to JSON object or handles exception
434 *
435 **/
436 async sendGet (args, logger) {
437 return this.send('get', args, logger)
438 }
439
440 /**
441 * Handle client post requests
442 *
443 * @param {object} args
444 * Args for request
445 *
446 * @param {string} args.url
447 * Url pattern for request
448 *
449 * @param {object} args.context
450 * Context for url pattern substitution
451 *
452 * @param {object} args.payload
453 * Payload to post to endpoint
454 *
455 * @param {object} [args.sendOptions]
456 * Additional options to pass to got method
457 *
458 * @param {object} [logger]
459 * Bunyan logger instance
460 *
461 * @return {promise<object>}
462 * Returns promise resolving to JSON object or handles exception
463 *
464 **/
465 async sendPost (args, logger) {
466 return this.send('post', args, logger)
467 }
468
469 /**
470 * Handle client response errors
471 *
472 * @param {object} err
473 * Error returned by Request
474 *
475 * @return {undefined}
476 * Returns nothing as it should throw an error
477 *
478 **/
479 handleRequestError (err) {
480 // rethrow error if already client error
481 if (err.name === this.ErrorClass.name) {
482 throw err
483 }
484 if (err.body) {
485 if (typeof err.body === 'object') {
486 err.error = err.body
487 }
488 }
489 const {statusCode} = err
490 if (statusCode) {
491 if (statusCode === 404) {
492 // Data does not exist - ie. expired
493 this.throwRequestError(404)
494 } else {
495 let message
496 if (err.error) {
497 message = err.error.name || err.error.code || 'EUNSPECIFIED'
498 }
499 this.throwRequestError(statusCode, message)
500 }
501 } else if (err.error) {
502 // Handle errors which have an error object
503 const errorObj = err.error
504 const message = errorObj.name || errorObj.code || 'EUNSPECIFIED'
505 const statusCode = getErrorStatusCode(message)
506 this.throwRequestError(statusCode, message)
507 } else {
508 // Handle errors which have no error object
509 const message = err.code || 'ENOERROR'
510 const statusCode = getErrorStatusCode(message)
511 this.throwRequestError(statusCode, message)
512 }
513 }
514
515 /**
516 * Convenience function for throwing errors
517 *
518 * @param {number|string} code
519 * Error code
520 *
521 * @param {string} [message]
522 * Error message (defaults to code)
523 *
524 * @return {undefined}
525 * Returns nothing as it should throw an error
526 *
527 **/
528 throwRequestError (code, message) {
529 message = message || code
530 throw new this.ErrorClass({
531 error: {
532 code,
533 message
534 }
535 })
536 }
537}
538
539// default client error class
540FBJWTClient.prototype.ErrorClass = FBJWTClientError
541
542module.exports = FBJWTClient