Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to redirect to correct client route after social auth with Passport (react, react-router, express, passport)

I have a React/Redux/React Router front end, Node/Express back end. I’m using Passport (various strategies including Facebook, Google and Github) for authentication.

What I want to happen:

  1. Unauthenticated user attempts to access protected client route (something like /posts/:postid, and is redirected to /login. (React Router is handling this part)

  2. User clicks the ‘Log in with Facebook’ button (or other Social auth service)

  3. After authentication, user is automatically redirected back to the route they were attempting to access in step 1.

What is happening instead:

The only way I’ve found to successfully handle Passport social authentication with a React front end is to wrap the ‘Log in with Facebook’ button in an <a> tag:

<a href="http://localhost:8080/auth/facebook">Facebook Login</a>

If I try to do it as an API call instead of a link I always get an error message (this issue is explained in a lot more detail here: Authentication with Passport + Facebook + Express + create-react-app + React-Router + proxy)

So the user clicks the link, which hits the Express API, successfully authenticates with Passport, and then Passport redirects to the callback route (http://localhost:8080/auth/facebook/callback).

In the callback function I need to (1) return the user object and token to the client, and (2) redirect to a client route — either the protected route they were trying to access before they got redirected to /login, or some default route like / or /dashboard.

But since there isn’t a way to do both of these things in Express (I can’t res.send AND res.redirect, I have to choose one), I’ve been handling it in what feels like kind of a clunky way: res.redirect(`${CLIENT_URL}/user/${userId}`)

This loads the /user route on the client, and then I’m pulling the userId out of the route params, saving it to Redux, then making ANOTHER call to the server to return the token to save token to localStorage.

This is all working, although it feels clunky, but I can’t figure out how to redirect to the protected route the user was trying to access before being prompted to log in.

I first tried saving the attempted route to Redux when the user tries to access it, thinking I could use that to redirect once they land on the profile page after authentication. But since the Passport auth flow takes the user off-site for 3d-party authentication and then reloads the SPA on res.redirect, the store is destroyed and the redirect path is lost.

What I ended up settling on is saving the attempted route to localStorage, checking to see if there is a redirectUrl key in localStorage when the /user component mounts on the front end, redirecting with this.props.history.push(redirectUrl) and then clearing the redirectUrl key from localStorage. This seems like a really dirty workaround and there has got to be a better way to do this. Has anybody else figuree out how to make this work?

like image 228
rg_ Avatar asked Apr 12 '18 05:04

rg_


2 Answers

In case anybody else is struggling with this, this is what I ended up going with:

1. When user tries to access protected route, redirect to /login with React-Router.

First define a <PrivateRoute> component:

// App.jsx

const PrivateRoute = ({ component: Component, loggedIn, ...rest }) => {
  return (
    <Route
      {...rest}
      render={props =>
        loggedIn === true ? (
          <Component {...rest} {...props} />
        ) : (
          <Redirect
            to={{ pathname: "/login", state: { from: props.location } }}
          />
        )
      }
    />
  );
};

Then pass the loggedIn property to the route:

// App.jsx

<PrivateRoute
  loggedIn={this.props.appState.loggedIn}
  path="/poll/:id"
  component={ViewPoll}
/>

2. In /login component, save previous route to localStorage so I can later redirect back there after authentication:

// Login.jsx

  componentDidMount() {
   const { from } = this.props.location.state || { from: { pathname: "/" } };
   const pathname = from.pathname;
   window.localStorage.setItem("redirectUrl", pathname);
}

3. In SocialAuth callback, redirect to profile page on client, adding userId and token as route params

// auth.ctrl.js

exports.socialAuthCallback = (req, res) => {
  if (req.user.err) {
    res.status(401).json({
        success: false,
        message: `social auth failed: ${req.user.err}`,
        error: req.user.err
    })
  } else {
    if (req.user) {
      const user = req.user._doc;
      const userInfo = helpers.setUserInfo(user);
      const token = helpers.generateToken(userInfo);
      return res.redirect(`${CLIENT_URL}/user/${userObj._doc._id}/${token}`);
    } else {
      return res.redirect('/login');
    }
  }
};

4. In the Profile component on the client, pull the userId and token out of the route params, immediately remove them using window.location.replaceState, and save them to localStorage. Then check for a redirectUrl in localStorage. If it exists, redirect and then clear the value

// Profile.jsx

  componentWillMount() {
    let userId, token, authCallback;
    if (this.props.match.params.id) {
      userId = this.props.match.params.id;
      token = this.props.match.params.token;
      authCallback = true;

      // if logged in for first time through social auth,
      // need to save userId & token to local storage
      window.localStorage.setItem("userId", JSON.stringify(userId));
      window.localStorage.setItem("authToken", JSON.stringify(token));
      this.props.actions.setLoggedIn();
      this.props.actions.setSpinner("hide");

      // remove id & token from route params after saving to local storage
      window.history.replaceState(null, null, `${window.location.origin}/user`);
    } else {
      console.log("user id not in route params");

      // if userId is not in route params
      // look in redux store or local storage
      userId =
        this.props.profile.user._id ||
        JSON.parse(window.localStorage.getItem("userId"));
      if (window.localStorage.getItem("authToken")) {
        token = window.localStorage.getItem("authToken");
      } else {
        token = this.props.appState.authToken;
      }
    }

    // retrieve user profile & save to app state
    this.props.api.getProfile(token, userId).then(result => {
      if (result.type === "GET_PROFILE_SUCCESS") {
        this.props.actions.setLoggedIn();
        if (authCallback) {
          // if landing on profile page after social auth callback,
          // check for redirect url in local storage
          const redirect = window.localStorage.getItem("redirectUrl");
          if (redirect) {
            // redirect to originally requested page and then clear value
            // from local storage
            this.props.history.push(redirect);
            window.localStorage.setItem("redirectUrl", null);
          }
        }
      }
    });
  }

This blog post was helpful in figuring things out. The #4 (recommended) solution in the linked post is much simpler and would probably work fine in production, but I couldn't get it to work in development where the server and client have different base URLs, because a value set to localStorage by a page rendered at the server URL will not exist in local Storage for the client URL

like image 160
rg_ Avatar answered Oct 22 '22 21:10

rg_


Depending on your application architecture, I can give you a couple of ideas, but they are all based on the fundamental :

Once you have backend handling authentication, you need to store the state of the user in your backend as well ( via session cookie / JWT )

You can create a cookie-session store for your express app which cookie, you need to configure properly to use both the domains ( the backend domain and the front-end domain ) or use JWT for this.

Let's go with more details

Use React to check the authentication state

You can implement an end-point in express called /api/credentials/check which will return 403 if the user is not authenticated and 200 if is.

In your react app you will have to call this end-point and check if the user is authenticated or not. In case of not authenticated you can redirect to /login in your React front-end.

I use something similar :

class AuthRoute extends React.Component {
    render() {

        const isAuthenticated = this.props.user;
        const props = assign( {}, this.props );

        if ( isAuthenticated ) {
             return <Route {...props} />;
        } else {
             return <Redirect to="/login"/>;
        }

    }
}

And then in your router

<AuthRoute exact path="/users" component={Users} />
<Route exact path="/login" component={Login} />

In my root component I add

componentDidMount() {
    store.dispatch( CredentialsActions.check() );
}

Where CredentialsActions.check is just a call that populates props.user in case we return 200 from /credentials/check.

Use express to render your React app and dehydrate the user state inside the react app

This one is a bit tricky. And it has the presumption that your react app is served from your express app and not as static .html file.

In this case you can add a special <script>const state = { authenticated: true }</script> which will be served by express if the user was authenticated.

By doing this you can do:

const isAuthenticated = window.authenticated;

This is not the best practice, but it's the idea of hydrate and rehydration of your state.

References :

  1. Hydration / rehydration in Redux
  2. Hydrate / rehydrate idea
  3. Example of React / Passport authentication
  4. Example of cookie / Passport authentication
like image 2
drinchev Avatar answered Oct 22 '22 21:10

drinchev