From b907192849f11678bf57285de89f4dcb24cb2b9f Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Thu, 9 Feb 2017 13:00:18 +0100 Subject: [PATCH 01/14] Initial implementation --- .babelrc | 5 ++ .editorconfig | 21 ++++++ .eslintignore | 1 + .eslintrc | 25 +++++++ babelTestSetup.js | 5 ++ package.json | 59 ++++++++++++++++ src/index.js | 172 +++++++++++++++++++++++++++++++++++++++++++++ test/index.spec.js | 120 +++++++++++++++++++++++++++++++ test/server.js | 41 +++++++++++ 9 files changed, 449 insertions(+) create mode 100644 .babelrc create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 babelTestSetup.js create mode 100644 package.json create mode 100644 src/index.js create mode 100644 test/index.spec.js create mode 100644 test/server.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..df7d1f0 --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ +{ + presets: ["es2015"], + plugins: ["transform-object-rest-spread"], + 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/babelTestSetup.js b/babelTestSetup.js new file mode 100644 index 0000000..1a2f6ad --- /dev/null +++ b/babelTestSetup.js @@ -0,0 +1,5 @@ +require('babel-register')({ + presets: ['es2015'], + plugins: ['transform-object-rest-spread'], + sourceMaps: 'both', +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..8216614 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "@shoutem/fetch-token-intercept", + "version": "0.0.1", + "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": "" + }, + "keywords": [ + "fetch", + "intercept", + "refresh", + "token", + "access" + ], + "author": "Shoutem", + "license": "MIT", + "bugs": { + "url": "" + }, + "homepage": "", + "devDependencies": { + "babel-cli": "^6.9.0", + "babel-core": "^6.9.1", + "babel-eslint": "^6.0.0", + "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/index.js b/src/index.js new file mode 100644 index 0000000..5a08acd --- /dev/null +++ b/src/index.js @@ -0,0 +1,172 @@ +let config = { + refreshEndpoint: null, +}; + +let tokens = { + accessToken: null, + refreshToken: null, +}; + +let refreshTokenPromise = null; + +// Uses Emscripten stategy for determining environment +const ENVIRONMENT_IS_REACT_NATIVE = typeof navigator === 'object' && navigator.product === 'ReactNative'; +const ENVIRONMENT_IS_NODE = typeof process === 'object' && typeof require === 'function'; +const ENVIRONMENT_IS_WEB = typeof window === 'object'; +const ENVIRONMENT_IS_WORKER = typeof importScripts === 'function'; + +const UNAUTHORIZED = 401; +const OK = 200; + +if (ENVIRONMENT_IS_REACT_NATIVE) { + attach(global); +} else if (ENVIRONMENT_IS_WORKER) { + attach(self); +} else if (ENVIRONMENT_IS_WEB) { + attach(window); +} else if (ENVIRONMENT_IS_NODE) { + attach(global); +} else { + throw new Error('Unsupported environment for fetch-token-intercept'); +} + +function attach(env) { + if (!env.fetch) { + throw Error('No fetch available. Unable to register fetch-token-intercept'); + } + + // monkey patch fetch + env.fetch = (function (fetch) { + return function (...args) { + return interceptor(fetch, ...args); + }; + })(env.fetch); +} + +function runRefreshTokenPromise() { + return new Promise((resolve, reject) => { + // prepare request + const tokenRequest = new Request(config.refreshEndpoint, { + headers: { + Authorization: `Bearer ${tokens.refreshToken}`, + } + }); + + // fetch new token with refresh token + fetch(tokenRequest) + .then(response => { + refreshTokenPromise = null; + + if (response.status !== OK) { + throw new Error('Refresh token expired'); + } + + return response.json(); + }) + // save access token to local config + .then(data => { + tokens.accessToken = data.accessToken; + resolve(data); + }) + .catch(error => { + tokens.accessToken = null; + tokens.refreshToken = null; + + if (config.onUnauthorized) { + config.onUnauthorized(); + } + + reject(error); + }); + }); +} + +function convertToRequest(args) { + const request = new Request(...args); + request.id = Date.now(); + return request; +} + +function addAuthHeader(request) { + return new Request(request, { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }); +} + +function interceptor(fetch, ...args) { + const request = convertToRequest(args); + + // ignore fetch to refresh endpoint + if (request.url === config.refreshEndpoint) { + return fetch(request); + } + + // outer fetch promise + return new Promise((outerResolve, outerReject) => { + // inner promise which includes resolving access token + const runInnerPromise = () => + Promise.resolve(request) + .then(addAuthHeader) + // initial fetch + .then(() => fetch(request)) + .then(response => { + // if response is not unauthorized return results + if (response.status !== UNAUTHORIZED) { + return response; + } + + // check if we're already fetching the token + if (!refreshTokenPromise) { + refreshTokenPromise = runRefreshTokenPromise(); + } + + return refreshTokenPromise + .then(token => addAuthHeader(request)) + // retry fetch + .then(request => { + return fetch(request); + }) + }) + .then(outerResolve) + .catch(outerReject); + + // if refresh token is currently running all incoming fetches should chain to + // on refresh token promise + if (refreshTokenPromise) { + refreshTokenPromise + .then(() => { + return runInnerPromise(); + }); + } + + // otherwise attempt fetch operation + return runInnerPromise(); + }); +} + +/** + * Configures fetch token intercept + */ +export function configure(initConfig) { + Object.assign(config, initConfig); +} + +/** + * Configures current refresh token, refresh token invalidates on rejection + * @param refreshToken + * @param accessToken + */ +export function authorize(refreshToken, accessToken) { + Object.assign(tokens, { refreshToken, accessToken }); +} + +/** + * Returns current authorization for fetch interceptor + * @returns {{accessToken: string, refreshToken: string}} + */ +export function getAuthorization() { + return tokens; +} + diff --git a/test/index.spec.js b/test/index.spec.js new file mode 100644 index 0000000..06f8a28 --- /dev/null +++ b/test/index.spec.js @@ -0,0 +1,120 @@ +import 'fetch-everywhere'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as fetchInterceptor from '../src/index'; +import * as server from './server'; + +describe('fetch-intercept', function () { + + describe('refresh token is valid', () => { + let accessToken = null; + beforeEach(done => { + fetchInterceptor.configure({ + refreshEndpoint: 'http://localhost:5000/token', + onUnauthorized: () => { + console.log('Refresh token is now unauthorized'); + } + }); + + fetchInterceptor.authorize('refresh_token'); + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + it('should fetch successfully with access token valid', function (done) { + fetch('http://localhost:5000/200', { + headers: { authorization: `Bearer ${accessToken}` } + }) + .then((response)=> { + expect(response.status).to.be.equal(200); + done(); + }) + .catch(error => { + done(error); + }); + }); + + it('should fetch successfully with access token empty', function (done) { + 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 multiple resources and retain order', function (done) { + 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); + }); + }); + }); + + describe('refresh token is invalid', () => { + const onUnauthorizedSpy = sinon.spy(); + + before(done => { + fetchInterceptor.configure({ + refreshEndpoint: 'http://localhost:5000/token', + onUnauthorized: onUnauthorizedSpy, + }); + + fetchInterceptor.authorize('invalid_refresh_token'); + + server.start(done); + }); + + after(done => { + server.stop(done); + }); + + it('should propagate 401 with invalid refresh token', function (done) { + fetch('http://localhost:5000/401/1').then(()=> { + done('Should not end up here'); + }) + .catch(() => { + const tokens = fetchInterceptor.getAuthorization(); + + expect(tokens.accessToken).to.be.null; + expect(tokens.refreshToken).to.be.null; + + sinon.assert.calledOnce(onUnauthorizedSpy); + + done(); + }); + }); + }); +}); diff --git a/test/server.js b/test/server.js new file mode 100644 index 0000000..958f5ea --- /dev/null +++ b/test/server.js @@ -0,0 +1,41 @@ +import express from 'express'; + +let app = express(); +let server = null; + +let currentToken = 'token1'; +let currentRefreshToken = 'refresh_token'; + +app.get('/200', function(req, res) { + res.send(); +}); + +app.get('/401/:id', function(req, res) { + const token = req.header('Authorization') && req.header('Authorization').split(' ')[1]; + if (token === 'token1') { + res.status(401).send(); + } else if (token === 'token2') { + res.json({ "value": req.params.id }); + } else { + res.status(401).send(); + } +}); + +app.get('/token', function(req, res) { + if (req.header('Authorization') === `Bearer ${currentRefreshToken}`){ + currentToken = 'token2'; + res.json({ + 'accessToken': currentToken + }) + } else { + res.status(401).send(); + } +}); + +export function start(done) { + server = app.listen(5000, done); +} + +export function stop(done) { + server.close(done); +} From 211c01f5876de3f32b111839f50364591a9a3e0b Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Sun, 12 Feb 2017 13:33:31 +0100 Subject: [PATCH 02/14] Refactoring after initial usage, added more test coverage --- src/const.js | 11 +++ src/helpers/tokenFormatter.js | 17 ++++ src/index.js | 150 ++++++++++++++++++++----------- test/index.spec.js | 160 +++++++++++++++++++++++++++++----- test/promiseHelpers.js | 9 ++ test/server.js | 53 ++++++++--- 6 files changed, 312 insertions(+), 88 deletions(-) create mode 100644 src/const.js create mode 100644 src/helpers/tokenFormatter.js create mode 100644 test/promiseHelpers.js diff --git a/src/const.js b/src/const.js new file mode 100644 index 0000000..a45cb77 --- /dev/null +++ b/src/const.js @@ -0,0 +1,11 @@ +// Uses Emscripten stategy for determining environment +export const ENVIRONMENT_IS_REACT_NATIVE = typeof navigator === 'object' && navigator.product === 'ReactNative'; +export const ENVIRONMENT_IS_NODE = typeof process === 'object' && typeof require === 'function'; +export const ENVIRONMENT_IS_WEB = typeof window === 'object'; +export const ENVIRONMENT_IS_WORKER = typeof importScripts === 'function'; + +export const ERROR_REFRESH_TOKEN_EXPIRED = 'refresh-token-expired'; +export const ERROR_INVALID_CONFIG = 'invalid-config'; + +export const STATUS_UNAUTHORIZED = 401; +export const STATUS_OK = 200; diff --git a/src/helpers/tokenFormatter.js b/src/helpers/tokenFormatter.js new file mode 100644 index 0000000..1d3882f --- /dev/null +++ b/src/helpers/tokenFormatter.js @@ -0,0 +1,17 @@ +export function formatBearer(token) { + return `Bearer ${token}`; +} + +export function parseBearer(authorizationHeader) { + if (!authorizationHeader) { + return null; + } + + const parts = authorizationHeader.split(' '); + const token = parts === 2 ? parts[1] : null; + if (token === 'undefined') { + return null; + } + + return token; +} diff --git a/src/index.js b/src/index.js index 5a08acd..ee9b2fb 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,20 @@ +import { formatBearer, parseBearer } from './helpers/tokenFormatter'; +import { + ENVIRONMENT_IS_REACT_NATIVE, + ENVIRONMENT_IS_NODE, + ENVIRONMENT_IS_WEB, + ENVIRONMENT_IS_WORKER, + ERROR_REFRESH_TOKEN_EXPIRED, + ERROR_INVALID_CONFIG, + STATUS_UNAUTHORIZED, + STATUS_OK, +} from './const'; + let config = { - refreshEndpoint: null, + prepareRefreshTokenRequest: null, + shouldIntercept: null, + getAccessTokenFromResponse: null, + setRequestAuthorization: null, }; let tokens = { @@ -7,16 +22,7 @@ let tokens = { refreshToken: null, }; -let refreshTokenPromise = null; - -// Uses Emscripten stategy for determining environment -const ENVIRONMENT_IS_REACT_NATIVE = typeof navigator === 'object' && navigator.product === 'ReactNative'; -const ENVIRONMENT_IS_NODE = typeof process === 'object' && typeof require === 'function'; -const ENVIRONMENT_IS_WEB = typeof window === 'object'; -const ENVIRONMENT_IS_WORKER = typeof importScripts === 'function'; - -const UNAUTHORIZED = 401; -const OK = 200; +let refreshAccessTokenPromise = null; if (ENVIRONMENT_IS_REACT_NATIVE) { attach(global); @@ -38,44 +44,44 @@ function attach(env) { // monkey patch fetch env.fetch = (function (fetch) { return function (...args) { - return interceptor(fetch, ...args); + return fetchInterceptor(fetch, ...args); }; })(env.fetch); } function runRefreshTokenPromise() { return new Promise((resolve, reject) => { + console.log('RT_START'); // prepare request - const tokenRequest = new Request(config.refreshEndpoint, { - headers: { - Authorization: `Bearer ${tokens.refreshToken}`, - } - }); + const tokenRequest = config.prepareRefreshTokenRequest(tokens.refreshToken); // fetch new token with refresh token fetch(tokenRequest) .then(response => { - refreshTokenPromise = null; + console.log('RT_END'); + refreshAccessTokenPromise = null; + + if (response.status !== STATUS_OK) { + throw new Error(ERROR_REFRESH_TOKEN_EXPIRED); + } - if (response.status !== OK) { - throw new Error('Refresh token expired'); + if (!config.getAccessTokenFromResponse) { + throw new Error(ERROR_INVALID_CONFIG); } - return response.json(); + return config.getAccessTokenFromResponse(response); }) // save access token to local config - .then(data => { - tokens.accessToken = data.accessToken; - resolve(data); + .then(token => { + tokens.accessToken = token; + + resolve(token); }) .catch(error => { + console.log('RT_ERR'); tokens.accessToken = null; tokens.refreshToken = null; - if (config.onUnauthorized) { - config.onUnauthorized(); - } - reject(error); }); }); @@ -87,46 +93,85 @@ function convertToRequest(args) { return request; } -function addAuthHeader(request) { - return new Request(request, { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); +function shouldFetchAccessToken(request) { + // check if we're already fetching the token + if (refreshAccessTokenPromise) { + return false; + } + + const requestAccessToken = parseBearer(request.headers.authorization); + if (requestAccessToken && requestAccessToken !== tokens.accessToken) { + return false; + } + + return true; } -function interceptor(fetch, ...args) { +function fetchInterceptor(fetch, ...args) { const request = convertToRequest(args); - // ignore fetch to refresh endpoint - if (request.url === config.refreshEndpoint) { + if (!config.shouldIntercept) { + throw new Error(ERROR_INVALID_CONFIG); + } + + // check whether we should ignore this request + if (!tokens.refreshToken || !config.shouldIntercept(request)) { return fetch(request); } // outer fetch promise return new Promise((outerResolve, outerReject) => { + if (!config.setRequestAuthorization) { + throw new Error(ERROR_INVALID_CONFIG); + } + // inner promise which includes resolving access token const runInnerPromise = () => Promise.resolve(request) - .then(addAuthHeader) + .then((request) => config.setRequestAuthorization(request, tokens.accessToken)) // initial fetch - .then(() => fetch(request)) + .then(() => { + console.log('REQUEST', request.path, request.headers); + return fetch(request); + }) .then(response => { - // if response is not unauthorized return results - if (response.status !== UNAUTHORIZED) { + // if response is not unauthorized we don't care about it + if (response.status !== STATUS_UNAUTHORIZED) { return response; } - // check if we're already fetching the token - if (!refreshTokenPromise) { - refreshTokenPromise = runRefreshTokenPromise(); + console.log('RESPONSE', response.url); + // if we received unauthorized and current request's token is same as access token + // we should refresh the token. otherwise we should just repeat request since + // some other request already refreshed access token + if (shouldFetchAccessToken(request)) { + refreshAccessTokenPromise = runRefreshTokenPromise(); } - return refreshTokenPromise - .then(token => addAuthHeader(request)) - // retry fetch - .then(request => { - return fetch(request); + // if refresh token promise is null, it already finished before this request + // in that case we just want to continue and repeat this request + const returnPromise = refreshAccessTokenPromise || Promise.resolve(); + + return returnPromise + .then(() => { + // repeat request if tokens don't match + if (parseBearer(request.headers.authorization) !== tokens.accessToken) { + const authorizedRequest = config.setRequestAuthorization(request, tokens.accessToken); + console.log('RETRY', authorizedRequest.url, authorizedRequest.headers); + return fetch(authorizedRequest); + } + // otherwise return initial response + return response; + }) + // fetching refresh token failed + .catch(error => { + // we return the initial response because we failed to refresh token + if (error.message === ERROR_REFRESH_TOKEN_EXPIRED) { + outerResolve(response); + } else { + // otherwise we propagate error out + outerReject(error); + } }) }) .then(outerResolve) @@ -134,8 +179,8 @@ function interceptor(fetch, ...args) { // if refresh token is currently running all incoming fetches should chain to // on refresh token promise - if (refreshTokenPromise) { - refreshTokenPromise + if (refreshAccessTokenPromise) { + refreshAccessTokenPromise .then(() => { return runInnerPromise(); }); @@ -160,10 +205,11 @@ export function configure(initConfig) { */ export function authorize(refreshToken, accessToken) { Object.assign(tokens, { refreshToken, accessToken }); + runRefreshTokenPromise(); } /** - * Returns current authorization for fetch interceptor + * Returns current authorization for fetch fetchInterceptor * @returns {{accessToken: string, refreshToken: string}} */ export function getAuthorization() { diff --git a/test/index.spec.js b/test/index.spec.js index 06f8a28..7025fc2 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -1,8 +1,9 @@ import 'fetch-everywhere'; import { expect } from 'chai'; -import sinon from 'sinon'; -import * as fetchInterceptor from '../src/index'; import * as server from './server'; +import { delayPromise } from './promiseHelpers'; +import { formatBearer } from '../src/helpers/tokenFormatter'; +import * as fetchInterceptor from '../src/index'; describe('fetch-intercept', function () { @@ -10,9 +11,16 @@ describe('fetch-intercept', function () { let accessToken = null; beforeEach(done => { fetchInterceptor.configure({ - refreshEndpoint: 'http://localhost:5000/token', - onUnauthorized: () => { - console.log('Refresh token is now unauthorized'); + prepareRefreshTokenRequest: refreshToken => + new Request('http://localhost:5000/token', { + headers: { authorization: `Bearer ${refreshToken}`} + }), + shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', + getAccessTokenFromResponse: response => + response.json().then(jsonData => jsonData ? jsonData.accessToken : null), + setRequestAuthorization: (request, token) => { + request.headers.set('authorization', formatBearer(token)); + return request; } }); @@ -68,52 +76,158 @@ describe('fetch-intercept', function () { }); }); - it('should fetch multiple resources and retain order', function (done) { - fetch('http://localhost:5000/401/1').then(response => { - expect(response.status).to.be.equal(200); - return response.json(); + 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); + }); + }); + + describe('headers are set', () => { + 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.value).to.be.equal('1'); + 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) { + fetchInterceptor.authorize('refresh_token', 'access_token'); + 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 access_token'); + done(); + }) + .catch(error => { + done(error); + }); + }); + }) }); describe('refresh token is invalid', () => { - const onUnauthorizedSpy = sinon.spy(); - - before(done => { + beforeEach(done => { fetchInterceptor.configure({ - refreshEndpoint: 'http://localhost:5000/token', - onUnauthorized: onUnauthorizedSpy, + prepareRefreshTokenRequest: refreshToken => + new Request('http://localhost:5000/token', { + headers: { authorization: `Bearer ${refreshToken}`} + }), + shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', + getAccessTokenFromResponse: response => + response.json().then(jsonData => jsonData ? jsonData.accessToken : null), + setRequestAuthorization: (request, token) => { + request.headers.set('authorization', formatBearer(token)); + return request; + } }); - fetchInterceptor.authorize('invalid_refresh_token'); server.start(done); }); - after(done => { + afterEach(done => { server.stop(done); }); it('should propagate 401 with invalid refresh token', function (done) { - fetch('http://localhost:5000/401/1').then(()=> { - done('Should not end up here'); - }) - .catch(() => { + fetch('http://localhost:5000/401/1').then(response=> { const tokens = fetchInterceptor.getAuthorization(); + expect(response.status).to.be.equal(401); + expect(tokens.accessToken).to.be.null; expect(tokens.refreshToken).to.be.null; - sinon.assert.calledOnce(onUnauthorizedSpy); + done(); + }) + .catch((error) => { + done(error); + }); + }); + + it('should fetch multiple requests successfully with access token expired', 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); done(); + }) + .catch(error => { + done(error); }); }); }); diff --git a/test/promiseHelpers.js b/test/promiseHelpers.js new file mode 100644 index 0000000..80ddc0c --- /dev/null +++ b/test/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/server.js b/test/server.js index 958f5ea..1ba0382 100644 --- a/test/server.js +++ b/test/server.js @@ -3,7 +3,10 @@ import express from 'express'; let app = express(); let server = null; -let currentToken = 'token1'; +const EXPIRED_TOKEN = 'token1'; +const VALID_TOKEN = 'token2'; + +let currentToken = EXPIRED_TOKEN; let currentRefreshToken = 'refresh_token'; app.get('/200', function(req, res) { @@ -11,24 +14,48 @@ app.get('/200', function(req, res) { }); app.get('/401/:id', function(req, res) { - const token = req.header('Authorization') && req.header('Authorization').split(' ')[1]; - if (token === 'token1') { - res.status(401).send(); - } else if (token === 'token2') { - res.json({ "value": req.params.id }); + 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) { + res.json({ 'value': req.params.id }); + } else { + res.status(401).send(); + } + }; + + const duration = req.query.duration || 0; + if (duration === 0) { + response(); } else { - res.status(401).send(); + setTimeout(response, duration); } }); +app.get('/headers', function(req, res) { + res.json(req.headers); +}); + app.get('/token', function(req, res) { - if (req.header('Authorization') === `Bearer ${currentRefreshToken}`){ - currentToken = 'token2'; - res.json({ - 'accessToken': currentToken - }) + const response = () => { + // exchange refresh token for new access token + if (req.header('authorization') === `Bearer ${currentRefreshToken}`){ + currentToken = VALID_TOKEN; + res.json({ + 'accessToken': currentToken + }) + } else { + res.status(401).send(); + } + }; + + const duration = req.query.duration || 0; + if (duration === 0) { + response(); } else { - res.status(401).send(); + setTimeout(response, duration); } }); From 8f25d1487109997815ac6b0af520f9f7dce62ac9 Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Mon, 13 Feb 2017 00:17:48 +0100 Subject: [PATCH 03/14] Added more tests, revised some configuration options --- src/helpers/tokenFormatter.js | 16 +++- src/index.js | 76 ++++++++++----- test/helpers/tokenFormatter.spec.js | 48 ++++++++++ test/index.spec.js | 140 +++++++++++++++++++++++++++- test/server.js | 7 +- 5 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 test/helpers/tokenFormatter.spec.js diff --git a/src/helpers/tokenFormatter.js b/src/helpers/tokenFormatter.js index 1d3882f..d5972d3 100644 --- a/src/helpers/tokenFormatter.js +++ b/src/helpers/tokenFormatter.js @@ -1,14 +1,22 @@ export function formatBearer(token) { + if (!token) { + return null; + } + return `Bearer ${token}`; } -export function parseBearer(authorizationHeader) { - if (!authorizationHeader) { +export function parseBearer(authorizationHeaderValue) { + if (!authorizationHeaderValue) { + return null; + } + + const parts = authorizationHeaderValue.split(' '); + if(parts.length !== 2) { return null; } - const parts = authorizationHeader.split(' '); - const token = parts === 2 ? parts[1] : null; + const token = parts[1]; if (token === 'undefined') { return null; } diff --git a/src/index.js b/src/index.js index ee9b2fb..71484b7 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import { formatBearer, parseBearer } from './helpers/tokenFormatter'; +import { parseBearer } from './helpers/tokenFormatter'; import { ENVIRONMENT_IS_REACT_NATIVE, ENVIRONMENT_IS_NODE, @@ -13,8 +13,10 @@ import { let config = { prepareRefreshTokenRequest: null, shouldIntercept: null, + shouldInvalidateAccessToken: null, getAccessTokenFromResponse: null, setRequestAuthorization: null, + onAccessTokenAcquired: null, }; let tokens = { @@ -50,15 +52,13 @@ function attach(env) { } function runRefreshTokenPromise() { - return new Promise((resolve, reject) => { - console.log('RT_START'); + refreshAccessTokenPromise = new Promise((resolve, reject) => { // prepare request const tokenRequest = config.prepareRefreshTokenRequest(tokens.refreshToken); // fetch new token with refresh token - fetch(tokenRequest) + return fetch(tokenRequest) .then(response => { - console.log('RT_END'); refreshAccessTokenPromise = null; if (response.status !== STATUS_OK) { @@ -75,16 +75,20 @@ function runRefreshTokenPromise() { .then(token => { tokens.accessToken = token; + if (config.onAccessTokenAcquired) { + config.onAccessTokenAcquired(token); + } + resolve(token); }) .catch(error => { - console.log('RT_ERR'); tokens.accessToken = null; tokens.refreshToken = null; reject(error); }); }); + return refreshAccessTokenPromise; } function convertToRequest(args) { @@ -99,14 +103,18 @@ function shouldFetchAccessToken(request) { return false; } - const requestAccessToken = parseBearer(request.headers.authorization); - if (requestAccessToken && requestAccessToken !== tokens.accessToken) { + const requestAccessToken = parseBearer(request.headers.get('authorization')); + if (requestAccessToken !== tokens.accessToken) { return false; } return true; } +function isAuthorized() { + return !!tokens.refreshToken; +} + function fetchInterceptor(fetch, ...args) { const request = convertToRequest(args); @@ -115,7 +123,7 @@ function fetchInterceptor(fetch, ...args) { } // check whether we should ignore this request - if (!tokens.refreshToken || !config.shouldIntercept(request)) { + if (!isAuthorized() || !config.shouldIntercept(request)) { return fetch(request); } @@ -130,22 +138,24 @@ function fetchInterceptor(fetch, ...args) { Promise.resolve(request) .then((request) => config.setRequestAuthorization(request, tokens.accessToken)) // initial fetch - .then(() => { - console.log('REQUEST', request.path, request.headers); - return fetch(request); - }) + .then(() => fetch(request)) .then(response => { + // check if response invalidates access token + if (config.shouldInvalidateAccessToken && config.shouldInvalidateAccessToken(response)) { + tokens.accessToken = null; + runRefreshTokenPromise(); + } + // if response is not unauthorized we don't care about it if (response.status !== STATUS_UNAUTHORIZED) { return response; } - console.log('RESPONSE', response.url); // if we received unauthorized and current request's token is same as access token // we should refresh the token. otherwise we should just repeat request since // some other request already refreshed access token if (shouldFetchAccessToken(request)) { - refreshAccessTokenPromise = runRefreshTokenPromise(); + runRefreshTokenPromise(); } // if refresh token promise is null, it already finished before this request @@ -155,17 +165,16 @@ function fetchInterceptor(fetch, ...args) { return returnPromise .then(() => { // repeat request if tokens don't match - if (parseBearer(request.headers.authorization) !== tokens.accessToken) { + if (parseBearer(request.headers.get('authorization')) !== tokens.accessToken) { const authorizedRequest = config.setRequestAuthorization(request, tokens.accessToken); - console.log('RETRY', authorizedRequest.url, authorizedRequest.headers); return fetch(authorizedRequest); } + // otherwise return initial response return response; }) // fetching refresh token failed .catch(error => { - // we return the initial response because we failed to refresh token if (error.message === ERROR_REFRESH_TOKEN_EXPIRED) { outerResolve(response); } else { @@ -177,12 +186,30 @@ function fetchInterceptor(fetch, ...args) { .then(outerResolve) .catch(outerReject); - // if refresh token is currently running all incoming fetches should chain to - // on refresh token promise - if (refreshAccessTokenPromise) { - refreshAccessTokenPromise + // if access token is not resolved yet + if (!tokens.accessToken) { + // check if we are alredy fetching it + if (!refreshAccessTokenPromise) { + // as a side-effect refreshTokenPromise is set + runRefreshTokenPromise(); + } + + // chain to refresh token promise (pending) + return refreshAccessTokenPromise .then(() => { return runInnerPromise(); + }) + .catch(error => { + if (error.message === ERROR_INVALID_CONFIG) { + outerReject(error); + } else { + // if we fail to resolve refresh token, and it's not internal error + // just pass the request normally + return fetch(request) + .then(outerResolve) + .catch(outerReject); + } + }); } @@ -195,7 +222,7 @@ function fetchInterceptor(fetch, ...args) { * Configures fetch token intercept */ export function configure(initConfig) { - Object.assign(config, initConfig); + config = { ...config, ...initConfig }; } /** @@ -204,8 +231,7 @@ export function configure(initConfig) { * @param accessToken */ export function authorize(refreshToken, accessToken) { - Object.assign(tokens, { refreshToken, accessToken }); - runRefreshTokenPromise(); + tokens = { ...tokens, refreshToken, accessToken }; } /** diff --git a/test/helpers/tokenFormatter.spec.js b/test/helpers/tokenFormatter.spec.js new file mode 100644 index 0000000..45782e3 --- /dev/null +++ b/test/helpers/tokenFormatter.spec.js @@ -0,0 +1,48 @@ +import 'fetch-everywhere'; +import { expect } from 'chai'; +import { formatBearer, parseBearer } from '../../src/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 null on invalid header value', () => { + const result = parseBearer('bearer undefined'); + + 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'); + }); + }) +}); \ No newline at end of file diff --git a/test/index.spec.js b/test/index.spec.js index 7025fc2..5a50e14 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -3,12 +3,126 @@ import { expect } from 'chai'; import * as server from './server'; import { delayPromise } from './promiseHelpers'; import { formatBearer } from '../src/helpers/tokenFormatter'; +import { ERROR_INVALID_CONFIG } from '../src/const'; import * as fetchInterceptor from '../src/index'; +const emptyConfiguration = { + prepareRefreshTokenRequest: () => {}, + shouldIntercept: request => false, + getAccessTokenFromResponse: () => {}, + setRequestAuthorization: () => {} +}; + describe('fetch-intercept', function () { + describe('should validate config', () => { + beforeEach(done => { + server.start(done); + }); + + afterEach(done => { + server.stop(done); + }); + + it('getAccessTokenFromResponse is not set throws', () => { + fetchInterceptor.configure({ + ...emptyConfiguration, + shouldIntercept: null, + }); + + expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); + }); + + it('setRequestAuthorization is not set throws', (done) => { + fetchInterceptor.configure({ + ...emptyConfiguration, + shouldIntercept: request => true, + setRequestAuthorization: null, + }); + fetchInterceptor.authorize('refresh_token'); + + fetch('http://localhost:5000/200').then(() => { + done('Should not be called') + }).catch(error => { + expect(error).to.not.be.null; + expect(error).to.be.instanceof(Error); + expect(error.message).to.be.equal(ERROR_INVALID_CONFIG); + + done(); + }) + }); + + it('getAccessTokenFromResponse is not set throws', (done) => { + fetchInterceptor.configure({ + ...emptyConfiguration, + prepareRefreshTokenRequest: (refreshToken) => new Request('http://localhost:5000/token', { + headers: { + authorization: `Bearer ${refreshToken}` + }, + }), + shouldIntercept: request => request.url !== 'http://localhost:5000/token', + getAccessTokenFromResponse: null, + }); + fetchInterceptor.authorize('refresh_token'); + + fetch('http://localhost:5000/401/1').then(() => { + done('Should not be called') + }).catch(error => { + expect(error).to.not.be.null; + expect(error).to.be.instanceof(Error); + expect(error.message).to.be.equal(ERROR_INVALID_CONFIG); + + done(); + }) + }); + }); + + describe('should not change default fetch behaviour', () => { + fetchInterceptor.configure({ + prepareRefreshTokenRequest: () => {}, + shouldIntercept: request => false, + getAccessTokenFromResponse: response => {}, + setRequestAuthorization: (request, token) => {} + }); + + describe('server is running', () => { + beforeEach(done => { + server.start(done); + }); + afterEach(done => { + server.stop(done); + }); + + it('fetches with success for 200 response', done => { + fetch('http://localhost:5000/200').then(() => { + done(); + }).catch(err => { + done(err); + }) + }); + + it('fetches with success for 401 response', done => { + fetch('http://localhost:5000/401/1').then(() => { + done(); + }).catch(err => { + done(err); + }) + }); + }); + + describe('server is not running', () => { + it('fails on server not started', done => { + fetch('http://localhost:5000/401/1').then(() => { + done('Should not end here'); + }).catch(() => { + done(); + }) + }); + }); + }); describe('refresh token is valid', () => { let accessToken = null; + beforeEach(done => { fetchInterceptor.configure({ prepareRefreshTokenRequest: refreshToken => @@ -21,7 +135,8 @@ describe('fetch-intercept', function () { setRequestAuthorization: (request, token) => { request.headers.set('authorization', formatBearer(token)); return request; - } + }, + shouldInvalidateAccessToken: response => response.headers.get('invalidates-token'), }); fetchInterceptor.authorize('refresh_token'); @@ -76,6 +191,23 @@ describe('fetch-intercept', function () { }); }); + it('should fetch successfully when access token is invalidated from response', function (done) { + // set expired access token + fetchInterceptor.authorize('refresh_token', 'token2'); + + fetch('http://localhost:5000/401/1?invalidate=true').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'); @@ -125,7 +257,7 @@ describe('fetch-intercept', function () { }); }); - describe('headers are set', () => { + describe('request has own headers', () => { it('should keep existing headers on request', function (done) { fetch('http://localhost:5000/headers', { headers: { @@ -193,7 +325,7 @@ describe('fetch-intercept', function () { server.stop(done); }); - it('should propagate 401 with invalid refresh token', function (done) { + it('should propagate 401 when refresh token is invalid', function (done) { fetch('http://localhost:5000/401/1').then(response=> { const tokens = fetchInterceptor.getAuthorization(); @@ -209,7 +341,7 @@ describe('fetch-intercept', function () { }); }); - it('should fetch multiple requests successfully with access token expired', function (done) { + it('should propagate 401 for multiple requests when refresh token is invalid', function (done) { Promise.all([ fetch('http://localhost:5000/401/1?duration=100'), fetch('http://localhost:5000/401/2?duration=300'), diff --git a/test/server.js b/test/server.js index 1ba0382..fcd8885 100644 --- a/test/server.js +++ b/test/server.js @@ -20,6 +20,10 @@ app.get('/401/:id', function(req, res) { 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(); @@ -43,9 +47,10 @@ app.get('/token', function(req, res) { // exchange refresh token for new access token if (req.header('authorization') === `Bearer ${currentRefreshToken}`){ currentToken = VALID_TOKEN; + res.json({ 'accessToken': currentToken - }) + }); } else { res.status(401).send(); } From 9fb6c05980ea9027384c5a22878602e2c2e8f912 Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Mon, 13 Feb 2017 15:47:37 +0100 Subject: [PATCH 04/14] Refactored config to enable promises as return values from config functions --- src/index.js | 48 +++++++++++++++++++++----------------- test/index.spec.js | 57 +++++++++++++++++----------------------------- 2 files changed, 48 insertions(+), 57 deletions(-) diff --git a/src/index.js b/src/index.js index 71484b7..0c465d5 100644 --- a/src/index.js +++ b/src/index.js @@ -53,11 +53,9 @@ function attach(env) { function runRefreshTokenPromise() { refreshAccessTokenPromise = new Promise((resolve, reject) => { - // prepare request - const tokenRequest = config.prepareRefreshTokenRequest(tokens.refreshToken); - // fetch new token with refresh token - return fetch(tokenRequest) + return Promise.resolve(config.prepareRefreshTokenRequest(tokens.refreshToken)) + .then(tokenRequest => fetch(tokenRequest)) .then(response => { refreshAccessTokenPromise = null; @@ -65,10 +63,6 @@ function runRefreshTokenPromise() { throw new Error(ERROR_REFRESH_TOKEN_EXPIRED); } - if (!config.getAccessTokenFromResponse) { - throw new Error(ERROR_INVALID_CONFIG); - } - return config.getAccessTokenFromResponse(response); }) // save access token to local config @@ -82,6 +76,8 @@ function runRefreshTokenPromise() { resolve(token); }) .catch(error => { + refreshAccessTokenPromise = null; + tokens.accessToken = null; tokens.refreshToken = null; @@ -116,12 +112,15 @@ function isAuthorized() { } function fetchInterceptor(fetch, ...args) { - const request = convertToRequest(args); - - if (!config.shouldIntercept) { + if (!config.shouldIntercept || + !config.setRequestAuthorization || + !config.prepareRefreshTokenRequest || + !config.getAccessTokenFromResponse) { throw new Error(ERROR_INVALID_CONFIG); } + const request = convertToRequest(args); + // check whether we should ignore this request if (!isAuthorized() || !config.shouldIntercept(request)) { return fetch(request); @@ -129,23 +128,32 @@ function fetchInterceptor(fetch, ...args) { // outer fetch promise return new Promise((outerResolve, outerReject) => { - if (!config.setRequestAuthorization) { - throw new Error(ERROR_INVALID_CONFIG); - } - // inner promise which includes resolving access token const runInnerPromise = () => Promise.resolve(request) .then((request) => config.setRequestAuthorization(request, tokens.accessToken)) // initial fetch - .then(() => fetch(request)) + .then(request => fetch(request)) .then(response => { // check if response invalidates access token - if (config.shouldInvalidateAccessToken && config.shouldInvalidateAccessToken(response)) { + if (config.shouldInvalidateAccessToken) { + return Promise.all([response, config.shouldInvalidateAccessToken(response)]); + } + + return response; + }) + .then(results => { + const response = results[0]; + const shouldInvalidateAccessToken = results[1]; + + if (shouldInvalidateAccessToken) { tokens.accessToken = null; runRefreshTokenPromise(); } + return response; + }) + .then(response => { // if response is not unauthorized we don't care about it if (response.status !== STATUS_UNAUTHORIZED) { return response; @@ -167,6 +175,7 @@ function fetchInterceptor(fetch, ...args) { // repeat request if tokens don't match if (parseBearer(request.headers.get('authorization')) !== tokens.accessToken) { const authorizedRequest = config.setRequestAuthorization(request, tokens.accessToken); + return fetch(authorizedRequest); } @@ -196,9 +205,7 @@ function fetchInterceptor(fetch, ...args) { // chain to refresh token promise (pending) return refreshAccessTokenPromise - .then(() => { - return runInnerPromise(); - }) + .then(() => runInnerPromise()) .catch(error => { if (error.message === ERROR_INVALID_CONFIG) { outerReject(error); @@ -209,7 +216,6 @@ function fetchInterceptor(fetch, ...args) { .then(outerResolve) .catch(outerReject); } - }); } diff --git a/test/index.spec.js b/test/index.spec.js index 5a50e14..e5c8e28 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -23,7 +23,7 @@ describe('fetch-intercept', function () { server.stop(done); }); - it('getAccessTokenFromResponse is not set throws', () => { + it('shouldIntercept is not set throws', () => { fetchInterceptor.configure({ ...emptyConfiguration, shouldIntercept: null, @@ -32,60 +32,45 @@ describe('fetch-intercept', function () { expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); }); - it('setRequestAuthorization is not set throws', (done) => { + it('setRequestAuthorization is not set throws', () => { fetchInterceptor.configure({ ...emptyConfiguration, - shouldIntercept: request => true, setRequestAuthorization: null, }); - fetchInterceptor.authorize('refresh_token'); - - fetch('http://localhost:5000/200').then(() => { - done('Should not be called') - }).catch(error => { - expect(error).to.not.be.null; - expect(error).to.be.instanceof(Error); - expect(error.message).to.be.equal(ERROR_INVALID_CONFIG); - done(); - }) + expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); }); - it('getAccessTokenFromResponse is not set throws', (done) => { + it('getAccessTokenFromResponse is not set throws', () => { fetchInterceptor.configure({ ...emptyConfiguration, - prepareRefreshTokenRequest: (refreshToken) => new Request('http://localhost:5000/token', { - headers: { - authorization: `Bearer ${refreshToken}` - }, - }), - shouldIntercept: request => request.url !== 'http://localhost:5000/token', getAccessTokenFromResponse: null, }); - fetchInterceptor.authorize('refresh_token'); - fetch('http://localhost:5000/401/1').then(() => { - done('Should not be called') - }).catch(error => { - expect(error).to.not.be.null; - expect(error).to.be.instanceof(Error); - expect(error.message).to.be.equal(ERROR_INVALID_CONFIG); + expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); + }); + + it('prepareRefreshTokenRequest is not set throws', () => { + fetchInterceptor.configure({ + ...emptyConfiguration, + prepareRefreshTokenRequest: null, + }); - done(); - }) + expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); }); }); describe('should not change default fetch behaviour', () => { - fetchInterceptor.configure({ - prepareRefreshTokenRequest: () => {}, - shouldIntercept: request => false, - getAccessTokenFromResponse: response => {}, - setRequestAuthorization: (request, token) => {} - }); describe('server is running', () => { beforeEach(done => { + fetchInterceptor.configure({ + prepareRefreshTokenRequest: () => {}, + shouldIntercept: request => false, + getAccessTokenFromResponse: response => {}, + setRequestAuthorization: (request, token) => {} + }); + server.start(done); }); afterEach(done => { @@ -127,7 +112,7 @@ describe('fetch-intercept', function () { fetchInterceptor.configure({ prepareRefreshTokenRequest: refreshToken => new Request('http://localhost:5000/token', { - headers: { authorization: `Bearer ${refreshToken}`} + headers: { authorization: `Bearer ${refreshToken}` } }), shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', getAccessTokenFromResponse: response => From 7a4236bc1ad67e655b3d6a915b6e61b371138dd9 Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Tue, 14 Feb 2017 18:08:42 +0100 Subject: [PATCH 05/14] Added license, fixed version --- LICENSE | 30 ++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 LICENSE 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/package.json b/package.json index 8216614..c3f83ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shoutem/fetch-token-intercept", - "version": "0.0.1", + "version": "0.0.1-alpha.1", "description": "Fetch interceptor for managing refresh token flow.", "main": "lib/index.js", "files": [ From 79ab1ad2ec5914fdbc4e90583cb439b3ad2748d2 Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Thu, 16 Feb 2017 23:53:50 +0100 Subject: [PATCH 06/14] Refactored initial solution into multiple files, including: * fetchInterceptor - main class for intercepting * accessTokenProvider - access token resolver multiple services to increase readability as per PR comments --- src/accessTokenProvider.js | 109 +++++++++ src/const.js | 9 - src/environment.js | 57 +++++ src/fetchInterceptor.js | 221 +++++++++++++++++ src/helpers/tokenFormatter.js | 20 +- src/index.js | 250 +------------------ src/services/environment.js | 16 ++ src/services/http.js | 18 ++ test/fetchInterceptor.spec.js | 348 ++++++++++++++++++++++++++ test/{ => helpers}/promiseHelpers.js | 0 test/{ => helpers}/server.js | 0 test/helpers/tokenFormatter.js | 7 + test/helpers/tokenFormatter.spec.js | 16 +- test/index.spec.js | 351 --------------------------- 14 files changed, 789 insertions(+), 633 deletions(-) create mode 100644 src/accessTokenProvider.js create mode 100644 src/environment.js create mode 100644 src/fetchInterceptor.js create mode 100644 src/services/environment.js create mode 100644 src/services/http.js create mode 100644 test/fetchInterceptor.spec.js rename test/{ => helpers}/promiseHelpers.js (100%) rename test/{ => helpers}/server.js (100%) create mode 100644 test/helpers/tokenFormatter.js delete mode 100644 test/index.spec.js diff --git a/src/accessTokenProvider.js b/src/accessTokenProvider.js new file mode 100644 index 0000000..0692096 --- /dev/null +++ b/src/accessTokenProvider.js @@ -0,0 +1,109 @@ +import { + isResponseUnauthorized, +} from './services/http'; + +export class AccessTokenProvider { + constructor(fetch, config) { + this.fetch = fetch; + this.config = config; + this.refreshAccessTokenPromise = null; + this.tokens = { + refreshToken: null, + accessToken: null, + }; + + this.isAuthorized = this.isAuthorized.bind(this); + this.refresh = this.refresh.bind(this); + this.clear = this.clear.bind(this); + + this.resolveAccessToken = this.resolveAccessToken.bind(this); + this.fetchToken = this.fetchToken.bind(this); + this.handleFetchResolved = this.handleFetchResolved.bind(this); + this.handleTokenResolved = this.handleTokenResolved.bind(this); + this.handleError = this.handleError.bind(this); + } + + /** + * Refreshes current access token with provided refresh token + */ + refresh() { + // if token resolver is not authorized it should just resolve + if (!this.isAuthorized()) { + return Promise.resolve(); + } + + // if we are not running token promise, start it + if (!this.refreshAccessTokenPromise) { + this.refreshAccessTokenPromise = new Promise(this.resolveAccessToken); + } + + // otherwise just return existing promise + return this.refreshAccessTokenPromise; + } + + /** + * 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; + } + + clear() { + this.tokens.accessToken = null; + this.tokens.refreshToken = null; + } + + isAuthorized() { + return this.tokens.refreshToken !== null; + } + + fetchToken(tokenRequest) { + return this.fetch(tokenRequest); + } + + handleFetchResolved(response) { + this.refreshAccessTokenPromise = null; + + if (isResponseUnauthorized(response)) { + this.clear(); + return null; + } + + return this.config.getAccessTokenFromResponse(response); + } + + handleTokenResolved(token, resolve) { + this.tokens.accessToken = token; + + if (this.config.onAccessTokenChange) { + this.config.onAccessTokenChange(token); + } + + resolve(token); + } + + handleError(error, reject) { + this.refreshAccessTokenPromise = null; + this.clear(); + + reject(error); + } + + resolveAccessToken(resolve, reject) { + return Promise.resolve(this.config.createAccessTokenRequest(this.tokens.refreshToken)) + .then(this.fetchToken) + .then(this.handleFetchResolved) + .then(token => this.handleTokenResolved(token, resolve)) + .catch(error => this.handleError(error, reject)); + } +} diff --git a/src/const.js b/src/const.js index a45cb77..528526c 100644 --- a/src/const.js +++ b/src/const.js @@ -1,11 +1,2 @@ -// Uses Emscripten stategy for determining environment -export const ENVIRONMENT_IS_REACT_NATIVE = typeof navigator === 'object' && navigator.product === 'ReactNative'; -export const ENVIRONMENT_IS_NODE = typeof process === 'object' && typeof require === 'function'; -export const ENVIRONMENT_IS_WEB = typeof window === 'object'; -export const ENVIRONMENT_IS_WORKER = typeof importScripts === 'function'; -export const ERROR_REFRESH_TOKEN_EXPIRED = 'refresh-token-expired'; export const ERROR_INVALID_CONFIG = 'invalid-config'; - -export const STATUS_UNAUTHORIZED = 401; -export const STATUS_OK = 200; diff --git a/src/environment.js b/src/environment.js new file mode 100644 index 0000000..5fe37cc --- /dev/null +++ b/src/environment.js @@ -0,0 +1,57 @@ +import { + isReactNative, + isWorker, + isWeb, + isNode, +} from './services/environment'; +import { + FetchInterceptor, +} from './fetchInterceptor'; + +const interceptors = []; + +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'); + } +} + +function attach(env) { + if (!env.fetch) { + throw Error('No fetch available. Unable to register fetch-token-intercept'); + } + + // for now add default interceptor + interceptors.push(new FetchInterceptor(env.fetch)); + + // monkey patch fetch + const fetchWrapper = fetch => (...args) => interceptors[0].intercept(...args); + env.fetch = fetchWrapper(env.fetch); +} + +function configure(config) { + interceptors[0].configure(config); +} + +function authorize(...args) { + interceptors[0].authorize(...args); +} + +function getAuthorization() { + return interceptors[0].getAuthorization(); +} + +export { + init, + configure, + authorize, + getAuthorization, +} diff --git a/src/fetchInterceptor.js b/src/fetchInterceptor.js new file mode 100644 index 0000000..4032af8 --- /dev/null +++ b/src/fetchInterceptor.js @@ -0,0 +1,221 @@ +import { + ERROR_INVALID_CONFIG, + ERROR_REFRESH_TOKEN_EXPIRED, +} from '../lib/const'; +import { + isResponseUnauthorized, +} from './services/http'; +import { AccessTokenProvider } from './accessTokenProvider'; + +export class FetchInterceptor { + constructor(fetch) { + // stores reference to vanilla fetch method + this.fetch = fetch; + this.config = { + createAccessTokenRequest: null, + shouldIntercept: null, + shouldInvalidateAccessToken: null, + getAccessTokenFromResponse: null, + authorizeRequest: null, + onAccessTokenChange: null, + }; + + this.isConfigValid = this.isConfigValid.bind(this); + this.fetchWithRetry = this.fetchWithRetry.bind(this); + this.intercept = this.intercept.bind(this); + this.resolveIntercept = this.resolveIntercept.bind(this); + + this.createRequestUnit = this.createRequestUnit.bind(this); + this.shouldIntercept = this.shouldIntercept.bind(this); + this.authorizeRequest = this.authorizeRequest.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); + } + + /** + * Configures fetch interceptor with given config object + * @param initConfig + */ + configure(initConfig) { + this.config = { ...this.config, ...initConfig }; + + if (!this.isConfigValid(this.config)) { + throw new Error(ERROR_INVALID_CONFIG); + } + + this.accessTokenProvider = new AccessTokenProvider(this.fetch, this.config); + } + + /** + * Authorizes fetch interceptor with given refresh 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(); + } + + intercept(...args) { + return new Promise((resolve, reject) => this.resolveIntercept(resolve, reject, ...args)); + } + + resolveIntercept(resolve, reject, ...args) { + const request = new Request(...args); + const { accessToken } = this.accessTokenProvider.getAuthorization(); + + // if access token is not resolved yet + if (!accessToken) { + return this.accessTokenProvider + .refresh() + .then(() => this.fetchWithRetry(request, resolve, reject)) + .catch(reject); + } + + // attempt normal fetch operation + return this.fetchWithRetry(request, resolve, reject) + .catch(reject); + } + + fetchWithRetry(request, outerResolve, outerReject) { + return Promise.resolve(this.createRequestUnit(request)) + .then(this.shouldIntercept) + // authorize request + .then(this.authorizeRequest) + // initial fetch + .then(this.fetchRequest) + .then(this.shouldInvalidateAccessToken) + .then(this.invalidateAccessToken) + .then(this.handleUnauthorizedRequest) + .then(requestUnit => { + const { response } = requestUnit; + // can only be empty on network errors + if (!response) { + outerReject(); + return; + } + outerResolve(response); + }) + .catch(outerReject); + } + + createRequestUnit(request) { + return { + request, + response: null, + shouldIntercept: false, + shouldInvalidateAccessToken: false, + accessToken: null, + } + } + + shouldIntercept(requestUnit) { + const { request } = requestUnit; + + return Promise.all([requestUnit, this.config.shouldIntercept(request)]) + .then(([requestUnit, shouldIntercept]) => + ({ ...requestUnit, shouldIntercept }) + ); + } + + authorizeRequest(requestUnit) { + const { shouldIntercept } = requestUnit; + + if (shouldIntercept) { + const { request } = requestUnit; + const { accessToken } = this.accessTokenProvider.getAuthorization(); + + if (request && accessToken){ + return Promise.all([ + requestUnit, + accessToken, + this.config.authorizeRequest(request, accessToken), + ]).then(([requestUnit, accessToken, request]) => + ({ ...requestUnit, accessToken, request }) + ); + } + } + + return requestUnit; + } + + fetchRequest(requestUnit) { + const { shouldIntercept } = requestUnit; + + if (shouldIntercept) { + const { request } = requestUnit; + return Promise.all([requestUnit, this.fetch(request)]) + .then(([requestUnit, response]) => + ({...requestUnit, response}) + ); + } + + return requestUnit; + } + + shouldInvalidateAccessToken(requestUnit) { + const { shouldIntercept } = requestUnit; + + if (shouldIntercept && this.config.shouldInvalidateAccessToken) { + const { response } = requestUnit; + // check if response invalidates access token + return Promise.all([ + requestUnit, + this.config.shouldInvalidateAccessToken(response), + ]).then(([requestUnit, shouldInvalidateAccessToken]) => + ({ ...requestUnit, shouldInvalidateAccessToken }) + ); + } + + return requestUnit; + } + + invalidateAccessToken(requestUnit) { + const { shouldIntercept, shouldInvalidateAccessToken } = requestUnit; + + if (shouldIntercept && shouldInvalidateAccessToken) { + this.accessTokenProvider.refresh(); + } + + return requestUnit; + } + + handleUnauthorizedRequest(requestUnit) { + const { shouldIntercept } = requestUnit; + + if (shouldIntercept) { + const { response } = requestUnit; + + // we only care for unauthorized responses + if (isResponseUnauthorized(response)) { + return Promise.all([requestUnit, this.accessTokenProvider.refresh()]) + .then(([requestUnit, accessToken]) => ({ + ...requestUnit, + accessToken, + shouldIntercept: !!accessToken, + })) + .then(this.authorizeRequest) + .then(this.fetchRequest) + .catch(error => new Error(error)); + } + } + + return requestUnit; + } + + isConfigValid() { + return this.config.shouldIntercept && + this.config.authorizeRequest && + this.config.createAccessTokenRequest && + this.config.getAccessTokenFromResponse; + } +} diff --git a/src/helpers/tokenFormatter.js b/src/helpers/tokenFormatter.js index d5972d3..b24964c 100644 --- a/src/helpers/tokenFormatter.js +++ b/src/helpers/tokenFormatter.js @@ -1,23 +1,17 @@ -export function formatBearer(token) { - if (!token) { - return null; - } - - return `Bearer ${token}`; -} - export function parseBearer(authorizationHeaderValue) { - if (!authorizationHeaderValue) { + if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { return null; } - const parts = authorizationHeaderValue.split(' '); - if(parts.length !== 2) { + const bearerRegex = /^Bearer (.+)$/; + const matches = bearerRegex.exec(authorizationHeaderValue); + // matches contains whole value and group, we are interested in group part + if (!matches || matches.length < 2) { return null; } - const token = parts[1]; - if (token === 'undefined') { + const token = matches[1]; + if (!token) { return null; } diff --git a/src/index.js b/src/index.js index 0c465d5..4ed0a11 100644 --- a/src/index.js +++ b/src/index.js @@ -1,250 +1,4 @@ -import { parseBearer } from './helpers/tokenFormatter'; -import { - ENVIRONMENT_IS_REACT_NATIVE, - ENVIRONMENT_IS_NODE, - ENVIRONMENT_IS_WEB, - ENVIRONMENT_IS_WORKER, - ERROR_REFRESH_TOKEN_EXPIRED, - ERROR_INVALID_CONFIG, - STATUS_UNAUTHORIZED, - STATUS_OK, -} from './const'; +import { init } from './environment'; -let config = { - prepareRefreshTokenRequest: null, - shouldIntercept: null, - shouldInvalidateAccessToken: null, - getAccessTokenFromResponse: null, - setRequestAuthorization: null, - onAccessTokenAcquired: null, -}; - -let tokens = { - accessToken: null, - refreshToken: null, -}; - -let refreshAccessTokenPromise = null; - -if (ENVIRONMENT_IS_REACT_NATIVE) { - attach(global); -} else if (ENVIRONMENT_IS_WORKER) { - attach(self); -} else if (ENVIRONMENT_IS_WEB) { - attach(window); -} else if (ENVIRONMENT_IS_NODE) { - attach(global); -} else { - throw new Error('Unsupported environment for fetch-token-intercept'); -} - -function attach(env) { - if (!env.fetch) { - throw Error('No fetch available. Unable to register fetch-token-intercept'); - } - - // monkey patch fetch - env.fetch = (function (fetch) { - return function (...args) { - return fetchInterceptor(fetch, ...args); - }; - })(env.fetch); -} - -function runRefreshTokenPromise() { - refreshAccessTokenPromise = new Promise((resolve, reject) => { - // fetch new token with refresh token - return Promise.resolve(config.prepareRefreshTokenRequest(tokens.refreshToken)) - .then(tokenRequest => fetch(tokenRequest)) - .then(response => { - refreshAccessTokenPromise = null; - - if (response.status !== STATUS_OK) { - throw new Error(ERROR_REFRESH_TOKEN_EXPIRED); - } - - return config.getAccessTokenFromResponse(response); - }) - // save access token to local config - .then(token => { - tokens.accessToken = token; - - if (config.onAccessTokenAcquired) { - config.onAccessTokenAcquired(token); - } - - resolve(token); - }) - .catch(error => { - refreshAccessTokenPromise = null; - - tokens.accessToken = null; - tokens.refreshToken = null; - - reject(error); - }); - }); - return refreshAccessTokenPromise; -} - -function convertToRequest(args) { - const request = new Request(...args); - request.id = Date.now(); - return request; -} - -function shouldFetchAccessToken(request) { - // check if we're already fetching the token - if (refreshAccessTokenPromise) { - return false; - } - - const requestAccessToken = parseBearer(request.headers.get('authorization')); - if (requestAccessToken !== tokens.accessToken) { - return false; - } - - return true; -} - -function isAuthorized() { - return !!tokens.refreshToken; -} - -function fetchInterceptor(fetch, ...args) { - if (!config.shouldIntercept || - !config.setRequestAuthorization || - !config.prepareRefreshTokenRequest || - !config.getAccessTokenFromResponse) { - throw new Error(ERROR_INVALID_CONFIG); - } - - const request = convertToRequest(args); - - // check whether we should ignore this request - if (!isAuthorized() || !config.shouldIntercept(request)) { - return fetch(request); - } - - // outer fetch promise - return new Promise((outerResolve, outerReject) => { - // inner promise which includes resolving access token - const runInnerPromise = () => - Promise.resolve(request) - .then((request) => config.setRequestAuthorization(request, tokens.accessToken)) - // initial fetch - .then(request => fetch(request)) - .then(response => { - // check if response invalidates access token - if (config.shouldInvalidateAccessToken) { - return Promise.all([response, config.shouldInvalidateAccessToken(response)]); - } - - return response; - }) - .then(results => { - const response = results[0]; - const shouldInvalidateAccessToken = results[1]; - - if (shouldInvalidateAccessToken) { - tokens.accessToken = null; - runRefreshTokenPromise(); - } - - return response; - }) - .then(response => { - // if response is not unauthorized we don't care about it - if (response.status !== STATUS_UNAUTHORIZED) { - return response; - } - - // if we received unauthorized and current request's token is same as access token - // we should refresh the token. otherwise we should just repeat request since - // some other request already refreshed access token - if (shouldFetchAccessToken(request)) { - runRefreshTokenPromise(); - } - - // if refresh token promise is null, it already finished before this request - // in that case we just want to continue and repeat this request - const returnPromise = refreshAccessTokenPromise || Promise.resolve(); - - return returnPromise - .then(() => { - // repeat request if tokens don't match - if (parseBearer(request.headers.get('authorization')) !== tokens.accessToken) { - const authorizedRequest = config.setRequestAuthorization(request, tokens.accessToken); - - return fetch(authorizedRequest); - } - - // otherwise return initial response - return response; - }) - // fetching refresh token failed - .catch(error => { - if (error.message === ERROR_REFRESH_TOKEN_EXPIRED) { - outerResolve(response); - } else { - // otherwise we propagate error out - outerReject(error); - } - }) - }) - .then(outerResolve) - .catch(outerReject); - - // if access token is not resolved yet - if (!tokens.accessToken) { - // check if we are alredy fetching it - if (!refreshAccessTokenPromise) { - // as a side-effect refreshTokenPromise is set - runRefreshTokenPromise(); - } - - // chain to refresh token promise (pending) - return refreshAccessTokenPromise - .then(() => runInnerPromise()) - .catch(error => { - if (error.message === ERROR_INVALID_CONFIG) { - outerReject(error); - } else { - // if we fail to resolve refresh token, and it's not internal error - // just pass the request normally - return fetch(request) - .then(outerResolve) - .catch(outerReject); - } - }); - } - - // otherwise attempt fetch operation - return runInnerPromise(); - }); -} - -/** - * Configures fetch token intercept - */ -export function configure(initConfig) { - config = { ...config, ...initConfig }; -} - -/** - * Configures current refresh token, refresh token invalidates on rejection - * @param refreshToken - * @param accessToken - */ -export function authorize(refreshToken, accessToken) { - tokens = { ...tokens, refreshToken, accessToken }; -} - -/** - * Returns current authorization for fetch fetchInterceptor - * @returns {{accessToken: string, refreshToken: string}} - */ -export function getAuthorization() { - return tokens; -} +init(); 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..4f316a3 --- /dev/null +++ b/test/fetchInterceptor.spec.js @@ -0,0 +1,348 @@ +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/environment'; +fetchInterceptor.init(); + +const configuration = () => ({ + createAccessTokenRequest: refreshToken => + new Request('http://localhost:5000/token', { + headers: {authorization: `Bearer ${refreshToken}`} + }), + shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', + getAccessTokenFromResponse: response => + response.json().then(jsonData => jsonData ? jsonData.accessToken : null), + authorizeRequest: (request, token) => { + request.headers.set('authorization', formatBearer(token)); + return request; + } +}); + +const emptyConfiguration = () => ({ + createAccessTokenRequest: () => { + }, + shouldIntercept: request => false, + getAccessTokenFromResponse: () => { + }, + authorizeRequest: () => { + } +}); + +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 = { + ...emptyConfiguration(), + shouldIntercept: null, + }; + + expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); + }); + + it('throws if authorizeRequest is not set', () => { + const config = { + ...emptyConfiguration(), + authorizeRequest: null, + }; + + expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); + }); + + it('throws if getAccessTokenFromResponse is not set', () => { + const config = { + ...emptyConfiguration(), + getAccessTokenFromResponse: null, + }; + + expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); + }); + + it('throws if createAccessTokenRequest is not set', () => { + const config = { + ...emptyConfiguration(), + createAccessTokenRequest: null, + }; + + expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); + }); + }); + + describe('should not change default fetch behaviour', () => { + + describe('server is running', () => { + beforeEach(done => { + fetchInterceptor.configure(emptyConfiguration()); + + 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 => { + 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.authorize('refresh_token', 'token2'); + + fetch('http://localhost:5000/401/1?invalidate=true').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); + }); + }); + }); + + 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 tokens = fetchInterceptor.getAuthorization(); + + expect(response.status).to.be.equal(401); + + expect(tokens.accessToken).to.be.null; + expect(tokens.refreshToken).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/promiseHelpers.js b/test/helpers/promiseHelpers.js similarity index 100% rename from test/promiseHelpers.js rename to test/helpers/promiseHelpers.js diff --git a/test/server.js b/test/helpers/server.js similarity index 100% rename from test/server.js rename to test/helpers/server.js 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 index 45782e3..4e2ad7d 100644 --- a/test/helpers/tokenFormatter.spec.js +++ b/test/helpers/tokenFormatter.spec.js @@ -1,13 +1,13 @@ -import 'fetch-everywhere'; import { expect } from 'chai'; -import { formatBearer, parseBearer } from '../../src/helpers/tokenFormatter'; +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'); @@ -30,16 +30,8 @@ describe('token formatter', () => { expect(result).to.be.null; }); - - it('should return null on invalid header value', () => { - const result = parseBearer('bearer undefined'); - - expect(result).to.be.null; - }); - - it('should return token value', () => { - const result = parseBearer('bearer token'); + const result = parseBearer('Bearer token'); expect(result).to.not.be.null; expect(result).to.be.equal('token'); diff --git a/test/index.spec.js b/test/index.spec.js deleted file mode 100644 index e5c8e28..0000000 --- a/test/index.spec.js +++ /dev/null @@ -1,351 +0,0 @@ -import 'fetch-everywhere'; -import { expect } from 'chai'; -import * as server from './server'; -import { delayPromise } from './promiseHelpers'; -import { formatBearer } from '../src/helpers/tokenFormatter'; -import { ERROR_INVALID_CONFIG } from '../src/const'; -import * as fetchInterceptor from '../src/index'; - -const emptyConfiguration = { - prepareRefreshTokenRequest: () => {}, - shouldIntercept: request => false, - getAccessTokenFromResponse: () => {}, - setRequestAuthorization: () => {} -}; - -describe('fetch-intercept', function () { - describe('should validate config', () => { - beforeEach(done => { - server.start(done); - }); - - afterEach(done => { - server.stop(done); - }); - - it('shouldIntercept is not set throws', () => { - fetchInterceptor.configure({ - ...emptyConfiguration, - shouldIntercept: null, - }); - - expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); - }); - - it('setRequestAuthorization is not set throws', () => { - fetchInterceptor.configure({ - ...emptyConfiguration, - setRequestAuthorization: null, - }); - - expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); - }); - - it('getAccessTokenFromResponse is not set throws', () => { - fetchInterceptor.configure({ - ...emptyConfiguration, - getAccessTokenFromResponse: null, - }); - - expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); - }); - - it('prepareRefreshTokenRequest is not set throws', () => { - fetchInterceptor.configure({ - ...emptyConfiguration, - prepareRefreshTokenRequest: null, - }); - - expect(() => fetch('http://localhost:5000/200')).to.throw(Error, ERROR_INVALID_CONFIG); - }); - }); - - describe('should not change default fetch behaviour', () => { - - describe('server is running', () => { - beforeEach(done => { - fetchInterceptor.configure({ - prepareRefreshTokenRequest: () => {}, - shouldIntercept: request => false, - getAccessTokenFromResponse: response => {}, - setRequestAuthorization: (request, token) => {} - }); - - server.start(done); - }); - afterEach(done => { - server.stop(done); - }); - - it('fetches with success for 200 response', done => { - fetch('http://localhost:5000/200').then(() => { - done(); - }).catch(err => { - done(err); - }) - }); - - it('fetches with success for 401 response', done => { - fetch('http://localhost:5000/401/1').then(() => { - done(); - }).catch(err => { - done(err); - }) - }); - }); - - describe('server is not running', () => { - it('fails on server not started', done => { - fetch('http://localhost:5000/401/1').then(() => { - done('Should not end here'); - }).catch(() => { - done(); - }) - }); - }); - }); - - describe('refresh token is valid', () => { - let accessToken = null; - - beforeEach(done => { - fetchInterceptor.configure({ - prepareRefreshTokenRequest: refreshToken => - new Request('http://localhost:5000/token', { - headers: { authorization: `Bearer ${refreshToken}` } - }), - shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', - getAccessTokenFromResponse: response => - response.json().then(jsonData => jsonData ? jsonData.accessToken : null), - setRequestAuthorization: (request, token) => { - request.headers.set('authorization', formatBearer(token)); - return request; - }, - shouldInvalidateAccessToken: response => response.headers.get('invalidates-token'), - }); - - fetchInterceptor.authorize('refresh_token'); - server.start(done); - }); - - afterEach(done => { - server.stop(done); - }); - - it('should fetch successfully with access token valid', function (done) { - fetch('http://localhost:5000/200', { - headers: { authorization: `Bearer ${accessToken}` } - }) - .then((response)=> { - expect(response.status).to.be.equal(200); - done(); - }) - .catch(error => { - done(error); - }); - }); - - it('should fetch successfully with access token empty', function (done) { - 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 when access token is invalidated from response', function (done) { - // set expired access token - fetchInterceptor.authorize('refresh_token', 'token2'); - - fetch('http://localhost:5000/401/1?invalidate=true').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); - }); - }); - - describe('request has own headers', () => { - 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) { - fetchInterceptor.authorize('refresh_token', 'access_token'); - 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 access_token'); - done(); - }) - .catch(error => { - done(error); - }); - }); - }) - }); - - describe('refresh token is invalid', () => { - beforeEach(done => { - fetchInterceptor.configure({ - prepareRefreshTokenRequest: refreshToken => - new Request('http://localhost:5000/token', { - headers: { authorization: `Bearer ${refreshToken}`} - }), - shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', - getAccessTokenFromResponse: response => - response.json().then(jsonData => jsonData ? jsonData.accessToken : null), - setRequestAuthorization: (request, token) => { - request.headers.set('authorization', formatBearer(token)); - return request; - } - }); - fetchInterceptor.authorize('invalid_refresh_token'); - - server.start(done); - }); - - afterEach(done => { - server.stop(done); - }); - - it('should propagate 401 when refresh token is invalid', function (done) { - fetch('http://localhost:5000/401/1').then(response=> { - const tokens = fetchInterceptor.getAuthorization(); - - expect(response.status).to.be.equal(401); - - expect(tokens.accessToken).to.be.null; - expect(tokens.refreshToken).to.be.null; - - done(); - }) - .catch((error) => { - done(error); - }); - }); - - it('should propagate 401 for multiple requests when refresh token is invalid', 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); - - done(); - }) - .catch(error => { - done(error); - }); - }); - }); -}); From af42f0e414e3e0a39aca395df4113252851d5cda Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Fri, 17 Feb 2017 00:51:23 +0100 Subject: [PATCH 07/14] Minor renames --- src/accessTokenProvider.js | 2 +- src/environment.js | 5 +++++ src/fetchInterceptor.js | 8 ++++++-- src/index.js | 14 +++++++++++++- test/fetchInterceptor.spec.js | 15 ++++++--------- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/accessTokenProvider.js b/src/accessTokenProvider.js index 0692096..a47b4c3 100644 --- a/src/accessTokenProvider.js +++ b/src/accessTokenProvider.js @@ -79,7 +79,7 @@ export class AccessTokenProvider { return null; } - return this.config.getAccessTokenFromResponse(response); + return this.config.parseAccessToken(response); } handleTokenResolved(token, resolve) { diff --git a/src/environment.js b/src/environment.js index 5fe37cc..d93f44a 100644 --- a/src/environment.js +++ b/src/environment.js @@ -49,8 +49,13 @@ function getAuthorization() { return interceptors[0].getAuthorization(); } +function clear() { + return interceptors[0].clear(); +} + export { init, + clear, configure, authorize, getAuthorization, diff --git a/src/fetchInterceptor.js b/src/fetchInterceptor.js index 4032af8..8eacd27 100644 --- a/src/fetchInterceptor.js +++ b/src/fetchInterceptor.js @@ -15,7 +15,7 @@ export class FetchInterceptor { createAccessTokenRequest: null, shouldIntercept: null, shouldInvalidateAccessToken: null, - getAccessTokenFromResponse: null, + parseAccessToken: null, authorizeRequest: null, onAccessTokenChange: null, }; @@ -65,6 +65,10 @@ export class FetchInterceptor { return this.accessTokenProvider.getAuthorization(); } + clear() { + this.accessTokenProvider.clear(); + } + intercept(...args) { return new Promise((resolve, reject) => this.resolveIntercept(resolve, reject, ...args)); } @@ -216,6 +220,6 @@ export class FetchInterceptor { return this.config.shouldIntercept && this.config.authorizeRequest && this.config.createAccessTokenRequest && - this.config.getAccessTokenFromResponse; + this.config.parseAccessToken; } } diff --git a/src/index.js b/src/index.js index 4ed0a11..8b0853a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,16 @@ -import { init } from './environment'; +import { + init, + clear, + configure, + authorize, + getAuthorization, +} from './environment'; init(); +export { + clear, + configure, + authorize, + getAuthorization, +} diff --git a/test/fetchInterceptor.spec.js b/test/fetchInterceptor.spec.js index 4f316a3..65c9c12 100644 --- a/test/fetchInterceptor.spec.js +++ b/test/fetchInterceptor.spec.js @@ -13,7 +13,7 @@ const configuration = () => ({ headers: {authorization: `Bearer ${refreshToken}`} }), shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', - getAccessTokenFromResponse: response => + parseAccessToken: response => response.json().then(jsonData => jsonData ? jsonData.accessToken : null), authorizeRequest: (request, token) => { request.headers.set('authorization', formatBearer(token)); @@ -22,13 +22,10 @@ const configuration = () => ({ }); const emptyConfiguration = () => ({ - createAccessTokenRequest: () => { - }, + createAccessTokenRequest: () => {}, shouldIntercept: request => false, - getAccessTokenFromResponse: () => { - }, - authorizeRequest: () => { - } + parseAccessToken: () => {}, + authorizeRequest: () => {} }); describe('fetch-intercept', function () { @@ -61,10 +58,10 @@ describe('fetch-intercept', function () { expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); }); - it('throws if getAccessTokenFromResponse is not set', () => { + it('throws if parseAccessToken is not set', () => { const config = { ...emptyConfiguration(), - getAccessTokenFromResponse: null, + parseAccessToken: null, }; expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); From 2d563b40e1332a913e0f6f1c1109337ccab2ec02 Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Sat, 18 Feb 2017 00:40:05 +0100 Subject: [PATCH 08/14] Added shouldFetch and onResponse Fixed scope for fetch method --- src/accessTokenProvider.js | 4 +++- src/fetchInterceptor.js | 32 ++++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/accessTokenProvider.js b/src/accessTokenProvider.js index a47b4c3..233e617 100644 --- a/src/accessTokenProvider.js +++ b/src/accessTokenProvider.js @@ -5,6 +5,7 @@ import { export class AccessTokenProvider { constructor(fetch, config) { this.fetch = fetch; + this.config = config; this.refreshAccessTokenPromise = null; this.tokens = { @@ -68,7 +69,8 @@ export class AccessTokenProvider { } fetchToken(tokenRequest) { - return this.fetch(tokenRequest); + const { fetch } = this; + return fetch(tokenRequest); } handleFetchResolved(response) { diff --git a/src/fetchInterceptor.js b/src/fetchInterceptor.js index 8eacd27..49d38cd 100644 --- a/src/fetchInterceptor.js +++ b/src/fetchInterceptor.js @@ -11,6 +11,7 @@ export class FetchInterceptor { constructor(fetch) { // stores reference to vanilla fetch method this.fetch = fetch; + this.config = { createAccessTokenRequest: null, shouldIntercept: null, @@ -18,6 +19,7 @@ export class FetchInterceptor { parseAccessToken: null, authorizeRequest: null, onAccessTokenChange: null, + onResponse: null, }; this.isConfigValid = this.isConfigValid.bind(this); @@ -28,6 +30,7 @@ export class FetchInterceptor { 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); @@ -95,6 +98,7 @@ export class FetchInterceptor { .then(this.shouldIntercept) // authorize request .then(this.authorizeRequest) + .then(this.shouldFetch) // initial fetch .then(this.fetchRequest) .then(this.shouldInvalidateAccessToken) @@ -107,6 +111,11 @@ export class FetchInterceptor { outerReject(); return; } + + if (this.config.onResponse) { + this.config.onResponse(response); + } + outerResolve(response); }) .catch(outerReject); @@ -118,6 +127,7 @@ export class FetchInterceptor { response: null, shouldIntercept: false, shouldInvalidateAccessToken: false, + shouldFetch: true, accessToken: null, } } @@ -131,6 +141,19 @@ export class FetchInterceptor { ); } + shouldFetch(requestUnit) { + const { request } = requestUnit; + + if (this.config.shouldFetch) { + return Promise.all([requestUnit, this.config.shouldFetch(request)]) + .then(([requestUnit, shouldFetch]) => + ({ ...requestUnit, shouldFetch }) + ); + } + + return requestUnit; + } + authorizeRequest(requestUnit) { const { shouldIntercept } = requestUnit; @@ -153,11 +176,12 @@ export class FetchInterceptor { } fetchRequest(requestUnit) { - const { shouldIntercept } = requestUnit; + const { shouldFetch } = requestUnit; - if (shouldIntercept) { + if (shouldFetch) { const { request } = requestUnit; - return Promise.all([requestUnit, this.fetch(request)]) + const { fetch } = this; + return Promise.all([requestUnit, fetch(request)]) .then(([requestUnit, response]) => ({...requestUnit, response}) ); @@ -205,7 +229,7 @@ export class FetchInterceptor { .then(([requestUnit, accessToken]) => ({ ...requestUnit, accessToken, - shouldIntercept: !!accessToken, + shouldFetch: !!accessToken, })) .then(this.authorizeRequest) .then(this.fetchRequest) From 952e1c268efb15bc8638d6f05116390668a60efc Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Tue, 21 Feb 2017 14:26:19 +0100 Subject: [PATCH 09/14] Version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c3f83ac..8aac82c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shoutem/fetch-token-intercept", - "version": "0.0.1-alpha.1", + "version": "0.0.1-alpha.2", "description": "Fetch interceptor for managing refresh token flow.", "main": "lib/index.js", "files": [ From 35ba205093ddcec58926fdc6c87a328bf19276fa Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Tue, 21 Feb 2017 15:34:57 +0100 Subject: [PATCH 10/14] Fix invalid imports Bump versoin --- package.json | 2 +- src/const.js | 1 - src/fetchInterceptor.js | 3 +-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 8aac82c..c941126 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shoutem/fetch-token-intercept", - "version": "0.0.1-alpha.2", + "version": "0.0.1-alpha.3", "description": "Fetch interceptor for managing refresh token flow.", "main": "lib/index.js", "files": [ diff --git a/src/const.js b/src/const.js index 528526c..3967950 100644 --- a/src/const.js +++ b/src/const.js @@ -1,2 +1 @@ - export const ERROR_INVALID_CONFIG = 'invalid-config'; diff --git a/src/fetchInterceptor.js b/src/fetchInterceptor.js index 49d38cd..c390a23 100644 --- a/src/fetchInterceptor.js +++ b/src/fetchInterceptor.js @@ -1,7 +1,6 @@ import { ERROR_INVALID_CONFIG, - ERROR_REFRESH_TOKEN_EXPIRED, -} from '../lib/const'; +} from './const'; import { isResponseUnauthorized, } from './services/http'; From 6598de815ea887702bde5067fc8b9d43fb2d3022 Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Fri, 24 Feb 2017 00:15:49 +0100 Subject: [PATCH 11/14] Refactored flow logic with custom exceptions Refactored tests Minor renames and improvements --- package.json | 6 +- src/AccessTokenProvider.js | 127 ++++++++ src/FetchInterceptor.js | 326 ++++++++++++++++++++ src/accessTokenProvider.js | 111 ------- src/environment.js | 62 ---- src/fetchInterceptor.js | 248 --------------- src/helpers/tokenFormatter.js | 5 +- src/index.js | 66 +++- src/services/RetryCountExceededException.js | 15 + src/services/TokenExpiredException.js | 15 + test/fetchInterceptor.spec.js | 205 ++++++++---- test/helpers/server.js | 4 +- test/helpers/tokenFormatter.spec.js | 4 +- 13 files changed, 687 insertions(+), 507 deletions(-) create mode 100644 src/AccessTokenProvider.js create mode 100644 src/FetchInterceptor.js delete mode 100644 src/accessTokenProvider.js delete mode 100644 src/environment.js delete mode 100644 src/fetchInterceptor.js create mode 100644 src/services/RetryCountExceededException.js create mode 100644 src/services/TokenExpiredException.js diff --git a/package.json b/package.json index c941126..3b0ec8e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "repository": { "type": "git", - "url": "" + "url": "https://github.com/shoutem/fetch-token-intercept/" }, "keywords": [ "fetch", @@ -24,9 +24,9 @@ "access" ], "author": "Shoutem", - "license": "MIT", + "license": "BSD", "bugs": { - "url": "" + "url": "https://github.com/shoutem/fetch-token-intercept/issues" }, "homepage": "", "devDependencies": { diff --git a/src/AccessTokenProvider.js b/src/AccessTokenProvider.js new file mode 100644 index 0000000..ae2fc45 --- /dev/null +++ b/src/AccessTokenProvider.js @@ -0,0 +1,127 @@ +import { + isResponseUnauthorized, +} from './services/http'; + +/** + * Provides a way for renewing access token with correct renew token. It will automatically + * dispatch a call to server with request provided via config. It also ensures that + * renew 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.log('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 renew 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(token, resolve) { + this.tokens.accessToken = token; + + if (this.config.onAccessTokenChange) { + this.config.onAccessTokenChange(token); + } + + resolve(token); + } + + 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..2e9c298 --- /dev/null +++ b/src/FetchInterceptor.js @@ -0,0 +1,326 @@ +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)); + } + + 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) { + 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, + }) + ); + } + + return requestUnit; + } + + shouldInvalidateAccessToken(requestUnit) { + const { shouldIntercept } = requestUnit; + const { shouldInvalidateAccessToken } = this.config; + + if (shouldIntercept && shouldInvalidateAccessToken) { + const { response } = requestUnit; + // check if response invalidates access token + return Promise.resolve(shouldInvalidateAccessToken(response)) + .then(shouldInvalidateAccessToken => + ({ ...requestUnit, shouldInvalidateAccessToken }) + ); + } + + return requestUnit; + } + + invalidateAccessToken(requestUnit) { + const { shouldIntercept, shouldInvalidateAccessToken } = requestUnit; + + if (shouldIntercept && shouldInvalidateAccessToken) { + 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); + } + + return fetchReject(error); + } + + isConfigValid() { + return this.config.shouldIntercept && + this.config.authorizeRequest && + this.config.createAccessTokenRequest && + this.config.parseAccessToken; + } +} diff --git a/src/accessTokenProvider.js b/src/accessTokenProvider.js deleted file mode 100644 index 233e617..0000000 --- a/src/accessTokenProvider.js +++ /dev/null @@ -1,111 +0,0 @@ -import { - isResponseUnauthorized, -} from './services/http'; - -export class AccessTokenProvider { - constructor(fetch, config) { - this.fetch = fetch; - - this.config = config; - this.refreshAccessTokenPromise = null; - this.tokens = { - refreshToken: null, - accessToken: null, - }; - - this.isAuthorized = this.isAuthorized.bind(this); - this.refresh = this.refresh.bind(this); - this.clear = this.clear.bind(this); - - this.resolveAccessToken = this.resolveAccessToken.bind(this); - this.fetchToken = this.fetchToken.bind(this); - this.handleFetchResolved = this.handleFetchResolved.bind(this); - this.handleTokenResolved = this.handleTokenResolved.bind(this); - this.handleError = this.handleError.bind(this); - } - - /** - * Refreshes current access token with provided refresh token - */ - refresh() { - // if token resolver is not authorized it should just resolve - if (!this.isAuthorized()) { - return Promise.resolve(); - } - - // if we are not running token promise, start it - if (!this.refreshAccessTokenPromise) { - this.refreshAccessTokenPromise = new Promise(this.resolveAccessToken); - } - - // otherwise just return existing promise - return this.refreshAccessTokenPromise; - } - - /** - * 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; - } - - clear() { - this.tokens.accessToken = null; - this.tokens.refreshToken = null; - } - - isAuthorized() { - return this.tokens.refreshToken !== null; - } - - fetchToken(tokenRequest) { - const { fetch } = this; - return fetch(tokenRequest); - } - - handleFetchResolved(response) { - this.refreshAccessTokenPromise = null; - - if (isResponseUnauthorized(response)) { - this.clear(); - return null; - } - - return this.config.parseAccessToken(response); - } - - handleTokenResolved(token, resolve) { - this.tokens.accessToken = token; - - if (this.config.onAccessTokenChange) { - this.config.onAccessTokenChange(token); - } - - resolve(token); - } - - handleError(error, reject) { - this.refreshAccessTokenPromise = null; - this.clear(); - - reject(error); - } - - resolveAccessToken(resolve, reject) { - return Promise.resolve(this.config.createAccessTokenRequest(this.tokens.refreshToken)) - .then(this.fetchToken) - .then(this.handleFetchResolved) - .then(token => this.handleTokenResolved(token, resolve)) - .catch(error => this.handleError(error, reject)); - } -} diff --git a/src/environment.js b/src/environment.js deleted file mode 100644 index d93f44a..0000000 --- a/src/environment.js +++ /dev/null @@ -1,62 +0,0 @@ -import { - isReactNative, - isWorker, - isWeb, - isNode, -} from './services/environment'; -import { - FetchInterceptor, -} from './fetchInterceptor'; - -const interceptors = []; - -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'); - } -} - -function attach(env) { - if (!env.fetch) { - throw Error('No fetch available. Unable to register fetch-token-intercept'); - } - - // for now add default interceptor - interceptors.push(new FetchInterceptor(env.fetch)); - - // monkey patch fetch - const fetchWrapper = fetch => (...args) => interceptors[0].intercept(...args); - env.fetch = fetchWrapper(env.fetch); -} - -function configure(config) { - interceptors[0].configure(config); -} - -function authorize(...args) { - interceptors[0].authorize(...args); -} - -function getAuthorization() { - return interceptors[0].getAuthorization(); -} - -function clear() { - return interceptors[0].clear(); -} - -export { - init, - clear, - configure, - authorize, - getAuthorization, -} diff --git a/src/fetchInterceptor.js b/src/fetchInterceptor.js deleted file mode 100644 index c390a23..0000000 --- a/src/fetchInterceptor.js +++ /dev/null @@ -1,248 +0,0 @@ -import { - ERROR_INVALID_CONFIG, -} from './const'; -import { - isResponseUnauthorized, -} from './services/http'; -import { AccessTokenProvider } from './accessTokenProvider'; - -export class FetchInterceptor { - constructor(fetch) { - // stores reference to vanilla fetch method - this.fetch = fetch; - - this.config = { - createAccessTokenRequest: null, - shouldIntercept: null, - shouldInvalidateAccessToken: null, - parseAccessToken: null, - authorizeRequest: null, - onAccessTokenChange: null, - onResponse: null, - }; - - this.isConfigValid = this.isConfigValid.bind(this); - this.fetchWithRetry = this.fetchWithRetry.bind(this); - this.intercept = this.intercept.bind(this); - this.resolveIntercept = this.resolveIntercept.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); - } - - /** - * Configures fetch interceptor with given config object - * @param initConfig - */ - configure(initConfig) { - this.config = { ...this.config, ...initConfig }; - - if (!this.isConfigValid(this.config)) { - throw new Error(ERROR_INVALID_CONFIG); - } - - this.accessTokenProvider = new AccessTokenProvider(this.fetch, this.config); - } - - /** - * Authorizes fetch interceptor with given refresh 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(); - } - - clear() { - this.accessTokenProvider.clear(); - } - - intercept(...args) { - return new Promise((resolve, reject) => this.resolveIntercept(resolve, reject, ...args)); - } - - resolveIntercept(resolve, reject, ...args) { - const request = new Request(...args); - const { accessToken } = this.accessTokenProvider.getAuthorization(); - - // if access token is not resolved yet - if (!accessToken) { - return this.accessTokenProvider - .refresh() - .then(() => this.fetchWithRetry(request, resolve, reject)) - .catch(reject); - } - - // attempt normal fetch operation - return this.fetchWithRetry(request, resolve, reject) - .catch(reject); - } - - fetchWithRetry(request, outerResolve, outerReject) { - return Promise.resolve(this.createRequestUnit(request)) - .then(this.shouldIntercept) - // authorize request - .then(this.authorizeRequest) - .then(this.shouldFetch) - // initial fetch - .then(this.fetchRequest) - .then(this.shouldInvalidateAccessToken) - .then(this.invalidateAccessToken) - .then(this.handleUnauthorizedRequest) - .then(requestUnit => { - const { response } = requestUnit; - // can only be empty on network errors - if (!response) { - outerReject(); - return; - } - - if (this.config.onResponse) { - this.config.onResponse(response); - } - - outerResolve(response); - }) - .catch(outerReject); - } - - createRequestUnit(request) { - return { - request, - response: null, - shouldIntercept: false, - shouldInvalidateAccessToken: false, - shouldFetch: true, - accessToken: null, - } - } - - shouldIntercept(requestUnit) { - const { request } = requestUnit; - - return Promise.all([requestUnit, this.config.shouldIntercept(request)]) - .then(([requestUnit, shouldIntercept]) => - ({ ...requestUnit, shouldIntercept }) - ); - } - - shouldFetch(requestUnit) { - const { request } = requestUnit; - - if (this.config.shouldFetch) { - return Promise.all([requestUnit, this.config.shouldFetch(request)]) - .then(([requestUnit, shouldFetch]) => - ({ ...requestUnit, shouldFetch }) - ); - } - - return requestUnit; - } - - authorizeRequest(requestUnit) { - const { shouldIntercept } = requestUnit; - - if (shouldIntercept) { - const { request } = requestUnit; - const { accessToken } = this.accessTokenProvider.getAuthorization(); - - if (request && accessToken){ - return Promise.all([ - requestUnit, - accessToken, - this.config.authorizeRequest(request, accessToken), - ]).then(([requestUnit, accessToken, request]) => - ({ ...requestUnit, accessToken, request }) - ); - } - } - - return requestUnit; - } - - fetchRequest(requestUnit) { - const { shouldFetch } = requestUnit; - - if (shouldFetch) { - const { request } = requestUnit; - const { fetch } = this; - return Promise.all([requestUnit, fetch(request)]) - .then(([requestUnit, response]) => - ({...requestUnit, response}) - ); - } - - return requestUnit; - } - - shouldInvalidateAccessToken(requestUnit) { - const { shouldIntercept } = requestUnit; - - if (shouldIntercept && this.config.shouldInvalidateAccessToken) { - const { response } = requestUnit; - // check if response invalidates access token - return Promise.all([ - requestUnit, - this.config.shouldInvalidateAccessToken(response), - ]).then(([requestUnit, shouldInvalidateAccessToken]) => - ({ ...requestUnit, shouldInvalidateAccessToken }) - ); - } - - return requestUnit; - } - - invalidateAccessToken(requestUnit) { - const { shouldIntercept, shouldInvalidateAccessToken } = requestUnit; - - if (shouldIntercept && shouldInvalidateAccessToken) { - this.accessTokenProvider.refresh(); - } - - return requestUnit; - } - - handleUnauthorizedRequest(requestUnit) { - const { shouldIntercept } = requestUnit; - - if (shouldIntercept) { - const { response } = requestUnit; - - // we only care for unauthorized responses - if (isResponseUnauthorized(response)) { - return Promise.all([requestUnit, this.accessTokenProvider.refresh()]) - .then(([requestUnit, accessToken]) => ({ - ...requestUnit, - accessToken, - shouldFetch: !!accessToken, - })) - .then(this.authorizeRequest) - .then(this.fetchRequest) - .catch(error => new Error(error)); - } - } - - return requestUnit; - } - - isConfigValid() { - return this.config.shouldIntercept && - this.config.authorizeRequest && - this.config.createAccessTokenRequest && - this.config.parseAccessToken; - } -} diff --git a/src/helpers/tokenFormatter.js b/src/helpers/tokenFormatter.js index b24964c..34803f7 100644 --- a/src/helpers/tokenFormatter.js +++ b/src/helpers/tokenFormatter.js @@ -1,10 +1,11 @@ +const bearerRegex = /^Bearer (.+)$/; + export function parseBearer(authorizationHeaderValue) { if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { return null; } - const bearerRegex = /^Bearer (.+)$/; - const matches = bearerRegex.exec(authorizationHeaderValue); + const matches = authorizationHeaderValue.match(bearerRegex); // matches contains whole value and group, we are interested in group part if (!matches || matches.length < 2) { return null; diff --git a/src/index.js b/src/index.js index 8b0853a..cef07e4 100644 --- a/src/index.js +++ b/src/index.js @@ -1,16 +1,58 @@ import { - init, - clear, - configure, - authorize, - getAuthorization, -} from './environment'; + isReactNative, + isWorker, + isWeb, + isNode, +} from './services/environment'; +import FetchInterceptor from './FetchInterceptor'; -init(); +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 { - clear, - configure, - authorize, - getAuthorization, +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..512ef1f --- /dev/null +++ b/src/services/RetryCountExceededException.js @@ -0,0 +1,15 @@ +export default function RetryCountExceededException(requestUnit) { + this.message = 'Retry count has been exceeded'; + 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; + } +} + +RetryCountExceededException.prototype = Object.create(Error.prototype); +RetryCountExceededException.prototype.name = "RetryCountExceededException"; +RetryCountExceededException.prototype.constructor = RetryCountExceededException; \ No newline at end of file diff --git a/src/services/TokenExpiredException.js b/src/services/TokenExpiredException.js new file mode 100644 index 0000000..8507a9b --- /dev/null +++ b/src/services/TokenExpiredException.js @@ -0,0 +1,15 @@ +export default function TokenExpiredException(requestUnit) { + this.message = 'Access token has expired'; + this.requestUnit = requestUnit; + + // Use V8's native method if available, otherwise fallback + if ("captureStackTrace" in Error) { + Error.captureStackTrace(this, TokenExpiredException); + } else { + this.stack = (new Error()).stack; + } +} + +TokenExpiredException.prototype = Object.create(Error.prototype); +TokenExpiredException.prototype.name = "TokenExpiredException"; +TokenExpiredException.prototype.constructor = TokenExpiredException; diff --git a/test/fetchInterceptor.spec.js b/test/fetchInterceptor.spec.js index 65c9c12..41c3b37 100644 --- a/test/fetchInterceptor.spec.js +++ b/test/fetchInterceptor.spec.js @@ -4,13 +4,16 @@ 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/environment'; -fetchInterceptor.init(); +import * as fetchInterceptor from '../src/index'; +import sinon from 'sinon'; -const configuration = () => ({ +const configuration = config => ({ + fetchRetryCount: 1, createAccessTokenRequest: refreshToken => new Request('http://localhost:5000/token', { - headers: {authorization: `Bearer ${refreshToken}`} + headers: { + authorization: `Bearer ${refreshToken}` + } }), shouldIntercept: request => request.url.toString() !== 'http://localhost:5000/token', parseAccessToken: response => @@ -18,20 +21,15 @@ const configuration = () => ({ authorizeRequest: (request, token) => { request.headers.set('authorization', formatBearer(token)); return request; - } -}); - -const emptyConfiguration = () => ({ - createAccessTokenRequest: () => {}, - shouldIntercept: request => false, - parseAccessToken: () => {}, - authorizeRequest: () => {} + }, + shouldInvalidateAccessToken: null, + onAccessTokenChange: null, + onResponse: null, + ...config, }); describe('fetch-intercept', function () { describe('configure', () => { - - beforeEach(done => { server.start(done); }); @@ -41,48 +39,65 @@ describe('fetch-intercept', function () { }); it('throws if shouldIntercept is not set', () => { - const config = { - ...emptyConfiguration(), + const config = configuration({ shouldIntercept: null, - }; + }); expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); }); it('throws if authorizeRequest is not set', () => { - const config = { - ...emptyConfiguration(), + const config = configuration({ authorizeRequest: null, - }; + }); expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); }); it('throws if parseAccessToken is not set', () => { - const config = { - ...emptyConfiguration(), + const config = configuration({ parseAccessToken: null, - }; + }); expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); }); it('throws if createAccessTokenRequest is not set', () => { - const config = { - ...emptyConfiguration(), + const config = configuration({ createAccessTokenRequest: null, - }; + }); expect(() => fetchInterceptor.configure(config)).to.throw(Error, ERROR_INVALID_CONFIG); }); }); - describe('should not change default fetch behaviour', () => { + 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(emptyConfiguration()); - + fetchInterceptor.configure(configuration()); server.start(done); }); @@ -109,6 +124,18 @@ describe('fetch-intercept', function () { 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(() => { @@ -182,13 +209,13 @@ describe('fetch-intercept', function () { expect(response.status).to.be.equal(200); return response.json(); }) - .then(data => { - expect(data.value).to.be.equal('1'); - done(); - }) - .catch(error => { - done(error); - }); + .then(data => { + expect(data.value).to.be.equal('1'); + done(); + }) + .catch(error => { + done(error); + }); }); it('should fetch successfully with access token expired', function (done) { @@ -223,19 +250,20 @@ describe('fetch-intercept', function () { 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?invalidate=true').then(response => { + 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); - }); + .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) { @@ -286,6 +314,53 @@ describe('fetch-intercept', function () { 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', () => { @@ -302,18 +377,18 @@ describe('fetch-intercept', function () { it('should propagate 401 for single request', function (done) { fetch('http://localhost:5000/401/1').then(response => { - const tokens = fetchInterceptor.getAuthorization(); + const { refreshToken, accessToken } = fetchInterceptor.getAuthorization(); expect(response.status).to.be.equal(401); - expect(tokens.accessToken).to.be.null; - expect(tokens.refreshToken).to.be.null; + expect(refreshToken).to.be.null; + expect(accessToken).to.be.null; done(); }) - .catch((error) => { - done(error); - }); + .catch((error) => { + done(error); + }); }); it('should propagate 401 for multiple requests', function (done) { @@ -322,24 +397,24 @@ describe('fetch-intercept', function () { 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); + .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(); + const tokens = fetchInterceptor.getAuthorization(); - expect(tokens.accessToken).to.be.null; - expect(tokens.refreshToken).to.be.null; + expect(tokens.accessToken).to.be.null; + expect(tokens.refreshToken).to.be.null; - done(); - }) - .catch(error => { - done(error); - }); + done(); + }) + .catch(error => { + done(error); + }); }); }); }); diff --git a/test/helpers/server.js b/test/helpers/server.js index fcd8885..767af14 100644 --- a/test/helpers/server.js +++ b/test/helpers/server.js @@ -46,10 +46,10 @@ app.get('/token', function(req, res) { const response = () => { // exchange refresh token for new access token if (req.header('authorization') === `Bearer ${currentRefreshToken}`){ - currentToken = VALID_TOKEN; + currentToken = req.query.invalid ? 'invalid_token' : VALID_TOKEN; res.json({ - 'accessToken': currentToken + 'accessToken': currentToken, }); } else { res.status(401).send(); diff --git a/test/helpers/tokenFormatter.spec.js b/test/helpers/tokenFormatter.spec.js index 4e2ad7d..cc91f8c 100644 --- a/test/helpers/tokenFormatter.spec.js +++ b/test/helpers/tokenFormatter.spec.js @@ -4,7 +4,7 @@ import { formatBearer } from '../helpers/tokenFormatter'; describe('token formatter', () => { describe('formatBearer', () => { - it ('should return null on empty value', () => { + it('should return null on empty value', () => { const result = formatBearer(); expect(result).to.be.null; }); @@ -37,4 +37,4 @@ describe('token formatter', () => { expect(result).to.be.equal('token'); }); }) -}); \ No newline at end of file +}); From 458ec479bc8c5f2a9944aaf75eb305debea5f923 Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Fri, 24 Feb 2017 11:21:24 +0100 Subject: [PATCH 12/14] Version bump --- package.json | 2 +- src/FetchInterceptor.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3b0ec8e..02382ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shoutem/fetch-token-intercept", - "version": "0.0.1-alpha.3", + "version": "0.0.1-alpha.4", "description": "Fetch interceptor for managing refresh token flow.", "main": "lib/index.js", "files": [ diff --git a/src/FetchInterceptor.js b/src/FetchInterceptor.js index 2e9c298..753fe97 100644 --- a/src/FetchInterceptor.js +++ b/src/FetchInterceptor.js @@ -47,7 +47,7 @@ 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 From 363777763befea5e45e3baab33370e5648769675 Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Fri, 24 Feb 2017 18:30:31 +0100 Subject: [PATCH 13/14] Fixing invalid exception handling --- src/FetchInterceptor.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FetchInterceptor.js b/src/FetchInterceptor.js index 753fe97..13f031b 100644 --- a/src/FetchInterceptor.js +++ b/src/FetchInterceptor.js @@ -314,7 +314,8 @@ export default class FetchInterceptor { return fetchResolve(response); } - return fetchReject(error); + // cannot be handled here + throw new Error(error); } isConfigValid() { From 8f8357b02dc3bf87e14c626d0fafd866a677aa2a Mon Sep 17 00:00:00 2001 From: Domagoj Rukavina Date: Mon, 27 Feb 2017 14:18:27 +0100 Subject: [PATCH 14/14] Refactored exceptions to be more es6-like Added readme Minor renames in AccessTokenProvider and FetchInterceptor Version bumped --- .babelrc | 5 +- README.md | 113 ++++++++++++++++++++ babelTestSetup.js | 5 +- package.json | 3 +- src/AccessTokenProvider.js | 16 +-- src/FetchInterceptor.js | 70 ++++++------ src/services/RetryCountExceededException.js | 23 ++-- src/services/TokenExpiredException.js | 23 ++-- 8 files changed, 189 insertions(+), 69 deletions(-) create mode 100644 README.md diff --git a/.babelrc b/.babelrc index df7d1f0..06227a6 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,8 @@ { presets: ["es2015"], - plugins: ["transform-object-rest-spread"], + plugins: ["transform-object-rest-spread", + ["babel-plugin-transform-builtin-extend", { + globals: ["Error", "Array"] + }]], sourceMaps: true } 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 index 1a2f6ad..bedb2ed 100644 --- a/babelTestSetup.js +++ b/babelTestSetup.js @@ -1,5 +1,8 @@ require('babel-register')({ presets: ['es2015'], - plugins: ['transform-object-rest-spread'], + plugins: [ + 'transform-object-rest-spread', + ["babel-plugin-transform-builtin-extend", { globals: ["Error", "Array"]}] + ], sourceMaps: 'both', }); diff --git a/package.json b/package.json index 02382ef..cbb938f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shoutem/fetch-token-intercept", - "version": "0.0.1-alpha.4", + "version": "0.0.1-alpha.5", "description": "Fetch interceptor for managing refresh token flow.", "main": "lib/index.js", "files": [ @@ -33,6 +33,7 @@ "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", diff --git a/src/AccessTokenProvider.js b/src/AccessTokenProvider.js index ae2fc45..256a828 100644 --- a/src/AccessTokenProvider.js +++ b/src/AccessTokenProvider.js @@ -3,9 +3,9 @@ import { } from './services/http'; /** - * Provides a way for renewing access token with correct renew token. It will automatically + * 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 - * renew token is fetched only once no matter how many requests are trying to get + * 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. */ @@ -39,7 +39,7 @@ export default class AccessTokenProvider { renew() { // if token resolver is not authorized it should just resolve if (!this.isAuthorized()) { - console.log('Please authorize provider before renewing or check shouldIntercept config.'); + console.warn('Please authorize provider before renewing or check shouldIntercept config.'); return Promise.resolve(); } @@ -53,7 +53,7 @@ export default class AccessTokenProvider { } /** - * Authorizes intercept library with given renew token + * Authorizes intercept library with given refresh token * @param refreshToken * @param accessToken */ @@ -97,14 +97,14 @@ export default class AccessTokenProvider { return this.config.parseAccessToken(response); } - handleAccessToken(token, resolve) { - this.tokens.accessToken = token; + handleAccessToken(accessToken, resolve) { + this.tokens = { ...this.tokens, accessToken }; if (this.config.onAccessTokenChange) { - this.config.onAccessTokenChange(token); + this.config.onAccessTokenChange(accessToken); } - resolve(token); + resolve(accessToken); } handleError(error, reject) { diff --git a/src/FetchInterceptor.js b/src/FetchInterceptor.js index 13f031b..7d2b973 100644 --- a/src/FetchInterceptor.js +++ b/src/FetchInterceptor.js @@ -118,6 +118,13 @@ export default class FetchInterceptor { 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(); @@ -222,52 +229,54 @@ export default class FetchInterceptor { fetchRequest(requestUnit) { const { shouldFetch } = requestUnit; - if (shouldFetch) { - const { request, fetchCount } = requestUnit; - const { fetchRetryCount } = this.config; + if (!shouldFetch) { + return requestUnit; + } - // verifies that retry count has not been exceeded - if (fetchCount > fetchRetryCount) { - throw new RetryCountExceededException(requestUnit); - } + const { request, fetchCount } = requestUnit; + const { fetchRetryCount } = this.config; - const { fetch } = this; - return Promise.resolve(fetch(request)) - .then(response => - ({ - ...requestUnit, - response, - fetchCount: fetchCount + 1, - }) - ); + // verifies that retry count has not been exceeded + if (fetchCount > fetchRetryCount) { + throw new RetryCountExceededException(requestUnit); } - return 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) { - const { response } = requestUnit; - // check if response invalidates access token - return Promise.resolve(shouldInvalidateAccessToken(response)) - .then(shouldInvalidateAccessToken => - ({ ...requestUnit, shouldInvalidateAccessToken }) - ); + if (!shouldIntercept || !shouldInvalidateAccessToken) { + return requestUnit; } - 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) { - this.accessTokenProvider.renew(); + if (!shouldIntercept || !shouldInvalidateAccessToken) { + return requestUnit; } + this.accessTokenProvider.renew(); + return requestUnit; } @@ -317,11 +326,4 @@ export default class FetchInterceptor { // cannot be handled here throw new Error(error); } - - isConfigValid() { - return this.config.shouldIntercept && - this.config.authorizeRequest && - this.config.createAccessTokenRequest && - this.config.parseAccessToken; - } } diff --git a/src/services/RetryCountExceededException.js b/src/services/RetryCountExceededException.js index 512ef1f..7e43b64 100644 --- a/src/services/RetryCountExceededException.js +++ b/src/services/RetryCountExceededException.js @@ -1,15 +1,14 @@ -export default function RetryCountExceededException(requestUnit) { - this.message = 'Retry count has been exceeded'; - this.requestUnit = requestUnit; +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; + // Use V8's native method if available, otherwise fallback + if ("captureStackTrace" in Error) { + Error.captureStackTrace(this, RetryCountExceededException); + } else { + this.stack = (new Error()).stack; + } } } - -RetryCountExceededException.prototype = Object.create(Error.prototype); -RetryCountExceededException.prototype.name = "RetryCountExceededException"; -RetryCountExceededException.prototype.constructor = RetryCountExceededException; \ No newline at end of file diff --git a/src/services/TokenExpiredException.js b/src/services/TokenExpiredException.js index 8507a9b..f337d07 100644 --- a/src/services/TokenExpiredException.js +++ b/src/services/TokenExpiredException.js @@ -1,15 +1,14 @@ -export default function TokenExpiredException(requestUnit) { - this.message = 'Access token has expired'; - this.requestUnit = requestUnit; +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; + // Use V8's native method if available, otherwise fallback + if ("captureStackTrace" in Error) { + Error.captureStackTrace(this, TokenExpiredException); + } else { + this.stack = (new Error()).stack; + } } } - -TokenExpiredException.prototype = Object.create(Error.prototype); -TokenExpiredException.prototype.name = "TokenExpiredException"; -TokenExpiredException.prototype.constructor = TokenExpiredException;