diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..06227a6 --- /dev/null +++ b/.babelrc @@ -0,0 +1,8 @@ +{ + presets: ["es2015"], + plugins: ["transform-object-rest-spread", + ["babel-plugin-transform-builtin-extend", { + globals: ["Error", "Array"] + }]], + sourceMaps: true +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3502f2e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# http://editorconfig.org +root = true + +# All files +[*] +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# JS files +[*.js] +indent_style = space +indent_size = 2 + +# JSON files +[*.json] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +test diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..cfac555 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,25 @@ +{ + "parser" : "babel-eslint", + "extends": "airbnb", + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "jsx": false + } + }, + "plugins" : [ + "flow-vars" + ], + "env" : { + "browser" : true, + "mocha": true, + }, + "rules": { + "no-empty-label": 0, + "space-before-keywords": 0, + "space-after-keywords": 0, + "space-return-throw-case": 0, + "no-iterator": 0 + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..84bbf01 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For @shoutem/fetch-token-intercept software + +Copyright (c) 2017-present, Shoutem. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + * Neither the name of the Shoutem nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c485ad --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# fetch-token-intercept +Library for easy renewing of access tokens in OAuth's refresh token flow. This library will monkey +patch fetch on your target environment and will try to resolve unauthorized requests automatically +by renewing the current access token and then retrying an initial fetch operation. + +If you are not familiar with refresh token flow you should check some of the following resources: +- [RFC standards track regarding refresh token flow](https://tools.ietf.org/html/rfc6749#page-10) +- [Auth0 blog - Refresh Tokens: When to Use Them and How They Interact with JWTs](https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/) + +>Note: +This library expects that fetch and promise api's are available at target environment. You should +provide a polyfill when necessary. + +## Installation + +`fetch-token-intercept` is available on [npm](https://www.npmjs.com/package/@shoutem/fetch-token-intercept). + +``` +$ npm install @shoutem/fetch-token-intercept --save +``` + +## Getting started + +Before making any fetch requests you should `configure` and `authorize` this library to support +interception. + +Configuration is provided via `config` object: + +``` +config: { + //(Required) Prepare fetch request for renewing new access token + createAccessTokenRequest: (refreshToken) => request, + + //(Required) Parses access token from access token response + parseAccessToken: (response) => accessToken, + + //(Required) Defines whether interceptor will intercept this request or just let it pass through + shouldIntercept: (request) => boolean, + + //(Required) Defines whether access token will be invalidated after this response + shouldInvalidateAccessToken: (response) => boolean, + + //(Required) Adds authorization for intercepted requests + authorizeRequest: (request) => authorizedRequest, + + //Number of retries after initial request was unauthorized + fetchRetryCount: 1, + + //Event invoked when access token has changed + onAccessTokenChange: null, + + //Event invoked when response is resolved + onResponse: null, +} +``` + +All required methods support returning a promise to enable reading of body. +You should avoid reading the body directly on provided requests and responses and instead clone +them first. The library does not clone objects to avoid unnecessary overhead in cases where +reading a body is not required to provide data. + +To configure the interceptor you should import and call `configure` function. And when you obtain +a refresh token you should call `authorize`, which accepts refresh and access tokens. + +``` + import { configure, authorize } from '@shoutem/fetch-token-intercept'; + + ... + configure(configuration); + // perform authentication with user credentials against your auth server + // when you recieve refresh token (and optionally access token) provide them to interceptor lib + authorize(refreshToken, accessToken); + ... +``` + +User is now logged in with provided refresh token. If refresh token invalidates interceptor +will automatically clear both tokens and further requests won't be intercepted. You should redirect +user to authentication screen and re-authorize interceptor on successful authentication. + +To manually clear tokens you can call clear method. You should call this when user log outs manually +to stop fetch interception. + +``` + import { clear } from '@shoutem/fetch-token-intercept'; + + ... + clear(); + ... +``` + +## API reference + +### Exports + `configure` + + Configures fetch token interceptor with provided configuration object. + + `authorize` + + Authorizes fetch token interceptor with provided tokens. + + `clear` + + Clears all tokens from interceptor. + + ## Tests + + ``` + $ npm install && npm run test + ``` + + ## License + BSD \ No newline at end of file diff --git a/babelTestSetup.js b/babelTestSetup.js new file mode 100644 index 0000000..bedb2ed --- /dev/null +++ b/babelTestSetup.js @@ -0,0 +1,8 @@ +require('babel-register')({ + presets: ['es2015'], + plugins: [ + 'transform-object-rest-spread', + ["babel-plugin-transform-builtin-extend", { globals: ["Error", "Array"]}] + ], + sourceMaps: 'both', +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..cbb938f --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "@shoutem/fetch-token-intercept", + "version": "0.0.1-alpha.5", + "description": "Fetch interceptor for managing refresh token flow.", + "main": "lib/index.js", + "files": [ + "lib" + ], + "scripts": { + "lint": "eslint src test", + "test": "mocha --require babelTestSetup --reporter spec --recursive test", + "coverage": "babel-node node_modules/isparta/bin/isparta cover --report text --report html node_modules/mocha/bin/_mocha -- -R spec --recursive test", + "build": "babel src --out-dir lib" + }, + "repository": { + "type": "git", + "url": "https://github.com/shoutem/fetch-token-intercept/" + }, + "keywords": [ + "fetch", + "intercept", + "refresh", + "token", + "access" + ], + "author": "Shoutem", + "license": "BSD", + "bugs": { + "url": "https://github.com/shoutem/fetch-token-intercept/issues" + }, + "homepage": "", + "devDependencies": { + "babel-cli": "^6.9.0", + "babel-core": "^6.9.1", + "babel-eslint": "^6.0.0", + "babel-plugin-transform-builtin-extend": "^1.1.2", + "babel-preset-es2015": "^6.9.0", + "babel-preset-stage-0": "^6.3.13", + "babel-register": "^6.9.0", + "chai": "^3.5.0", + "chai-shallow-deep-equal": "^1.4.0", + "deep-freeze": "0.0.1", + "es6-promise": "^4.0.5", + "eslint": "^2.11.1", + "eslint-config-airbnb": "^9.0.1", + "eslint-plugin-flow-vars": "^0.4.0", + "eslint-plugin-import": "^1.8.1", + "eslint-plugin-jsx-a11y": "^1.3.0", + "eslint-plugin-react": "^5.1.1", + "estraverse-fb": "^1.3.1", + "express": "^4.14.1", + "fetch-everywhere": "^1.0.5", + "isparta": "^4.0.0", + "istanbul": "0.4.4", + "mocha": "^2.5.3", + "nock": "^8.0.0", + "sinon": "^1.17.4" + }, + "dependencies": {} +} diff --git a/src/AccessTokenProvider.js b/src/AccessTokenProvider.js new file mode 100644 index 0000000..256a828 --- /dev/null +++ b/src/AccessTokenProvider.js @@ -0,0 +1,127 @@ +import { + isResponseUnauthorized, +} from './services/http'; + +/** + * Provides a way for renewing access token with correct refresh token. It will automatically + * dispatch a call to server with request provided via config. It also ensures that + * access token is fetched only once no matter how many requests are trying to get + * a renewed version of access token at the moment. All subsequent requests will be chained + * to renewing fetch promise and resolved once the response is received. + */ +export default class AccessTokenProvider { + constructor(fetch, config) { + this.fetch = fetch; + + this.config = config; + this.renewAccessTokenPromise = null; + this.tokens = { + refreshToken: null, + accessToken: null, + }; + + this.renew = this.renew.bind(this); + this.authorize = this.authorize.bind(this); + this.getAuthorization = this.getAuthorization.bind(this); + this.clear = this.clear.bind(this); + + this.isAuthorized = this.isAuthorized.bind(this); + this.resolveAccessToken = this.resolveAccessToken.bind(this); + this.fetchAccessToken = this.fetchAccessToken.bind(this); + this.handleFetchAccessTokenResponse = this.handleFetchAccessTokenResponse.bind(this); + this.handleAccessToken = this.handleAccessToken.bind(this); + this.handleError = this.handleError.bind(this); + } + + /** + * Renews current access token with provided refresh token + */ + renew() { + // if token resolver is not authorized it should just resolve + if (!this.isAuthorized()) { + console.warn('Please authorize provider before renewing or check shouldIntercept config.'); + return Promise.resolve(); + } + + // if we are not running token promise, start it + if (!this.renewAccessTokenPromise) { + this.renewAccessTokenPromise = new Promise(this.resolveAccessToken); + } + + // otherwise just return existing promise + return this.renewAccessTokenPromise; + } + + /** + * Authorizes intercept library with given refresh token + * @param refreshToken + * @param accessToken + */ + authorize(refreshToken, accessToken) { + this.tokens = { ...this.tokens, refreshToken, accessToken }; + } + + /** + * Returns current authorization for fetch fetchInterceptor + * @returns {{accessToken: string, refreshToken: string}} + */ + getAuthorization() { + return this.tokens; + } + + /** + * Clears authorization tokens. Call this to effectively log out user from fetch interceptor. + */ + clear() { + this.tokens.accessToken = null; + this.tokens.refreshToken = null; + } + + isAuthorized() { + return this.tokens.refreshToken !== null; + } + + fetchAccessToken(tokenRequest) { + const { fetch } = this; + return fetch(tokenRequest); + } + + handleFetchAccessTokenResponse(response) { + this.renewAccessTokenPromise = null; + + if (isResponseUnauthorized(response)) { + this.clear(); + return null; + } + + return this.config.parseAccessToken(response); + } + + handleAccessToken(accessToken, resolve) { + this.tokens = { ...this.tokens, accessToken }; + + if (this.config.onAccessTokenChange) { + this.config.onAccessTokenChange(accessToken); + } + + resolve(accessToken); + } + + handleError(error, reject) { + this.renewAccessTokenPromise = null; + this.clear(); + + reject(error); + } + + resolveAccessToken(resolve, reject) { + const { refreshToken } = this.tokens; + const { createAccessTokenRequest } = this.config; + + return Promise.resolve(createAccessTokenRequest(refreshToken)) + .then(this.fetchAccessToken) + .then(this.handleFetchAccessTokenResponse) + .then(token => this.handleAccessToken(token, resolve)) + .catch(error => this.handleError(error, reject)); + } +} diff --git a/src/FetchInterceptor.js b/src/FetchInterceptor.js new file mode 100644 index 0000000..7d2b973 --- /dev/null +++ b/src/FetchInterceptor.js @@ -0,0 +1,329 @@ +import { + ERROR_INVALID_CONFIG, +} from './const'; +import { + isResponseUnauthorized, +} from './services/http'; +import TokenExpiredException from './services/TokenExpiredException'; +import RetryCountExceededException from './services/RetryCountExceededException'; +import AccessTokenProvider from './AccessTokenProvider'; + +/** + * Provides a default implementation for intercepting fetch requests. It will try to resolve + * unauthorized responses by renewing the access token and repeating the initial request. + */ +export default class FetchInterceptor { + constructor(fetch) { + // stores reference to vanilla fetch method + this.fetch = fetch; + + this.config = { + fetchRetryCount: 1, + createAccessTokenRequest: null, + shouldIntercept: null, + shouldInvalidateAccessToken: null, + parseAccessToken: null, + authorizeRequest: null, + onAccessTokenChange: null, + onResponse: null, + }; + + this.intercept = this.intercept.bind(this); + + this.resolveIntercept = this.resolveIntercept.bind(this); + this.fetchWithRetry = this.fetchWithRetry.bind(this); + this.isConfigValid = this.isConfigValid.bind(this); + this.createRequestUnit = this.createRequestUnit.bind(this); + this.shouldIntercept = this.shouldIntercept.bind(this); + this.authorizeRequest = this.authorizeRequest.bind(this); + this.shouldFetch = this.shouldFetch.bind(this); + this.fetchRequest = this.fetchRequest.bind(this); + this.shouldInvalidateAccessToken = this.shouldInvalidateAccessToken.bind(this); + this.invalidateAccessToken = this.invalidateAccessToken.bind(this); + this.handleUnauthorizedRequest = this.handleUnauthorizedRequest.bind(this); + this.handleResponse = this.handleResponse.bind(this); + } + + /** + * Configures fetch interceptor with given config object. All required properties can optionally + * return a promise which will be resolved by fetch interceptor automatically. + * + * @param config + * + * (Required) Prepare fetch request for renewing new access token + * createAccessTokenRequest: (refreshToken) => request, + * + * (Required) Parses access token from access token response + * parseAccessToken: (response) => accessToken, + * + * (Required) Defines whether interceptor will intercept this request or just let it pass through + * shouldIntercept: (request) => boolean, + * + * (Required) Defines whether access token will be invalidated after this response + * shouldInvalidateAccessToken: (response) => boolean, + * + * (Required) Adds authorization for intercepted requests + * authorizeRequest: (request) => authorizedRequest, + * + * Number of retries after initial request was unauthorized + * fetchRetryCount: 1, + * + * Event invoked when access token has changed + * onAccessTokenChange: null, + * + * Event invoked when response is resolved + * onResponse: null, + * + */ + configure(config) { + this.config = { ...this.config, ...config }; + + if (!this.isConfigValid(this.config)) { + throw new Error(ERROR_INVALID_CONFIG); + } + + this.accessTokenProvider = new AccessTokenProvider(this.fetch, this.config); + } + + /** + * Authorizes fetch interceptor with given renew token + * @param refreshToken + * @param accessToken + */ + authorize(refreshToken, accessToken) { + this.accessTokenProvider.authorize(refreshToken, accessToken); + } + + /** + * Returns current authorization for fetch fetchInterceptor + * @returns {{accessToken: string, refreshToken: string}} + */ + getAuthorization() { + return this.accessTokenProvider.getAuthorization(); + } + + /** + * Clears authorization tokens. Call this to effectively log out user from fetch interceptor. + */ + clear() { + this.accessTokenProvider.clear(); + } + + /** + * Main intercept method, you should chain this inside wrapped fetch call + * @param args Args initially provided to fetch method + * @returns {Promise} Promise which resolves the same way as fetch would + */ + intercept(...args) { + return new Promise((resolve, reject) => this.resolveIntercept(resolve, reject, ...args)); + } + + isConfigValid() { + return this.config.shouldIntercept && + this.config.authorizeRequest && + this.config.createAccessTokenRequest && + this.config.parseAccessToken; + } + + resolveIntercept(resolve, reject, ...args) { + const request = new Request(...args); + const { accessToken } = this.accessTokenProvider.getAuthorization(); + const requestUnit = this.createRequestUnit(request, resolve, reject); + + // if access token is not resolved yet + if (!accessToken) { + return this.accessTokenProvider + .renew() + .then(() => this.fetchWithRetry(requestUnit)) + .catch(reject); + } + + // attempt normal fetch operation + return this.fetchWithRetry(requestUnit) + .catch(reject); + } + + fetchWithRetry(requestUnit) { + // prepare initial request unit + return Promise.resolve(requestUnit) + // resolve should intercept flag, when false, step is skipped + .then(this.shouldIntercept) + // authorize request + .then(this.authorizeRequest) + // last minute check if fetch should be performed + // this is as close as it gets to canceling events since + // fetch spec does not support cancel at the moment + .then(this.shouldFetch) + // perform fetch + .then(this.fetchRequest) + // check if response invalidates current access token + .then(this.shouldInvalidateAccessToken) + // perform token invalidation if neccessary + .then(this.invalidateAccessToken) + // handle unauthorized response by requesting a new access token and + // repeating a request + .then(this.handleResponse) + .catch(this.handleUnauthorizedRequest); + } + + createRequestUnit(request, fetchResolve, fetchReject) { + return { + request, + response: null, + shouldIntercept: false, + shouldInvalidateAccessToken: false, + shouldFetch: true, + accessToken: null, + fetchCount: 0, + fetchResolve, + fetchReject, + } + } + + shouldIntercept(requestUnit) { + const { request } = requestUnit; + const { shouldIntercept } = this.config; + + return Promise.resolve(shouldIntercept(request)) + .then(shouldIntercept => + ({ ...requestUnit, shouldIntercept }) + ); + } + + authorizeRequest(requestUnit) { + const { shouldIntercept } = requestUnit; + + if (!shouldIntercept) { + return requestUnit; + } + + const { request } = requestUnit; + const { accessToken } = this.accessTokenProvider.getAuthorization(); + const { authorizeRequest } = this.config; + + if (request && accessToken){ + return Promise.resolve(authorizeRequest(request, accessToken)) + .then(request => + ({ ...requestUnit, accessToken, request }) + ); + } + + return requestUnit; + } + + shouldFetch(requestUnit) { + const { request } = requestUnit; + const { shouldFetch } = this.config; + + // verifies all outside conditions from config are met + if (!shouldFetch) { + return requestUnit; + } + + return Promise.resolve(shouldFetch(request)) + .then(shouldFetch => + ({ ...requestUnit, shouldFetch }) + ); + } + + fetchRequest(requestUnit) { + const { shouldFetch } = requestUnit; + + if (!shouldFetch) { + return requestUnit; + } + + const { request, fetchCount } = requestUnit; + const { fetchRetryCount } = this.config; + + // verifies that retry count has not been exceeded + if (fetchCount > fetchRetryCount) { + throw new RetryCountExceededException(requestUnit); + } + + const { fetch } = this; + return Promise.resolve(fetch(request)) + .then(response => + ({ + ...requestUnit, + response, + fetchCount: fetchCount + 1, + }) + ); + } + + shouldInvalidateAccessToken(requestUnit) { + const { shouldIntercept } = requestUnit; + const { shouldInvalidateAccessToken } = this.config; + + if (!shouldIntercept || !shouldInvalidateAccessToken) { + return requestUnit; + } + + const { response } = requestUnit; + // check if response invalidates access token + return Promise.resolve(shouldInvalidateAccessToken(response)) + .then(shouldInvalidateAccessToken => + ({ ...requestUnit, shouldInvalidateAccessToken }) + ); + } + + invalidateAccessToken(requestUnit) { + const { shouldIntercept, shouldInvalidateAccessToken } = requestUnit; + + if (!shouldIntercept || !shouldInvalidateAccessToken) { + return requestUnit; + } + + this.accessTokenProvider.renew(); + + return requestUnit; + } + + handleResponse(requestUnit) { + const { shouldIntercept, response, fetchResolve, fetchReject } = requestUnit; + + // can only be empty on network errors + if (!response) { + fetchReject(); + return; + } + + if (shouldIntercept && isResponseUnauthorized(response)) { + throw new TokenExpiredException({ ...requestUnit }) + } + + if (this.config.onResponse) { + this.config.onResponse(response); + } + + return fetchResolve(response); + } + + handleUnauthorizedRequest(error) { + // if expired token, we try to resolve it and retry operation + if (error instanceof TokenExpiredException) { + const { requestUnit } = error; + const { fetchReject } = requestUnit; + + return Promise.resolve(this.accessTokenProvider.renew()) + .then(() => this.fetchWithRetry(requestUnit)) + .catch(fetchReject); + } + + // if we failed to resolve token we just pass the last response + if (error instanceof RetryCountExceededException) { + const { requestUnit } = error; + const { response, fetchResolve } = requestUnit; + + if (this.config.onResponse) { + this.config.onResponse(response); + } + + return fetchResolve(response); + } + + // cannot be handled here + throw new Error(error); + } +} diff --git a/src/const.js b/src/const.js new file mode 100644 index 0000000..3967950 --- /dev/null +++ b/src/const.js @@ -0,0 +1 @@ +export const ERROR_INVALID_CONFIG = 'invalid-config'; diff --git a/src/helpers/tokenFormatter.js b/src/helpers/tokenFormatter.js new file mode 100644 index 0000000..34803f7 --- /dev/null +++ b/src/helpers/tokenFormatter.js @@ -0,0 +1,20 @@ +const bearerRegex = /^Bearer (.+)$/; + +export function parseBearer(authorizationHeaderValue) { + if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { + return null; + } + + const matches = authorizationHeaderValue.match(bearerRegex); + // matches contains whole value and group, we are interested in group part + if (!matches || matches.length < 2) { + return null; + } + + const token = matches[1]; + if (!token) { + return null; + } + + return token; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..cef07e4 --- /dev/null +++ b/src/index.js @@ -0,0 +1,58 @@ +import { + isReactNative, + isWorker, + isWeb, + isNode, +} from './services/environment'; +import FetchInterceptor from './FetchInterceptor'; + +let interceptor = null; + +function init() { + if (isReactNative()) { + attach(global); + } else if (isWorker()) { + attach(self); + } else if (isWeb()) { + attach(window); + } else if (isNode()) { + attach(global); + } else { + throw new Error('Unsupported environment for fetch-token-intercept'); + } +} + +export function attach(env) { + if (!env.fetch) { + throw Error('No fetch available. Unable to register fetch-token-intercept'); + } + + if (interceptor) { + throw Error('You should attach only once.'); + } + + // for now add default interceptor + interceptor = new FetchInterceptor(env.fetch); + + // monkey patch fetch + const fetchWrapper = fetch => (...args) => interceptor.intercept(...args); + env.fetch = fetchWrapper(env.fetch); +} + +export function configure(config) { + interceptor.configure(config); +} + +export function authorize(...args) { + interceptor.authorize(...args); +} + +export function getAuthorization() { + return interceptor.getAuthorization(); +} + +export function clear() { + return interceptor.clear(); +} + +init(); diff --git a/src/services/RetryCountExceededException.js b/src/services/RetryCountExceededException.js new file mode 100644 index 0000000..7e43b64 --- /dev/null +++ b/src/services/RetryCountExceededException.js @@ -0,0 +1,14 @@ +export default class RetryCountExceededException extends Error { + constructor(requestUnit) { + super('Retry count has been exceeded'); + this.name = this.constructor.name; + this.requestUnit = requestUnit; + + // Use V8's native method if available, otherwise fallback + if ("captureStackTrace" in Error) { + Error.captureStackTrace(this, RetryCountExceededException); + } else { + this.stack = (new Error()).stack; + } + } +} diff --git a/src/services/TokenExpiredException.js b/src/services/TokenExpiredException.js new file mode 100644 index 0000000..f337d07 --- /dev/null +++ b/src/services/TokenExpiredException.js @@ -0,0 +1,14 @@ +export default class TokenExpiredException extends Error { + constructor(requestUnit) { + super('Access token has expired'); + this.requestUnit = requestUnit; + this.name = this.constructor.name; + + // Use V8's native method if available, otherwise fallback + if ("captureStackTrace" in Error) { + Error.captureStackTrace(this, TokenExpiredException); + } else { + this.stack = (new Error()).stack; + } + } +} diff --git a/src/services/environment.js b/src/services/environment.js new file mode 100644 index 0000000..6e4ce4c --- /dev/null +++ b/src/services/environment.js @@ -0,0 +1,16 @@ +// Uses Emscripten stategy for determining environment +export function isReactNative() { + return typeof navigator === 'object' && navigator.product === 'ReactNative'; +} + +export function isNode() { + return typeof process === 'object' && typeof require === 'function'; +} + +export function isWeb() { + return typeof window === 'object'; +} + +export function isWorker() { + return typeof importScripts === 'function'; +} diff --git a/src/services/http.js b/src/services/http.js new file mode 100644 index 0000000..1cff947 --- /dev/null +++ b/src/services/http.js @@ -0,0 +1,18 @@ +export const STATUS_UNAUTHORIZED = 401; +export const STATUS_OK = 200; + +function isResponseStatus(response, status) { + if (!response) { + return false; + } + + return response['status'] === status; +} + +export function isResponseOk(response) { + return isResponseStatus(response, STATUS_OK); +} + +export function isResponseUnauthorized(response) { + return isResponseStatus(response, STATUS_UNAUTHORIZED); +} diff --git a/test/fetchInterceptor.spec.js b/test/fetchInterceptor.spec.js new file mode 100644 index 0000000..41c3b37 --- /dev/null +++ b/test/fetchInterceptor.spec.js @@ -0,0 +1,420 @@ +import 'fetch-everywhere'; +import {expect} from 'chai'; +import * as server from './helpers/server'; +import {delayPromise} from './helpers/promiseHelpers'; +import {formatBearer} from './helpers/tokenFormatter'; +import {ERROR_INVALID_CONFIG} from '../src/const'; +import * as fetchInterceptor from '../src/index'; +import sinon from 'sinon'; + +const configuration = config => ({ + fetchRetryCount: 1, + createAccessTokenRequest: refreshToken => + new Request('http://localhost:5000/token', { + headers: { + authorization: `Bearer ${refreshToken}` + } + }), + shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', + parseAccessToken: response => + response.json().then(jsonData => jsonData ? jsonData.accessToken : null), + authorizeRequest: (request, token) => { + request.headers.set('authorization', formatBearer(token)); + return request; + }, + shouldInvalidateAccessToken: null, + onAccessTokenChange: null, + onResponse: null, + ...config, +}); + +describe('fetch-intercept', function () { + describe('configure', () => { + beforeEach(done => { + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + it('throws if shouldIntercept is not set', () => { + const config = configuration({ + shouldIntercept: null, + }); + + expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); + }); + + it('throws if authorizeRequest is not set', () => { + const config = configuration({ + authorizeRequest: null, + }); + + expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); + }); + + it('throws if parseAccessToken is not set', () => { + const config = configuration({ + parseAccessToken: null, + }); + + expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); + }); + + it('throws if createAccessTokenRequest is not set', () => { + const config = configuration({ + createAccessTokenRequest: null, + }); + + expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); + }); + }); + + describe('authorize', function() { + it('sets authorization tokens', function() { + fetchInterceptor.configure(configuration()); + fetchInterceptor.authorize('refreshToken', 'accessToken'); + + const { refreshToken, accessToken } = fetchInterceptor.getAuthorization(); + expect('refreshToken', refreshToken); + expect('accessToken', accessToken); + }); + }); + + describe('clear', function() { + it('removes authorizaton tokens', function() { + fetchInterceptor.configure(configuration()); + fetchInterceptor.authorize('refreshToken', 'accessToken'); + fetchInterceptor.clear(); + + const { refreshToken, accessToken } = fetchInterceptor.getAuthorization(); + expect(refreshToken).to.be.null; + expect(accessToken).to.be.null; + }); + }); + + describe('should not change default fetch behaviour', () => { + describe('server is running', () => { + beforeEach(done => { + fetchInterceptor.configure(configuration()); + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + it('fetch success for 200 response', done => { + fetch('http://localhost:5000/200').then(() => { + done(); + }).catch(err => { + done(err); + }) + }); + + it('fetch success for 401 response', done => { + fetch('http://localhost:5000/401/1').then(() => { + done(); + }).catch(err => { + done(err); + }) + }); + }); + + describe('server is not running', () => { + it('fetch exception on network error', done => { + fetchInterceptor.configure(configuration()); + + fetch('http://localhost:5000/401/1').then(() => { + done('Should not end here'); + }).catch(() => { + done(); + }) + }); + + it('fetch exception on network error with intercept disabled', done => { + fetchInterceptor.configure(configuration({ shouldIntercept: () => false })); + + fetch('http://localhost:5000/401/1').then(() => { + done('Should not end here'); + }).catch(() => { + done(); + }) + }); + }); + }); + + describe('request headers', () => { + beforeEach(done => { + fetchInterceptor.configure(configuration()); + fetchInterceptor.authorize('refresh_token', 'token2'); + + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + it('should keep existing headers on request', function (done) { + fetch('http://localhost:5000/headers', { + headers: { + 'x-header': 'x-value' + } + }).then(response => { + expect(response.status).to.be.equal(200); + return response.json(); + }).then(data => { + expect(data['x-header']).to.exist; + expect(data['x-header']).to.be.equal('x-value'); + done(); + }).catch(error => { + done(error); + }); + }); + + it('should override authorization header', function (done) { + fetch('http://localhost:5000/headers', { + headers: { + 'authorization': 'test-authorization' + } + }).then(response => { + expect(response.status).to.be.equal(200); + return response.json(); + }).then(data => { + expect(data['authorization']).to.exist; + expect(data['authorization']).to.be.equal('Bearer token2'); + done(); + }).catch(error => { + done(error); + }); + }); + }) + + describe('refresh token is valid', () => { + beforeEach(done => { + fetchInterceptor.configure(configuration()); + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + it('should fetch successfully with access token empty', function (done) { + fetchInterceptor.authorize('refresh_token'); + + fetch('http://localhost:5000/401/1').then(response => { + expect(response.status).to.be.equal(200); + return response.json(); + }) + .then(data => { + expect(data.value).to.be.equal('1'); + done(); + }) + .catch(error => { + done(error); + }); + }); + + it('should fetch successfully with access token expired', function (done) { + // set expired access token + fetchInterceptor.authorize('refresh_token', 'token1'); + + fetch('http://localhost:5000/401/1').then(response => { + expect(response.status).to.be.equal(200); + return response.json(); + }) + .then(data => { + expect(data.value).to.be.equal('1'); + done(); + }) + .catch(error => { + done(error); + }); + }); + + it('should fetch successfully with access token valid', function (done) { + fetchInterceptor.authorize('refresh_token', 'token2'); + + fetch('http://localhost:5000/200') + .then((response) => { + expect(response.status).to.be.equal(200); + done(); + }) + .catch(error => { + done(error); + }); + }); + + it('should fetch successfully when access token is invalidated from response', function (done) { + // set expired access token + fetchInterceptor.configure(configuration({ shouldInvalidateAccessToken: () => true })); + fetchInterceptor.authorize('refresh_token', 'token2'); + + fetch('http://localhost:5000/401/1').then(response => { + expect(response.status).to.be.equal(200); + return response.json(); + }) + .then(data => { + expect(data.value).to.be.equal('1'); + done(); + }) + .catch(error => { + done(error); + }); + }); + + it('should fetch multiple simultaneous requests successfully with access token expired', function (done) { + // set expired access token + fetchInterceptor.authorize('refresh_token', 'token1'); + + Promise.all([ + fetch('http://localhost:5000/401/1?duration=100'), + fetch('http://localhost:5000/401/2?duration=300'), + fetch('http://localhost:5000/401/3?duration=100'), + ]) + .then(results => { + return {first: results[0], second: results[1], third: results[2]} + }) + .then(responses => { + expect(responses.first.status).to.be.equal(200); + expect(responses.second.status).to.be.equal(200); + expect(responses.third.status).to.be.equal(200); + + done(); + }) + .catch(error => { + done(error); + }); + }); + + it('should fetch multiple requests successfully with access token expired', function (done) { + // set expired access token + fetchInterceptor.authorize('refresh_token', 'token1'); + + Promise.all([ + fetch('http://localhost:5000/401/1?duration=100'), + Promise.resolve(delayPromise(100)).then(() => fetch('http://localhost:5000/401/2?duration=300')), + Promise.resolve(delayPromise(200)).then(() => fetch('http://localhost:5000/401/2?duration=100')), + ]) + .then(results => { + return {first: results[0], second: results[1], third: results[2]} + }) + .then(responses => { + + expect(responses.first.status).to.be.equal(200); + expect(responses.second.status).to.be.equal(200); + expect(responses.third.status).to.be.equal(200); + + done(); + }) + .catch(error => { + done(error); + }); + }); + + it('should stop after retry count is exceeded and resolve unauthorized', function(done) { + const config = configuration({ + createAccessTokenRequest: refreshToken => + new Request('http://localhost:5000/token?invalid=true', { + headers: { + authorization: `Bearer ${refreshToken}` + } + }), + fetchRetryCount: 5, + }); + fetchInterceptor.configure(config); + fetchInterceptor.authorize('refresh_token', 'token1'); + + fetch('http://localhost:5000/401/1').then(response => { + expect(response.status).to.be.equal(401); + done(); + }) + .catch(error => { + done(error); + }); + }); + + it('should stop after retry count is exceeded with onResponse called once', function(done) { + const onResponse = sinon.spy(); + const config = configuration({ + createAccessTokenRequest: refreshToken => + new Request('http://localhost:5000/token?invalid=true', { + headers: { + authorization: `Bearer ${refreshToken}` + } + }), + fetchRetryCount: 5, + onResponse, + }); + fetchInterceptor.configure(config); + fetchInterceptor.authorize('refresh_token', 'token1'); + + fetch('http://localhost:5000/401/1').then(response => { + expect(response.status).to.be.equal(401); + sinon.assert.calledOnce(onResponse); + done(); + }) + .catch(error => { + done(error); + }); + }); + }); + + describe('refresh token is invalid', () => { + beforeEach(done => { + fetchInterceptor.configure(configuration()); + fetchInterceptor.authorize('invalid_refresh_token'); + + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + it('should propagate 401 for single request', function (done) { + fetch('http://localhost:5000/401/1').then(response => { + const { refreshToken, accessToken } = fetchInterceptor.getAuthorization(); + + expect(response.status).to.be.equal(401); + + expect(refreshToken).to.be.null; + expect(accessToken).to.be.null; + + done(); + }) + .catch((error) => { + done(error); + }); + }); + + it('should propagate 401 for multiple requests', function (done) { + Promise.all([ + fetch('http://localhost:5000/401/1?duration=100'), + fetch('http://localhost:5000/401/2?duration=300'), + fetch('http://localhost:5000/401/3?duration=100'), + ]) + .then(results => { + return {first: results[0], second: results[1], third: results[2]} + }) + .then(responses => { + expect(responses.first.status).to.be.equal(401); + expect(responses.second.status).to.be.equal(401); + expect(responses.third.status).to.be.equal(401); + + const tokens = fetchInterceptor.getAuthorization(); + + expect(tokens.accessToken).to.be.null; + expect(tokens.refreshToken).to.be.null; + + done(); + }) + .catch(error => { + done(error); + }); + }); + }); +}); diff --git a/test/helpers/promiseHelpers.js b/test/helpers/promiseHelpers.js new file mode 100644 index 0000000..80ddc0c --- /dev/null +++ b/test/helpers/promiseHelpers.js @@ -0,0 +1,9 @@ +export function delayPromise(duration) { + return function(...args){ + return new Promise(function(resolve){ + setTimeout(function(){ + resolve(...args); + }, duration) + }); + }; +} diff --git a/test/helpers/server.js b/test/helpers/server.js new file mode 100644 index 0000000..767af14 --- /dev/null +++ b/test/helpers/server.js @@ -0,0 +1,73 @@ +import express from 'express'; + +let app = express(); +let server = null; + +const EXPIRED_TOKEN = 'token1'; +const VALID_TOKEN = 'token2'; + +let currentToken = EXPIRED_TOKEN; +let currentRefreshToken = 'refresh_token'; + +app.get('/200', function(req, res) { + res.send(); +}); + +app.get('/401/:id', function(req, res) { + const response = () => { + const token = req.header('authorization') && req.header('authorization').split(' ')[1]; + + if (token === EXPIRED_TOKEN) { + res.status(401).send(); + } else if (token === VALID_TOKEN) { + if (req.query.invalidate){ + res.set('invalidates-token', true); + } + + res.json({ 'value': req.params.id }); + } else { + res.status(401).send(); + } + }; + + const duration = req.query.duration || 0; + if (duration === 0) { + response(); + } else { + setTimeout(response, duration); + } +}); + +app.get('/headers', function(req, res) { + res.json(req.headers); +}); + +app.get('/token', function(req, res) { + const response = () => { + // exchange refresh token for new access token + if (req.header('authorization') === `Bearer ${currentRefreshToken}`){ + currentToken = req.query.invalid ? 'invalid_token' : VALID_TOKEN; + + res.json({ + 'accessToken': currentToken, + }); + } else { + res.status(401).send(); + } + }; + + const duration = req.query.duration || 0; + if (duration === 0) { + response(); + } else { + setTimeout(response, duration); + } +}); + +export function start(done) { + server = app.listen(5000, done); +} + +export function stop(done) { + server.close(done); +} diff --git a/test/helpers/tokenFormatter.js b/test/helpers/tokenFormatter.js new file mode 100644 index 0000000..1e09774 --- /dev/null +++ b/test/helpers/tokenFormatter.js @@ -0,0 +1,7 @@ +export function formatBearer(token) { + if (!token) { + return null; + } + + return `Bearer ${token}`; +} diff --git a/test/helpers/tokenFormatter.spec.js b/test/helpers/tokenFormatter.spec.js new file mode 100644 index 0000000..cc91f8c --- /dev/null +++ b/test/helpers/tokenFormatter.spec.js @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { parseBearer } from '../../src/helpers/tokenFormatter'; +import { formatBearer } from '../helpers/tokenFormatter'; + +describe('token formatter', () => { + describe('formatBearer', () => { + it('should return null on empty value', () => { + const result = formatBearer(); + expect(result).to.be.null; + }); + + it('should return formatted value for header', () => { + const result = formatBearer('token'); + + expect(result).not.to.be.null; + expect(result).to.be.equal('Bearer token'); + }) + }); + + describe('parseBearer', () => { + it('should return null on empty header value', () => { + const result = parseBearer(); + + expect(result).to.be.null; + }); + + it('should return null on invalid header parts', () => { + const result = parseBearer('bearervalue'); + + expect(result).to.be.null; + }); + + it('should return token value', () => { + const result = parseBearer('Bearer token'); + + expect(result).to.not.be.null; + expect(result).to.be.equal('token'); + }); + }) +});