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.