How to prevent a route from registering before jwt verifies react

Viewed 316

today I'm having an issue where react loads the route before my API verifies that the user's JWT token is valid. When using EJS I could pass in a middleware to the route and the middleware would not contain the next() parameter. As a result the server wouldn't render the EJS which is exactly what I want to achieve with react. Also is it possible to make useNavigate not reload when navigating the that specific route?

My routes in App.js currently look like this:

<Route element={<ProtectedRoute access={access}></ProtectedRoute>}>
  <Route
    path="/login"
    exact
    element={<Login login={login} access={access}></Login>}
  ></Route>
  <Route
    path="/signup"
    exact
    element={<Signup signup={signup} access={access}></Signup>}
  ></Route>
  <Route
    path="/forgot-password"
    exact
    element={<ForgotPassword access={access}></ForgotPassword>}
  ></Route>
  <Route
    path="/reset-password"
    exact
    element={<ResetPassword access={access}></ResetPassword>}
  ></Route>
</Route>;

The access function looks like this:

const access = async (token) => {
  return await axios.post(
    "http://localhost:5000/access",
    {},
    { headers: { Authorization: `Bearer ${token}` } }
  );
};

The protected route component looks like this:

import { useState, useContext } from "react";
import { useLocation, useNavigate, Outlet } from "react-router-dom";
import AuthContext from "../Context/AuthProvider";

const ProtectedRoute = ({ access }) => {
  const [authorized, setAuthorized] = useState(false);
  const { auth } = useContext(AuthContext);
  const navigate = useNavigate();

  const authorize = async () => {
    try {
      await access(auth.accessToken);
      setAuthorized(true);
    } catch (err) {
      setAuthorized(false);
    }
  };

  authorize();

  if (authorized) {
    navigate('/');
  } else {
    return <Outlet></Outlet>;
  }
};

export default ProtectedRoute;

When I use this code my login component renders a bit before the code navigates back to the home page, how do I make the login component not render at all and just make it stay on the home page?

1 Answers

Issue

  1. The ProtectedRoute component's initial authorized state masks the confirmed unauthenticated state, and since the component doesn't wait for authentication confirmation it happily and incorrectly redirects to "/".
  2. The ProtectedRoute component incorrectly issues a navigation action as an unintentional side-effect via the navigate function and doesn't return valid JSX in the unauthenticated case. Use the Navigate component instead.
  3. If the user is authorized the ProtectedRoute should render the Outlet for a protected route to be rendered into, and only redirect to login if unauthorized.

Solution

The ProtectedRoute component should use an indeterminant initial authorized state that doesn't match either the authenticated or unauthenticated state, and wait for the auth status to be confirmed before rendering either the Outlet or Navigate components.

Example:

import { useState, useContext } from "react";
import { useLocation, Navigate, Outlet } from "react-router-dom";
import AuthContext from "../Context/AuthProvider";

const ProtectedRoute = ({ access }) => {
  const location = useLocation();
  const [authorized, setAuthorized] = useState(); // initially undefined!

  const { auth } = useContext(AuthContext);

  useEffect(() => {
    const authorize = async () => {
      try {
        await access(auth.accessToken);
        setAuthorized(true);
      } catch (err) {
        setAuthorized(false);
      }
    };

    authorize();
  }, []);

  if (authorized === undefined) {
    return null; // or loading indicator/spinner/etc
  }

  return authorized
    ? <Outlet />
    : <Navigate to="/login" replace state={{ from: location }} />;
};

Move the login route outside the ProtectedRoute layout route.

<Routes>
  <Route
    path="/login"
    element={<Login login={login} access={access} />}
  />
  <Route
    path="/signup"
    element={<Signup signup={signup} access={access} />}
  />
  <Route
    path="/forgot-password"
    element={<ForgotPassword access={access} />}
  />
  <Route
    path="/reset-password"
    element={<ResetPassword access={access} />}
  />
  ... other unprotected routes ...

  <Route element={<ProtectedRoute access={access} />}>
    ... other protected routes ...
  </Route>
</Routes>

To protect the login/signup/forgot/reset/etc routes

Create an AnonymousRoute component that inverts the Outlet and Navigate components on the authentication status. This time authenticated users get redirected off the route.

const AnonymousRoute = ({ access }) => {
  const [authorized, setAuthorized] = useState(); // initially undefined!

  const { auth } = useContext(AuthContext);

  useEffect(() => {
    const authorize = async () => {
      try {
        await access(auth.accessToken);
        setAuthorized(true);
      } catch (err) {
        setAuthorized(false);
      }
    };

    authorize();
  }, []);

  if (authorized === undefined) {
    return null; // or loading indicator/spinner/etc
  }

  return authorized
    ? <Navigate to="/" replace />
    : <Outlet />;
};

...

<Routes>
  <Route element={<AnonymousRoute access={access} />}>
    <Route path="/login" element={<Login login={login} access={access} />} />
    <Route path="/signup" element={<Signup signup={signup} access={access} />} />
    <Route path="/forgot-password" element={<ForgotPassword access={access} />} />
    <Route path="/reset-password" element={<ResetPassword access={access} />} />
    ... other protected anonymous routes ...
  </Route>
  
  ... unprotected routes ...

  <Route element={<ProtectedRoute access={access} />}>
    ... other protected authenticated routes ...
  </Route>
</Routes>
Related