Skip to content
This repository has been archived by the owner on Jun 19, 2019. It is now read-only.

Commit

Permalink
replace existing redux-implicit-oauth2 implementation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
rjw57 committed Apr 13, 2018
1 parent 86ecd15 commit 6c6e3af
Show file tree
Hide file tree
Showing 10 changed files with 277 additions and 26 deletions.
2 changes: 1 addition & 1 deletion src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 0 additions & 1 deletion src/containers/AppRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ const AppRoutes = () => (
<LoginRequiredRoute path="/feedback" exact component={() => <Static page='feedback' />}/>
<LoginRequiredRoute path={NOT_A_USER_PATH} exact component={() => <Static page='no_permission' withSidebar={false} />}/>

<Route path="/oauth2-callback" exact component={() => <div />} />
<LoginRequiredRoute path="/" exact component={RedirectToMyDeptAssets} />

{ /* Catch all route for "not found" */ }
Expand Down
44 changes: 26 additions & 18 deletions src/redux/actions/auth.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,45 @@
/**
* 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 "/".
*
* Returns a function and requires the redux-thunk middleware.
*/
export const logout = () => dispatch => {
dispatch(implicitLogout());
dispatch({ type: LOGOUT });
history.push('/');
};
41 changes: 41 additions & 0 deletions src/redux/actions/auth.test.js
Original file line number Diff line number Diff line change
@@ -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 }
});
});
});
3 changes: 1 addition & 2 deletions src/redux/enhancer.js
Original file line number Diff line number Diff line change
@@ -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') {
Expand Down
92 changes: 92 additions & 0 deletions src/redux/reducers/auth.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
112 changes: 112 additions & 0 deletions src/redux/reducers/auth.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 2 additions & 2 deletions src/redux/reducers/index.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/redux/reducers/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion src/test/mock-localstorage.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down

0 comments on commit 6c6e3af

Please sign in to comment.