React Learning Journey - Part 3 - Improved Authentication

React Learning Journey - Part 3 - Improved Authentication
Photo by Nubelson Fernandes / Unsplash

In the first part of this journey, I set up a basic todo app with a client, server, and database. In the second part, I added users and basic authentication/authorization. Now we're going to take that basic authentication to the next level. I also took the opportunity to clean and restructure the code.

In Part 2, we used only an access token, stored in Local Storage, for authorization and authentication. This is not secure because it's prone to XSS and CSRF attacks. How do we solve this? Well, not everyone agrees on how. I won't bore you with all the opinions and arguments, just Google it and you'll see what I mean. For me though, I believe the most secure mechanism is one outlined by Hasura. That is:

  • Store the access token in memory (with a small expiry time).
  • Use a refresh token stored in an httpOnly cookie (with a large expiry time) to get new access tokens.

An httpOnly cookie isn't accessible from JavaScript on the client and hence is more secure than Local Storage. Note that I did not implement keeping track of refresh tokens (for invalidating them from the server side for added security) because it would complicate matters more and it seems like it defeats the purpose of using JWTs (stateless). With all this said, this path of refresh tokens, httpOnly cookies, and in-memory access tokens is pretty complex. It's up to you to judge whether the return on investment is worth the complexity or if you'd rather try other suggestions. Either way, you need a better mechanism than long-lived access tokens in Local Storage.

Here are all the resources I used for this part:

I kept the code in a branch here. Open it in another tab or window to follow along.

The first step was to return an httpOnly cookie to the client from the server for the login and register routes:

// Placed these 2 lines in login and register routes
// Create refresh token jwt to place in cookie
const refreshToken = await utils.issueJWT(user.rows[0], "10m");
// Create httpOnly cookie to store the refresh token
res.cookie(
    "refresh_token",
    refreshToken,
    generateRefreshTokenCookieArgs()
);

// Separate function to set the cookie options
const generateRefreshTokenCookieArgs = () => {
  return {
    httpOnly: true,
    secure: true,
  };
};

To test it, I opened Chrome Dev Tools > Application > Cookies to see if the cookie gets set. It didn't.

After quite a bit of Googling, it was apparent browser security was not allowing us to set cookies from localhost. I tried different suggestions but the only one that worked was to start Chrome with web security disabled:

open -na Google\ Chrome --args --user-data-dir=/tmp/temporary-chrome-profile-dir --disable-web-security --disable-site-isolation-trials

This isn't ideal of course but I had already spent too much time investigating so I kept going. Here is the cookie in the browser:

For another test, I tried to see if the cookie will remain after restarting the browser. It didn't. Ugggh. Notice the "Expires / Max-Age" in the cookie above being set to "Session"? That's why. The solution was to add expiry to the cookie. Here are our updated cookie options:

const generateRefreshTokenCookieArgs = () => {
  return {
    httpOnly: true,
    secure: false,
    expires: new Date(Date.now() + 60 * 24 * 60000), // 24 hours
  };
};

And here is the cookie in the browser with a valid expiry that fixes our cookie persistence issue on browser restart:

Refresh Token

Now that we have an httpOnly cookie set in the browser, we need a way to accept that refresh token and generate a new access token. That's where the new refresh_token endpoint comes into play. Here's a snippet of the important parts:

router.get("/refresh_token", async function (req, res) {
  try {
    // Get cookie
    const refreshToken = req.cookies["refresh_token"];
    if (!refreshToken) {
      res.status(401).json("No refresh token");
      return;
    }
    const validRefreshToken = await utils.validateJwt(refreshToken);
      
    ...
    
    // Generate a new access token and send it in the response
    const accessToken = await utils.issueJWT(existingUser.rows[0]);
    res.status(200).json({
      success: true,
      token: accessToken.token,
      user: existingUser.rows[0],
    });
  } catch (err) {
    console.log(err);
    res.status(400).json("Unauthorized");
  }
});

Now we can set a cookie, accept that cookie, and generate a new access token if the cookie and its token value are valid. Time for the client updates.

In-Memory Token

Changing the code that dealt with the user's logged-in state was quite an undertaking. But I learned so much that I was doing wrong.

I was relying too heavily on Local Storage across the code, making the logic scattered and difficult to debug. I also stored both the token and a user object in Local Storage, duplicating information.

Following the example of the resources mentioned earlier, I created an inMemToken.js file with all the logic for managing the in-memory access token.

const inMemoryJWTManager = () => {
  let inMemoryJWT = null;

  // Tries to refresh token, and if the cookie is valid, will return a new access token
  const refreshToken = async () => {
    try {
      const response = await axios.get(baseUrl, { withCredentials: true });
      if (response.status !== 200) {
        deleteToken();

        return { success: false };
      } else {
        setToken(response.data.token);
        return { success: true, user: response.data.user };
      }
    } catch (err) {
      deleteToken();
      console.log(err);
    }
  };

  const getToken = () => inMemoryJWT;

  const setToken = async (token) => {
    inMemoryJWT = token;
  };

  const deleteToken = async () => {
    inMemoryJWT = null;
    window.localStorage.setItem(storageKey, Date.now());
  };

};

