Sergio Xalambrí

Working with Refresh Tokens in Remix

When using an external API, you may need to keep an access token to send a request as a user. And a refresh token to get a new access token once the access token expires.

In a SPA, you can create a wrapper for your Fetch. Suppose the request is rejected because of an expired access token. In that case, you can refresh it, update your access and refresh token, and try the request again with the new one. From that moment, all future requests will use the new access token.

But what about Remix? What happens if your access token is used inside a loader? You probably stored both tokens in the session, so you need to commit the session to update it, and if more than one loader is running, they may all find the expired token.

To solve this in loaders, we can do a simple trick, refresh the token and redirect to the same URL that will trigger the same loaders. It will update the tokens in the session, so the new request will come with the updated tokens after the redirect.

For actions, it's trickier because you can't redirect and generate a new POST. At the same time, you know that only one action function will be called per request, so you could refresh the token, get the tokens back and set a cookie with the new one.

Let's say how we could create an authenticate function to do that.

// our authenticate function receives the Request, the Session and a Headers
// we make the headers optional so loaders don't need to pass one
async function authenticate(
  request: Request,
  session: Session,
  headers = new Headers()
) {
  try {
    // get the auth data from the session
    let accessToken = session.get("accessToken");

    // if not found, redirect to login, this means the user is not even logged-in
    if (!accessToken) throw redirect("/login");

    // if expired throw an error (we can extends Error to create this)
    if (new Date(session.get("expirationDate")) < new Date()) {
      throw new AuthorizationError("Expired");
    }

    // if not expired, return the access token
    return accessToken;
  } catch (error) {
    // here, check if the error is an AuthorizationError (the one we throw above)
    if (error instanceof AuthorizationError) {
      // refresh the token somehow, this depends on the API you are using
      let { accessToken, refreshToken, expirationDate } = await refreshToken(
        session.get("refreshToken")
      );

      // update the session with the new values
      session.set("accessToken", accessToken);
      session.set("refreshToken", refreshToken);
      session.set("expirationDate", expirationDate);

      // commit the session and append the Set-Cookie header
      headers.append("Set-Cookie", await commitSession(session));

      // redirect to the same URL if the request was a GET (loader)
      if (request.method === "GET") throw redirect(request.url, { headers });

      // return the access token so you can use it in your action
      return accessToken;
    }

    // throw again any unexpected error that could've happened
    throw error;
  }
}

Now, we can define a loader function like this:

export let loader: LoaderFunction = async ({ request }) => {
  // read the session
  let session = await getSession(request);
  // authenticate the request and get the accessToken back
  let accessToken = await authenticate(request, session);
  // do something with the token
  let data = await getSomeData(accessToken);
  // and return the response
  return json(data);
};

And our action functions will be similar:

export let action: ActionFunction = async ({ request }) => {
  // also read the session
  let session = await getSession(request);
  // but create a headers object
  let headers = new Headers();
  // authenticate the request and get the accessToken back, this will be the
  // already saved token or the refreshed one, in that case the headers above
  // will have the Set-Cookie header appended
  let accessToken = await authenticate(request, session, headers);
  // do something with the token
  let data = await getSomeData(accessToken);
  // and return the response passing the headers so we update the cookie
  return json(data, { headers });
};

And that's all. Our loader/action functions will be able to refresh the token and use the new one. In the loader case, a redirect will happen hidden entirely from our code. We don't need to think about it at all.

Note this may change once Remix supports pre/post request hooks, so we could do the auth check in a pre-request hook and do it once.

Another option if you use Express is to move this to the Express request handler in your server code. That will run before Remix, which can happen before all your loaders and actions run in a single request.