diff --git a/README.md b/README.md index 5c6564e..aa7559f 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ config: { ``` All required methods return a promise to enable reading of request or response 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 +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 @@ -117,6 +117,10 @@ to stop fetch interception. Clears all tokens from interceptor. + `unload()` + + Completely unloads the library and restores initial state. + `isResponseUnauthorized(response)` Utility method which determines if given response should be considered unauthorized. diff --git a/package.json b/package.json index 3c32982..29d3ce6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shoutem/fetch-token-intercept", - "version": "0.2.2", + "version": "0.3.0", "description": "Fetch interceptor for managing refresh token flow.", "main": "lib/index.js", "files": [ @@ -57,5 +57,7 @@ "nock": "^8.0.0", "sinon": "^1.17.4" }, - "dependencies": {} + "dependencies": { + "lodash": "^4.17.4" + } } diff --git a/src/AccessTokenProvider.js b/src/AccessTokenProvider.js index a880a5d..7d1a952 100644 --- a/src/AccessTokenProvider.js +++ b/src/AccessTokenProvider.js @@ -5,7 +5,7 @@ * 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 { +class AccessTokenProvider { constructor(fetch, config) { this.fetch = fetch; @@ -29,6 +29,13 @@ export default class AccessTokenProvider { this.handleError = this.handleError.bind(this); } + /** + * Configures access token provider + */ + configure(config) { + this.config = { ...this.config, ...config }; + } + /** * Renews current access token with provided refresh token */ @@ -120,3 +127,5 @@ export default class AccessTokenProvider { .catch(error => this.handleError(error, reject)); } } + +export default AccessTokenProvider; diff --git a/src/FetchInterceptor.js b/src/FetchInterceptor.js index ff015fb..8b70595 100644 --- a/src/FetchInterceptor.js +++ b/src/FetchInterceptor.js @@ -1,3 +1,4 @@ +import isFunction from 'lodash/isFunction'; import { ERROR_INVALID_CONFIG, } from './const'; @@ -6,26 +7,70 @@ import TokenExpiredException from './services/TokenExpiredException'; import RetryCountExceededException from './services/RetryCountExceededException'; import AccessTokenProvider from './AccessTokenProvider'; +/** + * Prepares signed request object which can be used for renewing access token + * + * @callback createAccessTokenRequest + * @param {string} refreshToken Refresh token used to sign the request + * @returns {Request} Signed request object which can be used to get access token + */ + +/** + * Parses access token from access token response object + * + * @callback parseAccessToken + * @param {Response} response Response object with access token + * @returns {string} Access token parsed from response + */ + +/** + * Checks whether interceptor will intercept this request or just let it pass through + * + * @callback shouldIntercept + * @param {Request} request Request object + * @returns {bool} A value indicating whether this request should be intercepted + */ + +/** + * Checks whether provided response invalidates current access token + * + * @callback shouldInvalidateAccessToken + * @param {Response} response Response object + * @returns {bool} A value indicating whether token should be invalidated + */ + +/** + * Adds authorization for intercepted requests + * + * @callback authorizeRequest + * @param {Request} request Request object being intercepted + * @param {string} accessToken Current access token + * @returns {Request} Authorized request object + */ + +const getDefaultConfig = () => ({ + fetchRetryCount: 1, + createAccessTokenRequest: null, + shouldIntercept: () => false, + shouldInvalidateAccessToken: () => false, + isResponseUnauthorized: http.isResponseUnauthorized, + parseAccessToken: null, + authorizeRequest: null, + onAccessTokenChange: null, + onResponse: null, +}); + /** * 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 { +class FetchInterceptor { constructor(fetch) { // stores reference to vanilla fetch method this.fetch = fetch; + this.accessTokenProvider = new AccessTokenProvider(this.fetch); - this.config = { - fetchRetryCount: 1, - createAccessTokenRequest: null, - shouldIntercept: () => true, - shouldInvalidateAccessToken: () => false, - isResponseUnauthorized: http.isResponseUnauthorized, - parseAccessToken: null, - authorizeRequest: null, - onAccessTokenChange: null, - onResponse: null, - }; + this.config = getDefaultConfig(); this.intercept = this.intercept.bind(this); @@ -48,37 +93,28 @@ export default class FetchInterceptor { * 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, accessToken) => authorizedRequest, - * - * Checks if response should be considered unauthorized (by default only 401 responses are - * considered unauthorized. Override this method if you need to trigger token renewal for - * other response statuses. - * isResponseUnauthorized: (response) => boolean, - * - * 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, - * + * @param {object} config + * @param {createAccessTokenRequest} config.createAccessTokenRequest + * Prepare fetch request for renewing new access token + * @param {parseAccessToken} config.parseAccessToken + * Parses access token from access token response + * @param {shouldIntercept} config.shouldIntercept + * Defines whether interceptor will intercept this request or just let it pass through + * @param {shouldInvalidateAccessToken} config.shouldInvalidateAccessToken + * Defines whether access token will be invalidated after this response + * @param {authorizeRequest} config.authorizeRequest + * Adds authorization for intercepted requests + * @param {function} [config.isResponseUnauthorized=null] + * Checks if response should be considered unauthorized (by default only 401 responses are + * considered unauthorized. Override this method if you need to trigger token renewal for + * other response statuses. + * @param {number} [config.fetchRetryCount=1] + * Number of retries after initial request was unauthorized + * @param {number} [config.onAccessTokenChange=null] + * Event invoked when access token has changed + * @param {number} [config.onResponse=null] + * Event invoked when response is resolved + * */ configure(config) { this.config = { ...this.config, ...config }; @@ -87,13 +123,13 @@ export default class FetchInterceptor { throw new Error(ERROR_INVALID_CONFIG); } - this.accessTokenProvider = new AccessTokenProvider(this.fetch, this.config); + this.accessTokenProvider.configure(this.config); } /** * Authorizes fetch interceptor with given refresh token - * @param refreshToken - * @param accessToken + * @param {string} refreshToken Refresh token + * @param {string} accessToken Access token */ authorize(refreshToken, accessToken) { this.accessTokenProvider.authorize(refreshToken, accessToken); @@ -114,6 +150,15 @@ export default class FetchInterceptor { this.accessTokenProvider.clear(); } + /** + * Clears current authorization and restores default configuration, e.g. interceptor + * will stop intercepting requests. + */ + unload() { + this.clear(); + this.config = getDefaultConfig(); + } + /** * Main intercept method, you should chain this inside wrapped fetch call * @param args Args initially provided to fetch method @@ -124,10 +169,11 @@ export default class FetchInterceptor { } isConfigValid() { - return this.config.shouldIntercept && - this.config.authorizeRequest && - this.config.createAccessTokenRequest && - this.config.parseAccessToken; + return this.config.shouldIntercept && isFunction(this.config.shouldIntercept) && + this.config.authorizeRequest && isFunction(this.config.authorizeRequest) && + this.config.isResponseUnauthorized && isFunction(this.config.isResponseUnauthorized) && + this.config.createAccessTokenRequest && isFunction(this.config.createAccessTokenRequest) && + this.config.parseAccessToken && isFunction(this.config.parseAccessToken); } resolveIntercept(resolve, reject, ...args) { @@ -357,3 +403,5 @@ export default class FetchInterceptor { throw new Error(error); } } + +export default FetchInterceptor; diff --git a/src/index.js b/src/index.js index e620a5f..ae66683 100644 --- a/src/index.js +++ b/src/index.js @@ -1,13 +1,11 @@ import { - isReactNative, - isWorker, - isWeb, - isNode, + resolveEnvironment, } from './services/environment'; import { isResponseUnauthorized } from './services/http'; import FetchInterceptor from './FetchInterceptor'; let interceptor = null; +let environment = null; export function attach(env) { if (!env.fetch) { @@ -28,38 +26,71 @@ export function attach(env) { env.fetch = fetchWrapper(env.fetch); } -function init() { - if (isReactNative()) { - attach(global); - } else if (isWorker()) { - attach(self); - } else if (isWeb()) { - attach(window); - } else if (isNode()) { - attach(global); - } else { +function initialize() { + environment = resolveEnvironment(); + if (!environment) { throw new Error('Unsupported environment for fetch-token-intercept'); } + + attach(environment); } +/** + * Initializes and configures interceptor + * @param config Configuration object + * @see FetchInterceptor#configure + */ export function configure(config) { + if (!interceptor) { + initialize(); + } + interceptor.configure(config); } +/** + * Initializes tokens which will be used by interceptor + * @param args + * @see FetchInterceptor#authorize + */ export function authorize(...args) { interceptor.authorize(...args); } +/** + * Returns current set of tokens used by interceptor + * @returns {{accessToken: string, refreshToken: string}|*} + */ export function getAuthorization() { return interceptor.getAuthorization(); } +/** + * Clears authorization tokens from interceptor + */ export function clear() { return interceptor.clear(); } +/** + * Gets a value indicating whether interceptor is currently active + * @returns {boolean} + */ +export function isActive() { + return !!interceptor; +} + +/** + * Removes interceptor and restores default behaviour + */ +export function unload() { + if (interceptor) { + interceptor.unload(); + } +} + export { isResponseUnauthorized, }; -init(); +initialize(); diff --git a/src/services/RetryCountExceededException.js b/src/services/RetryCountExceededException.js index 0990ca0..11c142b 100644 --- a/src/services/RetryCountExceededException.js +++ b/src/services/RetryCountExceededException.js @@ -5,7 +5,7 @@ export default class RetryCountExceededException extends Error { this.requestContext = requestContext; // Use V8's native method if available, otherwise fallback - if ("captureStackTrace" in Error) { + 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 index 806a618..29608e3 100644 --- a/src/services/TokenExpiredException.js +++ b/src/services/TokenExpiredException.js @@ -5,7 +5,7 @@ export default class TokenExpiredException extends Error { this.name = this.constructor.name; // Use V8's native method if available, otherwise fallback - if ("captureStackTrace" in Error) { + 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 index 6e4ce4c..69536a4 100644 --- a/src/services/environment.js +++ b/src/services/environment.js @@ -14,3 +14,20 @@ export function isWeb() { export function isWorker() { return typeof importScripts === 'function'; } + +export function resolveEnvironment() { + if (isReactNative()) { + return global; + } + if (isWorker()) { + return self; + } + if (isWeb()) { + return window; + } + if (isNode()) { + return global; + } + + return null; +} diff --git a/src/services/http.js b/src/services/http.js index fe352a4..37785d9 100644 --- a/src/services/http.js +++ b/src/services/http.js @@ -1,6 +1,12 @@ export const STATUS_UNAUTHORIZED = 401; export const STATUS_OK = 200; +/** + * Checks if response status matches the provided status + * @param response Response object + * @param status Query status + * @returns {boolean} Value indicating whether response status matches query status + */ function isResponseStatus(response, status) { if (!response) { return false; diff --git a/test/fetchInterceptor.spec.js b/test/FetchInterceptor.spec.js similarity index 91% rename from test/fetchInterceptor.spec.js rename to test/FetchInterceptor.spec.js index 516a3fa..55112b5 100644 --- a/test/fetchInterceptor.spec.js +++ b/test/FetchInterceptor.spec.js @@ -1,33 +1,12 @@ import 'fetch-everywhere'; -import {expect} from 'chai'; +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 { delayPromise } from './helpers/promiseHelpers'; +import configuration from './helpers/defaultConfigFactory'; +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; - }, - onAccessTokenChange: null, - onResponse: null, - isResponseUnauthorized: response => response.status === 401, - ...config, -}); - describe('fetch-intercept', function () { describe('configure', () => { beforeEach(done => { @@ -69,6 +48,15 @@ describe('fetch-intercept', function () { expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); }); + + it('should gracefully handle requests when not configured', done => { + fetch('http://localhost:5000/200').then((response) => { + expect(response.status).to.be.equal(200); + done(); + }).catch(err => { + done(err); + }) + }) }); describe('authorize', function() { @@ -94,6 +82,35 @@ describe('fetch-intercept', function () { }); }); + describe('unload', function () { + beforeEach(done => { + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + it('should clear authorization tokens and stop intercepting requests', done => { + fetchInterceptor.configure(configuration()); + fetchInterceptor.authorize('refreshToken', 'accessToken'); + fetchInterceptor.unload(); + + // assert that authorization has been cleared + const { refreshToken, accessToken } = fetchInterceptor.getAuthorization(); + expect(refreshToken).to.be.null; + expect(accessToken).to.be.null; + + // assert that fetch now works ok without interceptor + fetch('http://localhost:5000/200').then((response) => { + expect(response.status).to.be.equal(200); + done(); + }).catch(err => { + done(err); + }); + }); + }); + describe('should not change default fetch behaviour', () => { describe('server is running', () => { beforeEach(done => { diff --git a/test/helpers/defaultConfigFactory.js b/test/helpers/defaultConfigFactory.js new file mode 100644 index 0000000..7543c26 --- /dev/null +++ b/test/helpers/defaultConfigFactory.js @@ -0,0 +1,24 @@ +import { formatBearer } from './tokenFormatter'; + +export default function(config) { + return { + 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; + }, + onAccessTokenChange: null, + onResponse: null, + isResponseUnauthorized: response => response.status === 401, + ...config, + }; +} diff --git a/test/index.spec.js b/test/index.spec.js new file mode 100644 index 0000000..2cc2f74 --- /dev/null +++ b/test/index.spec.js @@ -0,0 +1,56 @@ +import 'fetch-everywhere'; +import { expect } from 'chai'; +import * as server from './helpers/server'; +import config from './helpers/defaultConfigFactory'; +import * as fetchInterceptor from '../src'; + +describe('index', function() { + beforeEach(done => { + fetchInterceptor.unload(); + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + describe('configure', function() { + it('should initialize interceptor', function (){ + fetchInterceptor.configure(config()); + + expect(fetchInterceptor.isActive()).to.not.be.empty; + }); + }); + + describe('getAuthorization', function() { + it('should return empty authorization', function (){ + fetchInterceptor.configure(config()); + + const { refreshToken, accessToken } = fetchInterceptor.getAuthorization(); + + expect(refreshToken).to.be.null; + expect(accessToken).to.be.null; + }); + + it('should return empty authorization after clearing', function (){ + fetchInterceptor.configure(config()); + fetchInterceptor.authorize('refresh-token', 'access-token'); + fetchInterceptor.clear(); + + const { refreshToken, accessToken } = fetchInterceptor.getAuthorization(); + + expect(refreshToken).to.be.null; + expect(accessToken).to.be.null; + }); + + it('should return authorization keys when authorized', function (){ + fetchInterceptor.configure(config()); + fetchInterceptor.authorize('refresh-token', 'access-token'); + + const { refreshToken, accessToken } = fetchInterceptor.getAuthorization(); + + expect(refreshToken).to.equal('refresh-token'); + expect(accessToken).to.equal('access-token'); + }); + }); +}); diff --git a/test/services/http.spec.js b/test/services/http.spec.js new file mode 100644 index 0000000..6f3049f --- /dev/null +++ b/test/services/http.spec.js @@ -0,0 +1,36 @@ +import 'fetch-everywhere'; +import { expect } from 'chai'; +import * as http from '../../src/services/http'; + +describe('services', () => { + describe('http', () => { + describe('isResponseOk', () => { + it('should return false on empty response', () => { + expect(http.isResponseOk(null)).to.be.false; + }); + + it('should return false on non-OK response', () => { + expect(http.isResponseOk(new Response({}, { status: 401 }))).to.be.false; + }); + + it('should return true on OK response', () => { + expect(http.isResponseOk(new Response({}, { status: 200 }))).to.be.true; + }); + }); + + describe('isResponseUnauthorized', () => { + it('should return false on empty response', () => { + expect(http.isResponseUnauthorized(null)).to.be.false; + }); + + it('should return false on authorized response', () => { + expect(http.isResponseUnauthorized(new Response({}, { status: 200 }))).to.be.false; + }); + + it('should return true on unauthorized response', () => { + expect(http.isResponseUnauthorized(new Response({}, { status: 401 }))).to.be.true; + }); + }); + }); +}); +