With the access token in memory, we now need to update our login and register flows to store it. We also need to set the User Context (refer to Part 2 for details).

const registerUser = async (firstname, lastname, email, password) => {
    try {
      const response = await http.post(`${baseUrl}/register`, {
        firstname: firstname,
        lastname: lastname,
        email: email,
        password: password,
      });
      if (response.status == 201) {
        // Set in mem access token and user context
        await inMemoryJWT.setToken(response.data.token);
        // Set user context
        setUser(response.data.user);
        history.push("/");
      }
    } catch (err) {
      setError(err.response.data);
    }
  };

const loginUser = async (email, password) => {
    try {
      const response = await http.post(`${baseUrl}/login`, {
        email: email,
        password: password,
      });
      if (response.status == 200) {
        // Set in mem access token and user context
        await inMemoryJWT.setToken(response.data.token);
        // Set user context
        setUser(response.data.user);
        history.push("/");
      }
    } catch (err) {
      setError(err);
    }
};

Logged-In/Logged-Out State

We have an in-memory access token that can be used in requests to our server. We can also refresh this token with our httpOnly cookie. This covers the following flows:

  • Login
  • Register
  • The first load of the home page (ListTodos) after a successful register/login

But what about all these other flows?

  • Refresh page after login/register - Should remain logged in and see your todo list
  • Access token valid, refresh token valid > Do something that makes an API call - Should succeed
  • Access token expires, refresh token valid > Do something that makes an API call – Should succeed
  • Refresh token expires > Refresh page — Should be logged out
  • Refresh token expires > Do something that makes an API call — Should be logged out
  • Log out > Refresh page — Should remain logged out

To handle any page refresh scenario, we need to update our App.js to try and refresh the access token (in-memory tokens are null after a page refresh). If it succeeds, we redirect to the home page. Otherwise, we get logged out. I created a useFindUser hook that tries to refresh the access token, and if it succeeds resets the in-memory token and user. I called this from App.js.

// useFindUser.js
export default function useFindUser() {
  const [user, setUser] = useState(null);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    async function findUser() {
      try {
        const response = await inMemoryJWT.refreshToken();
        if (response) {
          setUser(response.user);
          setLoading(false);
        } else {
          setLoading(false);
        }
      } catch (err) {
        setLoading(false);
      }
    }

    findUser();
  }, []);

  ...
}
// App.js

function App() {
    const { user, setUser, isLoading } = useFindUser();
	
    ...
    
}

To make API calls after the access token expires, I needed to add an Axios response interceptor. This will check if we get a 401, will try to refresh the token, and if it succeeds will replay the API call. If it fails, we'll redirect to the login page.

http.interceptors.response.use(
  function (response) {
    return response;
  },
  async function (error) {
    const originalRequest = error.config;
    if (
      error.response.status === 500 ||
      error.response.status === 403 ||
      error.response.status === 401
    ) {
      try {
        if (!originalRequest._retry) {
          // Try to get a new access token with the existing refresh token
          originalRequest._retry = true;
          const refreshedToken = await inMemoryJwt.refreshToken();
          if (refreshedToken.success) {
            return http(originalRequest);
          } else {
            originalRequest._retry = false;
            window.location.href = "/login";
            return Promise.reject(error);
          }
        }
      } catch (err) {
        originalRequest._retry = false;
        window.location.href = "/login";
        return Promise.reject(error);
      }
    }
    return Promise.reject(error);
  }
);

Finally, what about logout? I noticed that after logging out if I refresh the page, I get logged in again. This is because we still had a cookie with a valid refresh token that was being checked and sent on refresh. So, I implemented a logout route on the server. This /logout route will remove the cookie from the client.

router.get("/logout", async function (req, res) {
  try {
    res.clearCookie("refresh_token");
    // Send response with access token in the body
    res.status(200).json({
      success: "success",
    });
  } catch (err) {
    res.status(400).json("Something went wrong");
    console.log(err);
  }
});

Now we handle all scenarios. The user remains logged in for 24 hours as long as they don't explicitly log out. The access token gets refreshed every 10 minutes. We store the User Context and access token across the app, and manage private/public routes accurately.

I just added 1 more page and route to allow the user to update their first name, last name, and password.

At this point, I've officially achieved my goal for Part 3. This one was tough, but perseverance and discipline won in the end.

Next Steps

When will this end??! Trick question; it never does. There is always more to learn and do. For me though, I would like to complete the following before I move on to other endeavours:

  • Handle unhappy paths/error scenarios on the client and server.
  • Slight changes to forms to accept the "Enter" key as a submit.
  • Add unit tests for the client and server.