-
-
Notifications
You must be signed in to change notification settings - Fork 109
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
myleslinder
wants to merge
8
commits into
sergiodxa:main
Choose a base branch
from
myleslinder:authz-types-docs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
da6fe38
Update authenticate options type
myleslinder 6bbf2db
isAuthenticated and logout can accept session or request
myleslinder 366258b
Update remix dev deps
myleslinder f635439
Update Authorizer types and add initial docs
myleslinder d0790a9
Update tests to use createCookieSessionStorage
myleslinder 260f3fa
revert upgrade remix deps
myleslinder 7c5df6c
Merge branch 'main' of github.com:sergiodxa/remix-auth into authz-typ…
myleslinder fe1b217
Context always passed to rule functions
myleslinder File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"]; | ||
}; | ||
|
||
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 | ||
|
@@ -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"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }); | ||
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).