From 6c6e3af6a3103290815d741c77139fe148f70624 Mon Sep 17 00:00:00 2001 From: Rich Wareham Date: Thu, 12 Apr 2018 17:30:17 +0100 Subject: [PATCH] replace existing redux-implicit-oauth2 implementation Replace the existing redux-implicit-oauth2 authorisation with a work-alike which uses the same format for the redux state. This is a little bit of a halfway-horse; ideally we'd store the full client-oauth2 token object in the state but a lot of the rest of the app depends on the state having the same form and this commit is intended to be a small surgical change. Since we already hid the redux-implicit-oauth2 actions inside the src/redux/actions/auth.js module, replacing them is relatively straightforward. We add the ability to do prompt-less login via the login action. This is currently unused but is in place for the token timeout handling behaviour. We keep the existing behaviour of keeping the auth token in localStorage so that re-visits to the app can have speedy login. We load it as a by-product of initialising the local state which means we can do away with the existing auth middleware. Since we no-longer use the redux-implicit-oauth2 implementation, we can remove the special-case route in AppRoutes for it. --- src/config.js | 2 +- src/containers/AppRoutes.js | 1 - src/redux/actions/auth.js | 44 +++++++----- src/redux/actions/auth.test.js | 41 +++++++++++ src/redux/enhancer.js | 3 +- src/redux/reducers/auth.js | 92 +++++++++++++++++++++++++ src/redux/reducers/auth.test.js | 112 +++++++++++++++++++++++++++++++ src/redux/reducers/index.js | 4 +- src/redux/reducers/index.test.js | 2 +- src/test/mock-localstorage.js | 2 +- 10 files changed, 277 insertions(+), 26 deletions(-) create mode 100644 src/redux/actions/auth.test.js create mode 100644 src/redux/reducers/auth.js create mode 100644 src/redux/reducers/auth.test.js diff --git a/src/config.js b/src/config.js index 115de38..59c19d2 100644 --- a/src/config.js +++ b/src/config.js @@ -41,7 +41,7 @@ const config = { // If it hasn't previously been set, set the rdirect URL for OAuth2 implicit flow. We set it here // because "basename" may have been overridden by the reactAppConfiguration object. if(!config.oauth2RedirectUrl) { - config.oauth2RedirectUrl = window.location.origin + config.basename + 'oauth2-callback'; + config.oauth2RedirectUrl = window.location.origin + config.basename + 'oauth2-callback.html'; } export default config; diff --git a/src/containers/AppRoutes.js b/src/containers/AppRoutes.js index cc42da7..5e63716 100644 --- a/src/containers/AppRoutes.js +++ b/src/containers/AppRoutes.js @@ -24,7 +24,6 @@ const AppRoutes = () => ( }/> }/> -
} /> { /* Catch all route for "not found" */ } diff --git a/src/redux/actions/auth.js b/src/redux/actions/auth.js index afb0116..69bd0e9 100644 --- a/src/redux/actions/auth.js +++ b/src/redux/actions/auth.js @@ -1,30 +1,38 @@ /** * Redux actions for authenticating and authorising current user. * - * These are relatively thin wrappers around redux-implicit-oauth2's actions. They're put here to - * decouple the login/logout logic from having to know the mechanism by which login and logout are - * achieved. */ -import { login as implicitLogin, logout as implicitLogout } from 'redux-implicit-oauth2'; import history from '../../history' -import config from '../../config'; +import { login as authLogin } from '../../auth'; -/** - * OAuth2 credentials configuration for the IAR frontend application. - */ -const oauth2Config = { - url: config.oauth2AuthEndpoint, - client: config.oauth2ClientId, - redirect: config.oauth2RedirectUrl, - scope: config.oauth2Scopes, - width: config.oauth2PopupWidth, // Width (in pixels) of login popup window. - height: config.oauth2PopupHeight, // Height (in pixels) of login popup window. -}; +// Action types +export const LOGIN_REQUEST = 'LOGIN_REQUEST'; +export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; +export const LOGIN_FAILURE = 'LOGIN_FAILURE'; +export const LOGOUT = 'LOGOUT'; /** * Initialise login to application. + * + * Takes an optional options object as passed to auth.login(). Returns a thunk function which + * returns a promise resolved with the result of dispatching a LOGIN_SUCCESS or LOGIN_FAILURE + * action. + * + * Returns a function and requires the redux-thunk middleware. */ -export const login = () => implicitLogin(oauth2Config); +export const login = options => dispatch => { + dispatch({ type: LOGIN_REQUEST }); + + return authLogin(options) + .then(token => dispatch({ + type: LOGIN_SUCCESS, + payload: { token }, + })) + .catch(error => dispatch({ + type: LOGIN_FAILURE, + payload: { error }, + })); +} /** * Log out from the application and redirect to "/". @@ -32,6 +40,6 @@ export const login = () => implicitLogin(oauth2Config); * Returns a function and requires the redux-thunk middleware. */ export const logout = () => dispatch => { - dispatch(implicitLogout()); + dispatch({ type: LOGOUT }); history.push('/'); }; diff --git a/src/redux/actions/auth.test.js b/src/redux/actions/auth.test.js new file mode 100644 index 0000000..2ee0836 --- /dev/null +++ b/src/redux/actions/auth.test.js @@ -0,0 +1,41 @@ +jest.mock('../../auth'); + +import { login, LOGIN_SUCCESS, LOGIN_FAILURE } from './auth'; +import { login as authLogin } from '../../auth'; + +describe('login', () => { + let dispatch; + + beforeEach(() => { + authLogin.mockReset(); + authLogin.mockImplementation(() => Promise.resolve('some-token')); + dispatch = jest.fn(action => action); + }); + + test('passes options to auth.login', () => { + const options = { prompt: true }; + login(options)(dispatch); + expect(authLogin).toHaveBeenCalledTimes(1); + expect(authLogin).toHaveBeenCalledWith(options); + }); + + test('returns promise resolved with LOGIN_SUCCESS action if login succeeds', () => { + const options = { prompt: true }, token = 'new-token'; + authLogin.mockImplementation(() => Promise.resolve(token)); + const authPromise = login(options)(dispatch); + expect(authPromise).toBeInstanceOf(Promise); + expect(login(options)(dispatch)).resolves.toMatchObject({ + type: LOGIN_SUCCESS, payload: { token } + }); + }); + + test('returns promise resolved with LOGIN_FAILURE action if login succeeds', () => { + const options = { prompt: true }, error = 'do-not-like-you'; + authLogin.mockImplementation(() => Promise.reject(error)); + const authPromise = login(options)(dispatch); + expect(authPromise).toBeInstanceOf(Promise); + expect(login(options)(dispatch)).resolves.toMatchObject({ + type: LOGIN_FAILURE, payload: { error } + }); + }); +}); diff --git a/src/redux/enhancer.js b/src/redux/enhancer.js index fbc5ed4..91cd1ed 100644 --- a/src/redux/enhancer.js +++ b/src/redux/enhancer.js @@ -1,11 +1,10 @@ import { applyMiddleware } from 'redux'; -import { authMiddleware as auth } from 'redux-implicit-oauth2'; import { apiMiddleware as api } from 'redux-api-middleware'; import { apiAuth, netError } from './middlewares'; import logger from 'redux-logger'; import thunk from 'redux-thunk'; -export const middlewares = [thunk, auth, apiAuth, netError, api]; +export const middlewares = [thunk, apiAuth, netError, api]; // only add logger middleware in development if (process.env.NODE_ENV === 'development') { diff --git a/src/redux/reducers/auth.js b/src/redux/reducers/auth.js new file mode 100644 index 0000000..030a96a --- /dev/null +++ b/src/redux/reducers/auth.js @@ -0,0 +1,92 @@ +import { LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT } from '../actions/auth'; + +export const AUTH_TOKEN_STORAGE_KEY = 'cachedOAuth2TokenData'; + +// Update initial state based upon any token saved in the localStorage +export const retrieveStoredToken = state => { + // no localStorage support in browser? + if(!window.localStorage) { return state; } + + // is there any stored data? + const storedTokenJSON = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); + if(!storedTokenJSON) { return state; } + + // attempt to parse it as JSON + let storedToken; + try { + storedToken = JSON.parse(storedTokenJSON); + } catch(err) { + // we silently swallow the error here to avoid polluting the console but we could report it + // via, for example: + // console.error(err); + return state; + } + + // extract token data + const { token, expiresAt } = storedToken; + + // is the token still alive? + if(!token || !expiresAt || expiresAt < (new Date()).getTime()) { return state; } + + // everything checks out, retrieve it + return { ...state, isLoggedIn: true, token, expiresAt }; +}; + +// Save access token to localStorage +const saveStoredToken = (token, expiresAt) => { + // no localStorage support in browser? + if(!window.localStorage) { return; } + window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, JSON.stringify({ token, expiresAt })); +}; + +// Remove any access token cached in local storage. +const clearStoredToken = () => { + // no localStorage support in browser? + if(!window.localStorage) { return; } + window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); +} + +// The initial state is passed through retrieveStoredToken which sets the token, expiresAt and +// isLoggedIn fields if there is a valid token saved in the browser's local storage. +export const initialState = retrieveStoredToken({ + // Current access token for user or null if there is not one. + token: null, + + // If non-null, the last login error. + error: null, + + // If true, the user is currently logging in. + isLoggingIn: false, + + // If true, the user is currently logged in. + isLoggedIn: false, + + // A timestamp (as returned by Date.getTime()) indicating when the token expires. + expiresAt: null, +}); + +export default (state = initialState, action) => { + switch(action.type) { + case LOGIN_REQUEST: + return {...state, isLoggingIn: true }; + case LOGIN_SUCCESS: + { + // extract the authorisation token from the action's payload + const { token: tokenObject } = action.payload; + const token = tokenObject.accessToken, expiresAt = tokenObject.expires.getTime(); + + // cache the token in the browser's localStorage + saveStoredToken(token, expiresAt); + + return { ...state, isLoggingIn: false, isLoggedIn: true, token, expiresAt }; + } + case LOGIN_FAILURE: + return {...state, isLoggingIn: false, isLoggedIn: false, token: null, expiresAt: null}; + case LOGOUT: + // clear any cached token from localStorage + clearStoredToken(); + return {...state, isLoggedIn: false, token: null, expiresAt: null}; + default: + return state; + } +} diff --git a/src/redux/reducers/auth.test.js b/src/redux/reducers/auth.test.js new file mode 100644 index 0000000..69f27a3 --- /dev/null +++ b/src/redux/reducers/auth.test.js @@ -0,0 +1,112 @@ +// mock window.localStorage +import '../../test/mock-localstorage'; + +import reducer, { AUTH_TOKEN_STORAGE_KEY, retrieveStoredToken } from './auth'; +import { LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT } from '../actions/auth'; + +describe('LOGIN_REQUEST', () => { + test('sets isLoggingIn', () => { + const state = reducer(undefined, { type: LOGIN_REQUEST }); + expect(state.isLoggingIn).toBe(true); + }); +}); + +describe('LOGIN_SUCCESS', () => { + let action, state; + + beforeEach(() => { + // make sure local storage is clear + window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); + + action = { + type: LOGIN_SUCCESS, payload: { token: { accessToken: 'xxx', expires: new Date() } } + }; + + state = reducer(undefined, action); + }); + + test('sets isLoggedIn', () => { + expect(state.isLoggedIn).toBe(true); + }); + + test('sets token', () => { + expect(state.token).toBe(action.payload.token.accessToken); + }); + + test('sets expiresAt', () => { + expect(state.expiresAt).toBe(action.payload.token.expires.getTime()); + }); + + test('clears isLoggingIn', () => { + expect(state.isLoggingIn).toBe(false); + }); + + test('stores state', () => { + // now initial state should find the token cached + const newInitialState = retrieveStoredToken(reducer(undefined, { })); + expect(newInitialState).toMatchObject({ + token: action.payload.token.accessToken, + expiresAt: action.payload.token.expires.getTime(), + }); + }); +}); + +describe('LOGIN_FAILURE', () => { + let action, state; + + beforeEach(() => { + action = { + type: LOGIN_FAILURE, payload: { error: 'xxx' } + }; + state = reducer(undefined, action); + }); + + test('clears isLoggingIn', () => { + expect(state.isLoggingIn).toBe(false); + }); + + test('clears isLoggedIn', () => { + expect(state.isLoggedIn).toBe(false); + }); + + test('clears token', () => { + expect(state.token).toBe(null); + }); + + test('clears expiresAt', () => { + expect(state.expiresAt).toBe(null); + }); +}); + +describe('LOGOUT', () => { + let action, state; + + beforeEach(() => { + window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, 'xxx'); + action = { type: LOGOUT }; + state = reducer(undefined, action); + }); + + test('clears isLoggedIn', () => { + expect(state.isLoggedIn).toBe(false); + }); + + test('clears token', () => { + expect(state.token).toBe(null); + }); + + test('clears expiresAt', () => { + expect(state.expiresAt).toBe(null); + }); + + test('clears stored token', () => { + }); +}); + +describe('retrieveStoredToken', () => { + test('returns its input if stored state invalid', () => { + window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, 'not-some-json'); + const initialState = reducer(undefined, {}); + expect(retrieveStoredToken(initialState)).toBe(initialState); + }); +}); diff --git a/src/redux/reducers/index.js b/src/redux/reducers/index.js index b903db0..cc7de2e 100644 --- a/src/redux/reducers/index.js +++ b/src/redux/reducers/index.js @@ -1,12 +1,12 @@ import { combineReducers } from 'redux'; -import { authReducer as auth } from 'redux-implicit-oauth2'; +import auth from './auth'; import assets from './assetRegisterApi'; import deleteConfirmation from './deleteConfirmation'; import snackbar from './snackbar'; import lookupApi from './lookupApi'; import editAsset from './editAsset'; -import { LOGOUT } from 'redux-implicit-oauth2'; +import { LOGOUT } from '../actions/auth'; /** * Combine all reducers used in the application together into one reducer. diff --git a/src/redux/reducers/index.test.js b/src/redux/reducers/index.test.js index 29e85d3..5a2caf5 100644 --- a/src/redux/reducers/index.test.js +++ b/src/redux/reducers/index.test.js @@ -11,7 +11,7 @@ jest.mock('redux', () => { import { combineReducers } from 'redux'; import wrappedReducers, { reducers } from './index'; -import { LOGOUT } from 'redux-implicit-oauth2'; +import { LOGOUT } from '../actions/auth'; const mockReducers = combineReducers(); diff --git a/src/test/mock-localstorage.js b/src/test/mock-localstorage.js index 7252b42..a7fb040 100644 --- a/src/test/mock-localstorage.js +++ b/src/test/mock-localstorage.js @@ -1,7 +1,7 @@ /** * Import this module to mock the global window.localStorage object. * - * The redux-implicit-oauth2 package examines localStorage when first run to determine if there is + * The implicit OAuth2 implmentation examines localStorage when first run to determine if there is * any OAuth2 token defined for the app. */ import localStorage from 'mock-local-storage';