UNPKG

17.2 kBJavaScriptView Raw
1var extend = require('xtend')
2var Querystring = require('querystring')
3var Url = require('url')
4var defaultRequest = require('./request')
5
6var btoa = typeof Buffer === 'function' ? btoaBuffer : window.btoa
7
8/**
9 * Export `ClientOAuth2` class.
10 */
11module.exports = ClientOAuth2
12
13/**
14 * Default headers for executing OAuth 2.0 flows.
15 */
16var DEFAULT_HEADERS = {
17 'Accept': 'application/json, application/x-www-form-urlencoded',
18 'Content-Type': 'application/x-www-form-urlencoded'
19}
20
21/**
22 * Format error response types to regular strings for displaying to clients.
23 *
24 * Reference: http://tools.ietf.org/html/rfc6749#section-4.1.2.1
25 */
26var ERROR_RESPONSES = {
27 'invalid_request': [
28 'The request is missing a required parameter, includes an',
29 'invalid parameter value, includes a parameter more than',
30 'once, or is otherwise malformed.'
31 ].join(' '),
32 'invalid_client': [
33 'Client authentication failed (e.g., unknown client, no',
34 'client authentication included, or unsupported',
35 'authentication method).'
36 ].join(' '),
37 'invalid_grant': [
38 'The provided authorization grant (e.g., authorization',
39 'code, resource owner credentials) or refresh token is',
40 'invalid, expired, revoked, does not match the redirection',
41 'URI used in the authorization request, or was issued to',
42 'another client.'
43 ].join(' '),
44 'unauthorized_client': [
45 'The client is not authorized to request an authorization',
46 'code using this method.'
47 ].join(' '),
48 'unsupported_grant_type': [
49 'The authorization grant type is not supported by the',
50 'authorization server.'
51 ].join(' '),
52 'access_denied': [
53 'The resource owner or authorization server denied the request.'
54 ].join(' '),
55 'unsupported_response_type': [
56 'The authorization server does not support obtaining',
57 'an authorization code using this method.'
58 ].join(' '),
59 'invalid_scope': [
60 'The requested scope is invalid, unknown, or malformed.'
61 ].join(' '),
62 'server_error': [
63 'The authorization server encountered an unexpected',
64 'condition that prevented it from fulfilling the request.',
65 '(This error code is needed because a 500 Internal Server',
66 'Error HTTP status code cannot be returned to the client',
67 'via an HTTP redirect.)'
68 ].join(' '),
69 'temporarily_unavailable': [
70 'The authorization server is currently unable to handle',
71 'the request due to a temporary overloading or maintenance',
72 'of the server.'
73 ].join(' ')
74}
75
76/**
77 * Support base64 in node like how it works in the browser.
78 *
79 * @param {String} string
80 * @return {String}
81 */
82function btoaBuffer (string) {
83 return new Buffer(string).toString('base64')
84}
85
86/**
87 * Check if properties exist on an object and throw when they aren't.
88 *
89 * @throws {TypeError} If an expected property is missing.
90 *
91 * @param {Object} obj
92 * @param {Array} props
93 */
94function expects (obj, props) {
95 for (var i = 0; i < props.length; i++) {
96 var prop = props[i]
97
98 if (obj[prop] == null) {
99 throw new TypeError('Expected "' + prop + '" to exist')
100 }
101 }
102}
103
104/**
105 * Pull an authentication error from the response data.
106 *
107 * @param {Object} data
108 * @return {String}
109 */
110function getAuthError (body) {
111 var message = ERROR_RESPONSES[body.error] ||
112 body.error_description ||
113 body.error
114
115 if (message) {
116 var err = new Error(message)
117 err.body = body
118 err.code = 'EAUTH'
119 return err
120 }
121}
122
123/**
124 * Attempt to parse response body as JSON, fall back to parsing as a query string.
125 *
126 * @param {String} body
127 * @return {Object}
128 */
129function parseResponseBody (body) {
130 try {
131 return JSON.parse(body)
132 } catch (e) {
133 return Querystring.parse(body)
134 }
135}
136
137/**
138 * Sanitize the scopes option to be a string.
139 *
140 * @param {Array} scopes
141 * @return {String}
142 */
143function sanitizeScope (scopes) {
144 return Array.isArray(scopes) ? scopes.join(' ') : toString(scopes)
145}
146
147/**
148 * Create a request uri based on an options object and token type.
149 *
150 * @param {Object} options
151 * @param {String} tokenType
152 * @return {String}
153 */
154function createUri (options, tokenType) {
155 // Check the required parameters are set.
156 expects(options, [
157 'clientId',
158 'redirectUri',
159 'authorizationUri'
160 ])
161
162 return options.authorizationUri + '?' + Querystring.stringify(extend(
163 options.query,
164 {
165 client_id: options.clientId,
166 redirect_uri: options.redirectUri,
167 scope: sanitizeScope(options.scopes),
168 response_type: tokenType,
169 state: options.state
170 }
171 ))
172}
173
174/**
175 * Create basic auth header.
176 *
177 * @param {String} username
178 * @param {String} password
179 * @return {String}
180 */
181function auth (username, password) {
182 return 'Basic ' + btoa(toString(username) + ':' + toString(password))
183}
184
185/**
186 * Ensure a value is a string.
187 *
188 * @param {String} str
189 * @return {String}
190 */
191function toString (str) {
192 return str == null ? '' : String(str)
193}
194
195/**
196 * Merge request options from an options object.
197 */
198function requestOptions (requestOptions, options) {
199 return extend(requestOptions, {
200 body: extend(options.body, requestOptions.body),
201 query: extend(options.query, requestOptions.query),
202 headers: extend(options.headers, requestOptions.headers)
203 })
204}
205
206/**
207 * Construct an object that can handle the multiple OAuth 2.0 flows.
208 *
209 * @param {Object} options
210 */
211function ClientOAuth2 (options, request) {
212 this.options = options
213 this.request = request || defaultRequest
214
215 this.code = new CodeFlow(this)
216 this.token = new TokenFlow(this)
217 this.owner = new OwnerFlow(this)
218 this.credentials = new CredentialsFlow(this)
219 this.jwt = new JwtBearerFlow(this)
220}
221
222/**
223 * Alias the token constructor.
224 *
225 * @type {Function}
226 */
227ClientOAuth2.Token = ClientOAuth2Token
228
229/**
230 * Create a new token from existing data.
231 *
232 * @param {String} access
233 * @param {String} [refresh]
234 * @param {String} [type]
235 * @param {Object} [data]
236 * @return {Object}
237 */
238ClientOAuth2.prototype.createToken = function (access, refresh, type, data) {
239 var options = extend(
240 data,
241 typeof access === 'string' ? { access_token: access } : access,
242 typeof refresh === 'string' ? { refresh_token: refresh } : refresh,
243 typeof type === 'string' ? { token_type: type } : type
244 )
245
246 return new ClientOAuth2.Token(this, options)
247}
248
249/**
250 * Using the built-in request method, we'll automatically attempt to parse
251 * the response.
252 *
253 * @param {Object} options
254 * @return {Promise}
255 */
256ClientOAuth2.prototype._request = function (options) {
257 var url = options.url
258 var body = Querystring.stringify(options.body)
259 var query = Querystring.stringify(options.query)
260
261 if (query) {
262 url += (url.indexOf('?') === -1 ? '?' : '&') + query
263 }
264
265 return this.request(options.method, url, body, options.headers)
266 .then(function (res) {
267 var body = parseResponseBody(res.body)
268 var authErr = getAuthError(body)
269
270 if (authErr) {
271 return Promise.reject(authErr)
272 }
273
274 if (res.status < 200 || res.status >= 399) {
275 var statusErr = new Error('HTTP status ' + res.status)
276 statusErr.status = res.status
277 statusErr.body = res.body
278 statusErr.code = 'ESTATUS'
279 return Promise.reject(statusErr)
280 }
281
282 return body
283 })
284}
285
286/**
287 * General purpose client token generator.
288 *
289 * @param {Object} client
290 * @param {Object} data
291 */
292function ClientOAuth2Token (client, data) {
293 this.client = client
294 this.data = data
295 this.tokenType = data.token_type && data.token_type.toLowerCase()
296 this.accessToken = data.access_token
297 this.refreshToken = data.refresh_token
298
299 this.expiresIn(Number(data.expires_in))
300}
301
302/**
303 * Expire the token after some time.
304 *
305 * @param {Number|Date} duration Seconds from now to expire, or a date to expire on.
306 * @return {Date}
307 */
308ClientOAuth2Token.prototype.expiresIn = function (duration) {
309 if (typeof duration === 'number') {
310 this.expires = new Date()
311 this.expires.setSeconds(this.expires.getSeconds() + duration)
312 } else if (duration instanceof Date) {
313 this.expires = new Date(duration.getTime())
314 } else {
315 throw new TypeError('Unknown duration: ' + duration)
316 }
317
318 return this.expires
319}
320
321/**
322 * Sign a standardised request object with user authentication information.
323 *
324 * @param {Object} requestObject
325 * @return {Object}
326 */
327ClientOAuth2Token.prototype.sign = function (requestObject) {
328 if (!this.accessToken) {
329 throw new Error('Unable to sign without access token')
330 }
331
332 requestObject.headers = requestObject.headers || {}
333
334 if (this.tokenType === 'bearer') {
335 requestObject.headers.Authorization = 'Bearer ' + this.accessToken
336 } else {
337 var parts = requestObject.url.split('#')
338 var token = 'access_token=' + this.accessToken
339 var url = parts[0].replace(/[?&]access_token=[^&#]/, '')
340 var fragment = parts[1] ? '#' + parts[1] : ''
341
342 // Prepend the correct query string parameter to the url.
343 requestObject.url = url + (url.indexOf('?') > -1 ? '&' : '?') + token + fragment
344
345 // Attempt to avoid storing the url in proxies, since the access token
346 // is exposed in the query parameters.
347 requestObject.headers.Pragma = 'no-store'
348 requestObject.headers['Cache-Control'] = 'no-store'
349 }
350
351 return requestObject
352}
353
354/**
355 * Refresh a user access token with the supplied token.
356 *
357 * @return {Promise}
358 */
359ClientOAuth2Token.prototype.refresh = function (options) {
360 var self = this
361
362 options = extend(this.client.options, options)
363
364 if (!this.refreshToken) {
365 return Promise.reject(new Error('No refresh token'))
366 }
367
368 return this.client._request(requestOptions({
369 url: options.accessTokenUri,
370 method: 'POST',
371 headers: extend(DEFAULT_HEADERS, {
372 Authorization: auth(options.clientId, options.clientSecret)
373 }),
374 body: {
375 refresh_token: this.refreshToken,
376 grant_type: 'refresh_token'
377 }
378 }, options))
379 .then(function (data) {
380 return self.client.createToken(extend(self.data, data))
381 })
382}
383
384/**
385 * Check whether the token has expired.
386 *
387 * @return {Boolean}
388 */
389ClientOAuth2Token.prototype.expired = function () {
390 return Date.now() > this.expires.getTime()
391}
392
393/**
394 * Support resource owner password credentials OAuth 2.0 grant.
395 *
396 * Reference: http://tools.ietf.org/html/rfc6749#section-4.3
397 *
398 * @param {ClientOAuth2} client
399 */
400function OwnerFlow (client) {
401 this.client = client
402}
403
404/**
405 * Make a request on behalf of the user credentials to get an acces token.
406 *
407 * @param {String} username
408 * @param {String} password
409 * @return {Promise}
410 */
411OwnerFlow.prototype.getToken = function (username, password, options) {
412 var self = this
413
414 options = extend(this.client.options, options)
415
416 return this.client._request(requestOptions({
417 url: options.accessTokenUri,
418 method: 'POST',
419 headers: extend(DEFAULT_HEADERS, {
420 Authorization: auth(options.clientId, options.clientSecret)
421 }),
422 body: {
423 scope: sanitizeScope(options.scopes),
424 username: username,
425 password: password,
426 grant_type: 'password'
427 }
428 }, options))
429 .then(function (data) {
430 return self.client.createToken(data)
431 })
432}
433
434/**
435 * Support implicit OAuth 2.0 grant.
436 *
437 * Reference: http://tools.ietf.org/html/rfc6749#section-4.2
438 *
439 * @param {ClientOAuth2} client
440 */
441function TokenFlow (client) {
442 this.client = client
443}
444
445/**
446 * Get the uri to redirect the user to for implicit authentication.
447 *
448 * @param {Object} options
449 * @return {String}
450 */
451TokenFlow.prototype.getUri = function (options) {
452 options = extend(this.client.options, options)
453
454 return createUri(options, 'token')
455}
456
457/**
458 * Get the user access token from the uri.
459 *
460 * @param {String} uri
461 * @param {Object} [options]
462 * @return {Promise}
463 */
464TokenFlow.prototype.getToken = function (uri, options) {
465 options = extend(this.client.options, options)
466
467 var url = Url.parse(uri)
468 var expectedUrl = Url.parse(options.redirectUri)
469
470 if (url.pathname !== expectedUrl.pathname) {
471 return Promise.reject(new TypeError('Should match redirect uri: ' + uri))
472 }
473
474 // If no query string or fragment exists, we won't be able to parse
475 // any useful information from the uri.
476 if (!url.hash && !url.search) {
477 return Promise.reject(new TypeError('Unable to process uri: ' + uri))
478 }
479
480 // Extract data from both the fragment and query string. The fragment is most
481 // important, but the query string is also used because some OAuth 2.0
482 // implementations (Instagram) have a bug where state is passed via query.
483 var data = extend(
484 url.query ? Querystring.parse(url.query) : {},
485 url.hash ? Querystring.parse(url.hash.substr(1)) : {}
486 )
487
488 var err = getAuthError(data)
489
490 // Check if the query string was populated with a known error.
491 if (err) {
492 return Promise.reject(err)
493 }
494
495 // Check whether the state matches.
496 if (options.state != null && data.state !== options.state) {
497 return Promise.reject(new TypeError('Invalid state: ' + data.state))
498 }
499
500 // Initalize a new token and return.
501 return Promise.resolve(this.client.createToken(data))
502}
503
504/**
505 * Support client credentials OAuth 2.0 grant.
506 *
507 * Reference: http://tools.ietf.org/html/rfc6749#section-4.4
508 *
509 * @param {ClientOAuth2} client
510 */
511function CredentialsFlow (client) {
512 this.client = client
513}
514
515/**
516 * Request an access token using the client credentials.
517 *
518 * @param {Object} [options]
519 * @return {Promise}
520 */
521CredentialsFlow.prototype.getToken = function (options) {
522 var self = this
523
524 options = extend(this.client.options, options)
525
526 expects(options, [
527 'clientId',
528 'clientSecret',
529 'accessTokenUri'
530 ])
531
532 return this.client._request(requestOptions({
533 url: options.accessTokenUri,
534 method: 'POST',
535 headers: extend(DEFAULT_HEADERS, {
536 Authorization: auth(options.clientId, options.clientSecret)
537 }),
538 body: {
539 scope: sanitizeScope(options.scopes),
540 grant_type: 'client_credentials'
541 }
542 }, options))
543 .then(function (data) {
544 return self.client.createToken(data)
545 })
546}
547
548/**
549 * Support authorization code OAuth 2.0 grant.
550 *
551 * Reference: http://tools.ietf.org/html/rfc6749#section-4.1
552 *
553 * @param {ClientOAuth2} client
554 */
555function CodeFlow (client) {
556 this.client = client
557}
558
559/**
560 * Generate the uri for doing the first redirect.
561 *
562 * @return {String}
563 */
564CodeFlow.prototype.getUri = function (options) {
565 options = extend(this.client.options, options)
566
567 return createUri(options, 'code')
568}
569
570/**
571 * Get the code token from the redirected uri and make another request for
572 * the user access token.
573 *
574 * @param {String} uri
575 * @param {Object} [options]
576 * @return {Promise}
577 */
578CodeFlow.prototype.getToken = function (uri, options) {
579 var self = this
580
581 options = extend(this.client.options, options)
582
583 expects(options, [
584 'clientId',
585 'clientSecret',
586 'redirectUri',
587 'accessTokenUri'
588 ])
589
590 var url = Url.parse(uri)
591 var expectedUrl = Url.parse(options.redirectUri)
592
593 if (url.pathname !== expectedUrl.pathname) {
594 return Promise.reject(new TypeError('Should match redirect uri: ' + uri))
595 }
596
597 if (!url.search) {
598 return Promise.reject(new TypeError('Unable to process uri: ' + uri))
599 }
600
601 var data = Querystring.parse(url.query)
602 var err = getAuthError(data)
603
604 if (err) {
605 return Promise.reject(err)
606 }
607
608 if (options.state && data.state !== options.state) {
609 return Promise.reject(new TypeError('Invalid state:' + data.state))
610 }
611
612 // Check whether the response code is set.
613 if (!data.code) {
614 return Promise.reject(new TypeError('Missing code, unable to request token'))
615 }
616
617 return this.client._request(requestOptions({
618 url: options.accessTokenUri,
619 method: 'POST',
620 headers: extend(DEFAULT_HEADERS),
621 body: {
622 code: data.code,
623 grant_type: 'authorization_code',
624 redirect_uri: options.redirectUri,
625 client_id: options.clientId,
626 client_secret: options.clientSecret
627 }
628 }, options))
629 .then(function (data) {
630 return self.client.createToken(data)
631 })
632}
633
634/**
635 * Support JSON Web Token (JWT) Bearer Token OAuth 2.0 grant.
636 *
637 * Reference: https://tools.ietf.org/html/draft-ietf-oauth-jwt-bearer-12#section-2.1
638 *
639 * @param {ClientOAuth2} client
640 */
641function JwtBearerFlow (client) {
642 this.client = client
643}
644
645/**
646 * Request an access token using a JWT token.
647 *
648 * @param {string} token A JWT token.
649 * @param {Object} [options]
650 * @return {Promise}
651 */
652JwtBearerFlow.prototype.getToken = function (token, options) {
653 var self = this
654
655 options = extend(this.client.options, options)
656
657 expects(options, [
658 'accessTokenUri'
659 ])
660
661 var headers = extend(DEFAULT_HEADERS)
662
663 // Authentication of the client is optional, as described in
664 // Section 3.2.1 of OAuth 2.0 [RFC6749]
665 if (options.clientId) {
666 headers['Authorization'] = auth(options.clientId, options.clientSecret)
667 }
668
669 return this.client._request(requestOptions({
670 url: options.accessTokenUri,
671 method: 'POST',
672 headers: headers,
673 body: {
674 scope: sanitizeScope(options.scopes),
675 grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
676 assertion: token
677 }
678 }, options))
679 .then(function (data) {
680 return self.client.createToken(data)
681 })
682}