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.