Search code examples
reactjsreduxjwtredux-sagafeathersjs

How to ensure JWT validation occurs before React/Redux router redirect?


I'm developing a full stack PERN application using React/Redux, Knex + Objection.Js + PostgreSQL for the DB, and feathersjs for the API framework. As such, I'm using @feathersjs/client on the frontend as well and their authentication package. I'm also using connected-react-router for my routing. Unfortunately, whenever I attempt to navigate to a protected route, the "login" request responsible for setting the state of the user (from their jwt authenticating with the server) doesn't finish before the redirect takes the user to the login page.

I'm validating the jwt in the index.js file of the react application by dispatching an action.

if (localStorage['feathers-jwt']) {
  try {
       store.dispatch(authActions.login({strategy: 'jwt', accessToken: localStorage.getItem('feathers-jwt')}));
  }
  catch (err){
      console.log('authenticate catch', err);
  }
}

The action is picked up by redux-saga which performs the following action

export function* authSubmit(action) {
  console.log('received authSubmit');
  try {
    const data = yield call(loginApi, action);
    yield put({type: authTypes.LOGIN_SUCCESS, data});

  } catch (error) {
      console.log(error);
      yield put({type: authTypes.LOGIN_FAILURE, error})
  }
}

function loginApi(authParams) {
  return services.default.authenticate(authParams.payload)
}

Here's my isAuthenticated function with configuration object:

const isAuthenticated =  connectedReduxRedirect({
  redirectPath: '/login',
  authenticatedSelector: state => state.auth.user !== null,
  redirectAction: routerActions.replace,
  wrapperDisplayName: 'UserIsAuthenticated'
});

Here's the HOC being applied to the container components

const Login = LoginContainer;
const Counter = isAuthenticated(CounterContainer);
const LoginSuccess = isAuthenticated(LoginSuccessContainer);

And finally, here's the render

export default function (store, history) {
  ReactDOM.render(
    <Provider store={store}>
      <ConnectedRouter history={history}>
        <Switch>
          <Route exact={true} path="/" component={App}/>
          <Route path="/login" component={Login}/>
          <Route path="/counter" component={Counter}/>
          <Route path="/login-success" component={LoginSuccess}/>
          <Route component={NotFound} />
        </Switch>
      </ConnectedRouter>
    </Provider>,
    document.getElementById('root')
  );
}

What I expect to happen, when logged in and visiting, for example, /counter is the following

  1. LOGIN_REQUEST action fired

  2. LOGIN_SUCCESS action fired, user is authenticated by JWT

  3. router sees user.auth object isn't null, therefore the user is Authenticated

  4. router permits navigation without redirection

What I see instead is the following (when navigating manually to /counter)

  1. @@INIT

  2. auth/LOGIN_REQUEST [this is good, loggingIn: true]

  3. @@router/LOCATION_CHANGE

{
  type: '@@router/LOCATION_CHANGE',
  payload: {
    location: {
      pathname: '/counter',
      search: '',
      hash: ''
    },
    action: 'POP',
    isFirstRendering: true
  }
}
  1. @@router_LOCATION_CHANGE [this is the problem]
  type: '@@router/LOCATION_CHANGE',
  payload: {
    location: {
      pathname: '/login',
      hash: '',
      search: '?redirect=%2Fcounter',
      key: 'kdnf4l'
    },
    action: 'REPLACE',
    isFirstRendering: false
  }
}
  1. User navigates to /login, which logs the user out as it's currently designed.

  2. LOGOUT_REQUEST -> LOGIN_SUCCESS -> LOCATION_CHANGE (to /login-success)

Again, any help would be greatly appreciated and I can provide anything else as is needed.

Thanks!

-Brenden


Solution

  • Solution

    I was able to solve this today by taking a look at how the authentication package feathers-reduxify-authentication function. The redirect was, for the most part, configured correctly.

    BACKEND

    authentication.js

    Note multiple strategies, and how the context.result is returned. This is necessary for feathers-reduxify-authentication to work properly.

    module.exports = function (app) {
      const config = app.get('authentication');
    
      // Set up authentication with the secret
      app.configure(authentication(config));
      app.configure(jwt());
      app.configure(local(config.local));
    
    
      app.service('authentication').hooks({
        before: {
          create: [
            authentication.hooks.authenticate(config.strategies),
          ],
          remove: [
            authentication.hooks.authenticate('jwt')
          ]
        },
        after: {
          create: [
            context => {
              context.result.data = context.params.user;
              context.result.token = context.data.accessToken;
              delete context.result.data.password;
              return context;
            }
          ]
        }
      });
    };
    

    FRONTEND

    src/feathers/index.js

    This is according to eddystop's example project, but upgraded to feathers 3.0+

    import feathers from '@feathersjs/client';
    import  io  from 'socket.io-client';
    import reduxifyAuthentication from 'feathers-reduxify-authentication';
    import reduxifyServices, { getServicesStatus } from 'feathers-redux';
    import { mapServicePathsToNames, prioritizedListServices } from './feathersServices';
    const hooks = require('@feathersjs/client');
    
    const socket = io('http://localhost:3030');
    const app = feathers()
      .configure(feathers.socketio(socket))
      .configure(hooks)
      .configure(feathers.authentication({
        storage: window.localStorage
      }));
    export default app;
    
    // Reduxify feathers-client.authentication
    export const feathersAuthentication = reduxifyAuthentication(app,
      { authSelector: (state) => state.auth.user}
    );
    // Reduxify feathers services
    export const feathersServices = reduxifyServices(app, mapServicePathsToNames);
    export const getFeathersStatus =
      (servicesRootState, names = prioritizedListServices) =>
        getServicesStatus(servicesRootState, names);
    

    middleware and store. src/state/configureStore

    redux-saga is temporarily removed, I'll be bringing it back once I finish testing

    import { createBrowserHistory } from 'history';
    import { createStore, applyMiddleware, compose } from "redux";
    import { routerMiddleware  } from 'connected-react-router';
    import createRootReducer from './ducks';
    import promise  from 'redux-promise-middleware';
    import reduxMulti from 'redux-multi';
    import rootSaga from '../sagas';
    import createSagaMiddleware from 'redux-saga';
    export default function configureStore(initialState) {
    
        const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
            || compose;
    
        const middlewares = [
            //sagaMiddleware,
            promise,
            reduxMulti,
            routerMiddleware(history)];
    
        const store = createStore(
            createRootReducer(history),
            initialState,
            composeEnhancer(
                applyMiddleware(
                    ...middlewares
                )
            )
        );
    
        return store;
    }
    

    root reducers, src/state/ducks/index.js

    import { combineReducers } from "redux";
    import { connectRouter } from 'connected-react-router';
    import { reducer as reduxFormReducer } from 'redux-form';
    import {feathersAuthentication, feathersServices} from '../../feathers';
    import counter from './counter';
    
    const rootReducer = (history) => combineReducers({
        counter,
        router: connectRouter(history),
        users: feathersServices.users.reducer,
        auth: feathersAuthentication.reducer,
        form: reduxFormReducer, // reducers required by redux-form
    
    });
    
    export default rootReducer;