Route protection in Remix with Policies

A Policy is a design pattern used to define authorization rules that can be re-used easily across an application. One big benefit, aside of reutilization, is that it can be tested in isolation way easier.

When using Remix, you will most likely have some routes the user may only be able to access in certain conditions, like if it's authenticated or if it's an admin or has an active subscription or any other business rule we want to apply.

One easy way to do this initially is to add it to each route loader.

export let loader: LoaderFunction = async ({ request }) => {
  let session = await getSession(request.headers.get("Cookie"));
  let token = session.get("token");
  try {
    let user = await User.findBy("token", token); // fake ORM interface
    // do something with the user
  } catch (error) {
    // fake ORM error class
    if (error instanceof ORMNotFoundError) {
      session.set("origin", request.url);
      session.unset("token");
      let cookie = await commmitSession(session);
      return redirect("/login", { headers: { "Set-Cookie": cookie } });
    }
    // handle other possible errors
  }
};

Creating the Policy

The main pain point of doing it this way is that you will need to duplicate all of this code on each private route. This is when extracting it to a policy will help. So let's say we want to build our policies as functions, usually they are classes in OOP but here we can simplify it as a function.

export type Policy<PolicyResult> = (
  request: Request,
  callback: (input: PolicyResult) => Promise<LoaderReturn>
) => Promise<LoaderReturn>;

We could have that type for our policies, it receives a request and a callback and return the result of a loader. so let's create an authenticated policy.

let authenticated: Policy<{ user: User; token: string }> = async (
  request,
  callback
) => {
  let session = await getSession(request.headers.get("Cookie"));
  try {
    let token = session.get("token");
    let user = await User.findBy("token", token); // fake ORM interface
    // if the user is authenticated we call the callback passing the expected input
    return await callback({ user, token });
  } catch {
    // any error in the policy should consider the user is not authenticated
    // so in this case we don't need to handle specific errors, the callback
    // should handle its own errors and return a response
    session.set("origin", request.url);
    session.unset("token");
    let cookie = await commmitSession(session);
    return redirect("/login", { headers: { "Set-Cookie": cookie } });
  }
};

Now we can use it inside of our loader function.

export let loader: LoaderFunction = async ({ request }) => {
  return authenticated(request, ({ user, token }) => {
    try {
      // here we can use the user and token, together with the request to do
      // any query or API fetch we need to do to get the data of the route
    } catch (error) {
      // in case of an error we handle it here returning a response
    }
  });
};

With this policy function, every time we want to make a route only accessible for authenticated users we can call our function and have the result.

Testings

We can also easily test our policy.

describe("Policy - Authenticated", () => {
  test("Success", async () => {
    // create a fake session
    let session = await getSession("");
    // get the token of the first user
    session.set("token", (await User.first()).token);
    // create a fake request
    let request = new Request("/private", {
      // in this request we set the session as a cookie
      headers: { Cookie: await commitSession(session) },
    });
    // create a fake callback
    let callback = jest.fn();
    // run the policy
    await authenticated(request, callback);
    // if the callback was called then the policy passed
    expect(callback).toHaveBeenCalled();
  });

  test("Failure", async () => {
    // create a fake request without a cookie
    let request = new Request("/private");
    // create a fake callback
    let callback = jest.fn();
    // run the policy
    await authenticated(request, callback);
    // if the callback was called then the policy didn't worked
    expect(callback).not.toHaveBeenCalled();
  });
});

As you can see, we could easily unit test our policy to ensure our logic is working, we can make it slightly more complex by adding mocks of the DB queries or API fetches if we were doing that, but it's still easier to maintain and faster to run than an End-to-End test.