Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Authorizer types and add initial docs #191

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
151 changes: 151 additions & 0 deletions docs/authorizer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Authorizer

The Authorizer is Remix Auth's built in way to perform authorization on a per route basis. It uses rules that you provide to protect specific routes.

## Rules

A rule is a function that recieves the loader arguments (request, params, and optionally context), a `User` from the `Authenticator`, and optionally additional data.

A rule should return a promise resolving to a boolean value indicating whether or not the user is authorized. Throwing an error from within your rules will be an uncaught exception unless you catch it yourself.

You can create multiple rules for different scenarios and rules can either be global or related only to specific routes.

Both global and route specific rules are optional.

### Global Rules

Global rules are applied to the `Authenticator` instance when you create it (see [usage](#usage)). These rules are checked on every call to `authorize`.

### Route Specific Rules

Route specific rules are selectively applied each time you call `authorize`.

### Options for handling Unauthorized Users

The call to `authorize` accepts either the loader args or action args as its first argument and an options object as its second argument. The options are as follows:

```ts
raise: "error" | "redirect" | "response",
failureRedirect?: string,
rules: RuleFunction<User, Data>[]
```

The `raise` option dictates the behavior when a user is unauthorized. "error" will throw an error, "response" will throw a json response with a message and a 401 status code, and "redirect" will redirect to the `failureRedirect` provided (if you select "redirect" you must provide a failureRedirect value).

The default value of `raise` is "response".

#### Error & Response Messages

If the user is not authenticated at all then the error message is "Not authenticated". If a specific rule indicates the user is unauthorized (by returning false) then the error message will be "Forbidden by policy {ruleName}" (with ruleName being the name of the rule function, or "Forbidden" if an arrow function is used).

## Setup

To use it you need to import it first, you may want to create it alongside the `Authenticator` instance or in a separate file that imports the `Authenticator` instance.

```ts
import { Authenticator } from "remix-auth";
import { sessionStorage } from "~/session.server";

type User = { id: string; name: string; email: string; onboarding: boolean };

export let authenticator = new Authenticator<User>(sessionStorage);

export let authorizer = new Authorizer(authenticator, [
// This is a global rule, applied to every call to `authenticate`
// Global rules are optional
async function isOnboarded({ user }) {
return user.onboarding;
},
]);
```

You do not have to provide any global rules and the only check that the authorizer will perform is a call to `authenticator.isAuthenticated`.

The `User` type parameter provided to the Authenticator constructor defines the type of value of the `user` argument provided to each of your rule functions.

> The Authenticator constructor takes two type parameters Authenticator<User, Data>. You do not need to provide either, however if you provide a type for User you should also provide a type for Data otherwise it will be considered `unknown`.

## Usage

In any of your route files you can import the authorizer instance to use in a loader or action. If the user is authorized to access the route or perform the action then the call to `authorize` will return the user.

With only global rules (or no rules).

```ts
import { type LoaderArgs, json } from "@remix-run/node";
import { authorizer } from "~/auth.server"; // import our authorizer

export let loader: LoaderFunction = async (args: LoaderArgs) => {
// authorize calls `authenticator.isAuthenticated` under the hood
let user = await authorizer.authorize(args);
// At this point we know the user is authorized based on the global rules
return json({});
};
```

With a route specific rule (and all global rules if they exist)

```ts
export let loader: LoaderFunction = async (args: LoaderArgs) => {
//
let user = await authorizer.authorize(args, {
rules: [
async function isNotAdmin({ user }) {
return user.role !== "admin";
},
],
});
// At this point we know the user is authorized based on the global rules and the route specific rule applied above
return json({});
};
```

## Advanced Usage

### Providing additional data to rules

Rules can accept additional data provided at the time of calling `authorize`. A `data` property is provided along with the `user` property to each of your rule functions.

```ts
// rule function
async function isAdminWithNumber({
user,
data,
}: {
user: User;
data?: number;
}) {
return user.role === "admin";
}

export let loader: LoaderFunction = async ({ request, params }: LoaderArgs) => {
let user = await authorizer.authorize(
{ request, params, data: 10 },
{
rules: [isAdminWithNumber],
}
);
return json({});
};
```

The type of data is inferred either by the global rules you provide or if none are provided, by the route specific rules applied when calling `authorize`. The type is always unioned with undefined.

Each rule is provided with the same data so the following rule function signatures are incompatible:

```ts
async function isAdminWithNumber({ data }: { data?: number }) {
return user.role === "admin";
}
async function isAdminWithString({ data }: { data?: string }) {
return user.role === "admin";
}
```

Instead they would need to take a union of string | number:

```ts
async function isAdmin({ data }: { data?: number | string }) {
return user.role === "admin";
}
```
92 changes: 44 additions & 48 deletions src/authorizer.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { LoaderFunction, redirect, json } from "@remix-run/server-runtime";
import type { DataFunctionArgs } from "@remix-run/server-runtime";
import { json, redirect } from "@remix-run/server-runtime";
import { Authenticator } from "./authenticator";

type LoaderArgs = Parameters<LoaderFunction>[0];

type AuthorizeArgs<User, Data> = Omit<RuleContext<User, Data>, "user">;
/**
* Extra data passed to the Authorizer from the loader or action
*/
type AuthorizeArgs<Data> = { data?: Data } & Omit<
DataFunctionArgs,
"context"
> & {
context?: DataFunctionArgs["context"];
};
Comment on lines +11 to +13
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context shouldn't be option, I know you may not use it, and it's the most common thing, but the type is not optional in LoaderArgs so it shouldn't be here, or at least not the types the rules receives, we could accept context as optional but pass it to the rules with a default empty object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I've updated it to be optional in the call to authorize but the rule functions will always receive a context (an empty object if not provided).


export interface RuleContext<User, Data = unknown> extends LoaderArgs {
export type RuleContext<User, Data = null> = {
/**
* The authenticated user returned by the Authenticator
*/
user: User;
/**
* Extra data passed to the Authorizer from the loader or action
*/
data?: Data;
}
} & AuthorizeArgs<Data> & { context: DataFunctionArgs["context"] };

/**
* A Rule is a function that receives the same arguments of a Loader or Action
Expand All @@ -24,68 +27,61 @@ export interface RuleContext<User, Data = unknown> extends LoaderArgs {
* Inside a Rule function you can do any validation to verify a user to continue
* and return a promise resolving to a boolean value.
*/
export interface RuleFunction<User, Data = unknown> {
export interface RuleFunction<User, Data = null> {
(context: RuleContext<User, Data>): Promise<boolean>;
}

type AuthorizeOptionsError = {
failureRedirect?: never;
raise: "error";
};
type AuthorizeOptionsResponse = {
failureRedirect?: never;
raise: "response";
};
type AuthorizeOptionsRedirect = {
failureRedirect: string;
raise: "redirect";
};
type AuthorizeOptionsEmpty = {
failureRedirect?: never;
raise?: never;
};
type AuthorizeOptions<U, D> = (
| AuthorizeOptionsError
| AuthorizeOptionsRedirect
| AuthorizeOptionsResponse
| AuthorizeOptionsEmpty
) & { rules?: RuleFunction<U, D>[] };

export class Authorizer<User = unknown, Data = unknown> {
constructor(
private authenticator: Authenticator<User>,
private rules: RuleFunction<User, Data>[] = []
) {}

async authorize(
args: AuthorizeArgs<User, Data>,
options?: {
failureRedirect?: never;
raise?: "error";
rules?: RuleFunction<User, Data>[];
}
): Promise<User>;
async authorize(
args: AuthorizeArgs<User, Data>,
options?: {
failureRedirect?: never;
raise?: "response";
rules?: RuleFunction<User, Data>[];
}
): Promise<User>;
async authorize(
args: AuthorizeArgs<User, Data>,
options: {
failureRedirect: string;
raise: "redirect";
rules?: RuleFunction<User, Data>[];
async authorize<D extends Data>(
args: AuthorizeArgs<D>,
{ failureRedirect, raise, rules = [] }: AuthorizeOptions<User, D> = {
raise: "response",
rules: [],
}
): Promise<User>;
async authorize(
args: AuthorizeArgs<User, Data>,
{
failureRedirect,
raise = "response",
rules = [],
}: {
failureRedirect?: string;
raise?: "error" | "response" | "redirect";
rules?: RuleFunction<User, Data>[];
} = {}
): Promise<User> {
if (!raise) raise = "response";
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking the default raise should be error, I think that's more expected and if the developer setup Sentry it should know if this throw and the dev is not catching it.

Also, Authorizer may receive the default raise and use it as default here

let user = await this.authenticator.isAuthenticated(args.request);

if (!user) {
if (raise === "response") {
throw json({ message: "Not authenticated." }, { status: 401 });
}
if (raise === "redirect") {
// @ts-expect-error failureRedirect is a string if raise is redirect
throw redirect(failureRedirect);
}
throw new Error("Not authenticated.");
}

for (let rule of [...this.rules, ...rules]) {
if (await rule({ user, ...args })) continue;
// @ts-expect-error failureRedirect is a string if raise is redirect
if (await rule({ user, ...args, context: args.context ?? {} })) continue;
if (raise === "redirect") throw redirect(failureRedirect);
if (raise === "response") {
if (!rule.name) throw json({ message: "Forbidden" }, { status: 403 });
Expand Down