Skip to content

Commit

Permalink
Update documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
sergiodxa-silverback committed Sep 20, 2024
1 parent 74632c9 commit 5b49009
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 343 deletions.
301 changes: 159 additions & 142 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@
- Full **Server-Side** Authentication
- Complete **TypeScript** Support
- **Strategy**-based Authentication
- Easily handle **success and failure**
- Implement **custom** strategies
- Supports persistent **sessions**
- Integrates with Remix's **cookies**

## Overview

Remix Auth is a complete open-source authentication solution for Remix.run applications.
Remix Auth is a complete open-source authentication solution for Remix applications.

Heavily inspired by [Passport.js](https://passportjs.org), but completely rewrote it from scratch to work on top of the [Web Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). Remix Auth can be dropped in to any Remix-based application with minimal setup.

Expand All @@ -33,43 +32,30 @@ Also, install one of the strategies. A list of strategies is available in the [C

## Usage

Remix Auth needs a session storage object to store the user session. It can be any object that implements the [SessionStorage interface from Remix](https://remix.run/docs/en/main/utils/sessions#createsessionstorage).

In this example I'm using the [createCookieSessionStorage](https://remix.run/docs/en/main/utils/sessions#createcookiesessionstorage) function.
Remix Auth needs a cookie object that strategies can use to store intermediate state. It can be any object that implements the [Cookie interface from Remix](https://remix.run/docs/en/main/utils/cookies), the simplest way is to use the [createCookie](https://remix.run/docs/en/main/utils/cookies#createcookie) function.

```ts
// app/services/session.server.ts
import { createCookieSessionStorage } from "@remix-run/node";

// export the whole sessionStorage object
export let sessionStorage = createCookieSessionStorage({
cookie: {
name: "_session", // use any name you want here
sameSite: "lax", // this helps with CSRF
path: "/", // remember to add this so the cookie will work in all routes
httpOnly: true, // for security reasons, make this cookie http only
secrets: ["s3cr3t"], // replace this with an actual secret
secure: process.env.NODE_ENV === "production", // enable this in prod only
},
import { createCookie } from "@remix-run/node";

// I recommend you to create this along the Authenticator object
const cookie = createCookie("auth", {
sameSite: "lax", // this helps with CSRF
path: "/", // remember to add this so the cookie will work in all routes
httpOnly: true, // for security reasons, make this cookie http only
secrets: ["s3cr3t"], // replace this with an actual secret
secure: process.env.NODE_ENV === "production", // enable this in prod only
});

// you can also export the methods individually for your own usage
export let { getSession, commitSession, destroySession } = sessionStorage;
```

Now, create a file for the Remix Auth configuration. Here import the `Authenticator` class and your `sessionStorage` object.
Now add the Remix Auth configuration. Here import the `Authenticator` class and your `cookie` object.

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

// Create an instance of the authenticator, pass a generic with what
// strategies will return and will store in the session
export let authenticator = new Authenticator<User>(sessionStorage);
// strategies will return
export let authenticator = new Authenticator<User>(cookie);
```

The `User` type is whatever you will store in the session storage to identify the authenticated user. It can be the complete user data or a string with a token. It is completely configurable.
The `User` type is whatever your strategies will give you after identifying the authenticated user. It can be the complete user data, or a string with a token. It is completely up to you.

After that, register the strategies. In this example, we will use the [FormStrategy](https://github.com/sergiodxa/remix-auth-form) to check the documentation of the strategy you want to use to see any configuration you may need.

Expand All @@ -93,12 +79,11 @@ authenticator.use(
);
```

Now that at least one strategy is registered, it is time to set up the routes.
Once we have at least one strategy registered, it is time to set up the routes.

First, create a `/login` page. Here we will render a form to get the email and password of the user and use Remix Auth to authenticate the user.

```tsx
// app/routes/login.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { authenticator } from "~/services/auth.server";
Expand All @@ -124,169 +109,201 @@ export default function Screen() {
// `authenticator.authenticate method`
export async function action({ request }: ActionFunctionArgs) {
// we call the method with the name of the strategy we want to use and the
// request object, optionally we pass an object with the URLs we want the user
// to be redirected to after a success or a failure
return await authenticator.authenticate("user-pass", request, {
successRedirect: "/dashboard",
failureRedirect: "/login",
});
};

// Finally, we can export a loader function where we check if the user is
// authenticated with `authenticator.isAuthenticated` and redirect to the
// dashboard if it is or return null if it's not
export async function loader({ request }: LoaderFunctionArgs) {
// If the user is already authenticated redirect to /dashboard directly
return await authenticator.isAuthenticated(request, {
successRedirect: "/dashboard",
});
};
```
// request object
let user = await authenticator.authenticate("user-pass", request);

With this, we have our login page. If we need to get the user data in another route of the application, we can use the `authenticator.isAuthenticated` method passing the request this way:
let session = await sessionStorage.getSession(request.headers.get("cookie"));
session.set("user", user);

```ts
// get the user data or redirect to /login if it failed
let user = await authenticator.isAuthenticated(request, {
failureRedirect: "/login",
});

// if the user is authenticated, redirect to /dashboard
await authenticator.isAuthenticated(request, {
successRedirect: "/dashboard",
});
throw redirect("/", {
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
});
}

// get the user or null, and do different things in your loader/action based on
// the result
let user = await authenticator.isAuthenticated(request);
if (user) {
// here the user is authenticated
} else {
// here the user is not authenticated
// Finally, we need to export a loader function to check if the user is already
// authenticated and redirect them to the dashboard
export async function loader({ request }: LoaderFunctionArgs) {
let session = await sessionStorage.getSession(request.headers.get("cookie"));
let user = session.get("user");
if (user) throw redirect("/dashboard");
return null;
}
```

Once the user is ready to leave the application, we can call the `logout` method inside an action.

```ts
export async function action({ request }: ActionFunctionArgs) {
await authenticator.logout(request, { redirectTo: "/login" });
};
```
The sessionStorage can be created using Remix's session storage hepler, is up to you to decide what session storage mechanism you want to use, or how you plan to keep the user data after authentication.

## Advanced Usage

### Custom redirect URL based on the user
### Redirect the user to different routes based on their data

Say we have `/dashboard` and `/onboarding` routes, and after the user authenticates, you need to check some value in their data to know if they are onboarded or not.

If we do not pass the `successRedirect` option to the `authenticator.authenticate` method, it will return the user data.

Note that we will need to store the user data in the session this way. To ensure we use the correct session key, the authenticator has a `sessionKey` property.

```ts
export async function action({ request }: ActionFunctionArgs) {
let user = await authenticator.authenticate("user-pass", request, {
failureRedirect: "/login",
});
let user = await authenticator.authenticate("user-pass", request);

// manually get the session
let session = await getSession(request.headers.get("cookie"));
// and store the user data
session.set(authenticator.sessionKey, user);
let session = await sessionStorage.getSession(request.headers.get("cookie"));
session.set("user", user);

// commit the session
let headers = new Headers({ "Set-Cookie": await commitSession(session) });

// and do your validation to know where to redirect the user
if (isOnboarded(user)) return redirect("/dashboard", { headers });
return redirect("/onboarding", { headers });
};
}
```

### Changing the session key
### Handle rrrors

If we want to change the session key used by Remix Auth to store the user data, we can customize it when creating the `Authenticator` instance.
In case of error, the authenticator and the strategy will simply throw an error. You can catch it and handle it as you wish.

```ts
export let authenticator = new Authenticator<AccessToken>(sessionStorage, {
sessionKey: "accessToken",
});
export async function action({ request }: ActionFunctionArgs) {
try {
return await authenticator.authenticate("user-pass", request);
} catch (error) {
if (error instanceof Error) {
// here the error related to the authentication process
}

throw error; // Re-throw other values or unhandled errors
}
}
```

With this, both `authenticate` and `isAuthenticated` will use that key to read or write the user data (in this case, the access token).
### Logout the user

If we need to read or write from the session manually, remember always to use the `authenticator.sessionKey` property. If we change the key in the `Authenticator` instance, we will not need to change it in the code.
Because you're in charge of keeping the user data after login, how you handle the logout will depend on that. You can simply remove the user data from the session, or you can create a new session, or you can even invalidate the session.

### Reading authentication errors
```ts
export async function action({ request }: ActionFunctionArgs) {
let session = await sessionStorage.getSession(request.headers.get("cookie"));
return redirect("/login", {
headers: { "Set-Cookie": await sessionStorage.destroySession(session) },
});
}
```

When the user cannot authenticate, the error will be set in the session using the `authenticator.sessionErrorKey` property.
### Protect a route

We can customize the name of the key when creating the `Authenticator` instance.
To protect a route, you can use the `loader` function to check if the user is authenticated. If not, you can redirect them to the login page.

```ts
export let authenticator = new Authenticator<User>(sessionStorage, {
sessionErrorKey: "my-error-key",
});
export async function loader({ request }: LoaderFunctionArgs) {
let session = await sessionStorage.getSession(request.headers.get("cookie"));
let user = session.get("user");
if (!user) throw redirect("/login");
return null;
}
```

Furthermore, we can read the error using that key after a failed authentication.
This is outside the scope of Remix Auth as where you store the user data depends on your application.

### Create a strategy

All strategies extends the `Strategy` abstract class exported by Remix Auth. You can create your own strategies by extending this class and implementing the `authenticate` method.

```ts
// in the loader of the login route
export async function loader({ request }: LoaderFunctionArgs) {
await authenticator.isAuthenticated(request, {
successRedirect: "/dashboard",
});
let session = await getSession(request.headers.get("cookie"));
let error = session.get(authenticator.sessionErrorKey);
return json({ error }, {
headers:{
'Set-Cookie': await commitSession(session) // You must commit the session whenever you read a flash
}
});
};
import { Strategy } from "remix-auth/strategy";

export namespace MyStrategy {
export interface VerifyOptions {
// The values you will pass to the verify callback
}
}

export class MyStrategy<User> extends Strategy<User, MyStrategy.VerifyOptions> {
name = "my-strategy";

async authenticate(
request: Request,
options: Strategy.AuthenticateOptions
): Promise<User> {
// Your logic here
}
}
```

Remember always to use the `authenticator.sessionErrorKey` property. If we change the key in the `Authenticator` instance, we will not need to change it in the code.
At some point of your `authenticate` method, you will need to call `this.verify(options)` to call the `verify` the application defined.

### Errors Handling
```ts
export class MyStrategy<User> extends Strategy<User, MyStrategy.VerifyOptions> {
name = "my-strategy";

async authenticate(
request: Request,
options: Strategy.AuthenticateOptions
): Promise<User> {
return await this.verify({
/* your options here */
});
}
}
```

By default, any error in the authentication process will throw a Response object. If `failureRedirect` is specified, this will always be a redirect response with the error message on the `sessionErrorKey`.
The options will depend on the second generic you pass to the `Strategy` class.

If a `failureRedirect` is not defined, Remix Auth will throw a 401 Unauthorized response with a JSON body containing the error message. This way, we can use the CatchBoundary component of the route to render any error message.
What you want to pass to the `verify` method is up to you and what your authentication flow needs.

If we want to get an error object inside the action instead of throwing a Response, we can configure the `throwOnError` option to `true`. We can do this when instantiating the `Authenticator` or calling `authenticate`.
#### Store intermediate state

If we do it in the `Authenticator,` it will be the default behavior for all the `authenticate` calls.
If your strategy needs to store intermediate state, you can use the `options.cookie` object. This object is the same object you passed to the `Authenticator` class.

```ts
export let authenticator = new Authenticator<User>(sessionStorage, {
throwOnError: true,
});
export class MyStrategy<User> extends Strategy<User, MyStrategy.VerifyOptions> {
name = "my-strategy";

async authenticate(
request: Request,
options: Strategy.AuthenticateOptions
): Promise<User> {
let setCookieHeader = await options.cookie.serialize("some value");
// More code
}
}
```

Alternatively, we can do it on the action itself.
The result of `options.cookie.serialize` will be a string you have to send to the browser using the `Set-Cookie` header, this can be done by throwing a redirect with the header.

```ts
import { AuthorizationError } from "remix-auth";

export async function action({ request }: ActionFunctionArgs) {
try {
return await authenticator.authenticate("user-pass", request, {
successRedirect: "/dashboard",
throwOnError: true,
export class MyStrategy<User> extends Strategy<User, MyStrategy.VerifyOptions> {
name = "my-strategy";

async authenticate(
request: Request,
options: Strategy.AuthenticateOptions
): Promise<User> {
let setCookieHeader = await options.cookie.serialize("some value");
throw redirect("/some-route", {
headers: { "Set-Cookie": setCookieHeader },
});
} catch (error) {
// Because redirects work by throwing a Response, you need to check if the
// caught error is a response and return it or throw it again
if (error instanceof Response) return error;
if (error instanceof AuthorizationError) {
// here the error is related to the authentication process
}
// here the error is a generic error that another reason may throw
}
};
}
```

If we define both `failureRedirect` and `throwOnError`, the redirect will happen instead of throwing an error.
Then you can read the value in the next request using the `options.cookie` object.

```ts
export class MyStrategy<User> extends Strategy<User, MyStrategy.VerifyOptions> {
name = "my-strategy";

async authenticate(
request: Request,
options: Strategy.AuthenticateOptions
): Promise<User> {
let value = await options.cookie.parse(request.headers.get("cookie"));
// More code
}
}
```

Note that the result of `options.cookie.parse` is typed as `any` by Remix, so you may want to use a library like [Zod](https://zod.dev) to validate the value before using it.

## License

See [LICENSE](./LICENSE).

## Author

- [Sergio Xalambrí](https://sergiodxa.com)
Loading

0 comments on commit 5b49009

Please sign in to comment.