remix-auth

repository·main·Indexed 25 days ago

https://github.com/sergiodxa/remix-auth

A simple authentication library for Remix and React Router applications. It uses a strategy-based pattern inspired by Passport.js, providing a central Authenticator to manage various authentication flows (such as form or OAuth) while leaving session management to the developer.

Tokens
5.7K
Snippets
17
Records
20
Agent score
81%

What's inside remix-auth

  1. Pass extra data to authenticate using AsyncLocalStorage

    main

    If your authenticate method needs access to data beyond the standard Request object, you can use the Node.js AsyncLocalStorage API.

    1. Create an AsyncLocalStorage instance.
    2. Wrap the authenticator.authenticate call within asyncLocalStorage.run(...) inside your action.
    3. Inside the strategy's authenticate method, retrieve the data using asyncLocalStorage.getStore().
    import { AsyncLocalStorage } from "async_hooks";
    
    export const asyncLocalStorage = new AsyncLocalStorage<{ someValue: string }>();
    
    // In your Strategy
    async authenticate(request: Request): Promise<User> {
    	let store = asyncLocalStorage.getStore();
    	if (!store) throw new Error("Failed to get AsyncLocalStorage store");
    	let { someValue } = store;
    	// ...
    }
    
    // In your Action
    export async function action({ request }: Route.ActionArgs) {
    	let user = await asyncLocalStorage.run({ someValue: "some value" }, () =>
    		authenticator.authenticate("user-pass", request),
    	);
        // ...
    }
  2. Store intermediate state in a Strategy

    main

    If your custom strategy requires storing temporary data (like an OAuth state or a nonce) between requests, you can use cookies.

    1. In the authenticate method, generate a cookie (e.g., using @mjackson/headers).
    2. Throw a redirect response that includes the Set-Cookie header containing your intermediate state.
    3. In the subsequent request, read the cookie value to continue the authentication flow.
    import { SetCookie } from "@mjackson/headers";
    import { Cookie } from "@mjackson/headers";
    
    export class MyStrategy<User> extends Strategy<User, MyStrategy.VerifyOptions> {
    	name = "my-strategy";
    
    	constructor(
    		protected cookieName: string,
    		verify: Strategy.VerifyFunction<User, MyStrategy.VerifyOptions>,
    	) {
    		super(verify);
    	}
    
    	async authenticate(request: Request): Promise<User> {
    		// To write state:
    		let header = new SetCookie({
    			name: this.cookieName,
    			value: "some value",
    		});
    		// Note: In a real flow, you'd likely check if state exists first
    		// throw redirect("/some-route", { headers: { "Set-Cookie": header.toString() } });
    
    		// To read state:
    		let cookie = new Cookie(request.headers.get("cookie") ?? "");
    		let value = cookie.get(this.cookieName);
    		return await this.verify({ /* ... */ });
    	}
    }
  3. How the Authenticator and Strategy pattern works

    main

    Remix Auth uses a strategy-based pattern inspired by Passport.js.

    1. Authenticator: The central orchestrator. You instantiate it with a generic type representing the user data (e.g., new Authenticator<User>()).
    2. Strategies: Individual packages (like remix-auth-form or remix-auth-github) that handle specific authentication flows.
    3. Registration: You register strategies with the authenticator using the .use(strategy, name) method. The name is a unique identifier used later to trigger that specific strategy during the authentication process.

    This separation allows you to support multiple authentication methods (e.g., username/password AND GitHub OAuth) within the same application.

  4. Protect a route

    main

    To protect a route, check for the user's presence in the session within a loader or action. If the user is not found, throw a redirect to the login page.

    For a cleaner implementation, you can create a helper function like authenticate that handles session retrieval, user validation, and optional returnTo logic (storing the intended destination in the session before redirecting to login).

    // Helper implementation
    export async function authenticate(request: Request, returnTo?: string) {
    	let session = await sessionStorage.getSession(request.headers.get("cookie"));
    	let user = session.get("user");
    	if (user) return user;
    	if (returnTo) session.set("returnTo", returnTo);
    	throw redirect("/login", {
    		headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
    	});
    }
    
    // Usage in a loader
    export async function loader({ request }: Route.LoaderArgs) {
    	let user = await authenticate(request, "/dashboard");
    	// use the user data here
    }
  5. Redirect users based on authentication data

    main

    After a successful authentication, you can inspect the returned user data to determine the next destination. Since authenticator.authenticate returns the user object, you can manually manage the session and perform conditional redirects in your action.

    1. Authenticate the user.
    2. Retrieve the session from the request.
    3. Set the user in the session.
    4. Commit the session via headers.
    5. Perform logic (e.g., isOnboarded(user)) to decide the redirect path.
    export async function action({ request }: Route.ActionArgs) {
    	let user = await authenticator.authenticate("user-pass", request);
    
    	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 });
    }
  6. Initialize the Authenticator and Session Storage

    main

    To set up authentication, you must define your user type, create a session storage mechanism (using React Router's createCookieSessionStorage or similar), and instantiate the Authenticator with your user type.

    // app/services/auth.server.ts
    import { Authenticator } from "remix-auth";
    import { createCookieSessionStorage } from "react-router";
    
    // Define your user type
    type User = {
    	id: string;
    	email: string;
    	name: string;
    };
    
    // Create a session storage
    export const sessionStorage = createCookieSessionStorage({
    	cookie: {
    		name: "__session",
    		httpOnly: true,
    		path: "/",
    		sameSite: "lax",
    		secrets: ["s3cr3t"], // replace this with an actual secret
    		secure: process.env.NODE_ENV === "production",
    	},
    });
    
    // Create an instance of the authenticator, pass a generic with what
    // strategies will return
    export const authenticator = new Authenticator<User>();
  7. Handle authentication errors

    main

    The authenticator and its strategies throw errors when authentication fails. You can wrap the authenticate call in a try/catch block to handle these errors.

    Important: Some strategies (like OAuth2/OIDC) throw a Response object to trigger a redirect to an identity provider. You must ensure you re-throw any Response objects so the framework can handle the redirect correctly.

    Recommended pattern:

    try {
        return await authenticator.authenticate("user-pass", request);
    } catch (error) {
        if (error instanceof Response) throw error;
        if (error instanceof Error) {
            // Handle authentication-specific error
        }
        throw error;
    }
    export async function action({ request }: Route.ActionArgs) {
    	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
    	}
    }
  8. Authenticate a user in a route action

    main

    To perform authentication in a Remix/React Router route, call authenticator.authenticate(strategyName, request) inside your action function. If successful, you typically retrieve the user and save them to your session storage.

    // app/routes/login.tsx
    import { Form, data, redirect } from "react-router";
    import { authenticator, sessionStorage } from "~/services/auth.server";
    import type { Route } from "./+types";
    
    export async function action({ request }: Route.ActionArgs) {
    	try {
    		// Call authenticate with the strategy name used during registration
    		let user = await authenticator.authenticate("user-pass", request);
    
    		let session = await sessionStorage.getSession(
    			request.headers.get("cookie"),
    		);
    
    		session.set("user", user);
    
    		return redirect("/", {
    			headers: {
    				"Set-Cookie": await sessionStorage.commitSession(session),
    			},
    		});
    	} catch (error) {
    		if (error instanceof Error) {
    			return data({ error: error.message });
    		}
    		throw error;
    	}
    }
    // app/routes/login.tsx
    import { Form, data, redirect } from "react-router";
    import { authenticator, sessionStorage } from "~/services/auth.server";
    import type { Route } from "./+types";
    
    export async function action({ request }: Route.ActionArgs) {
    	try {
    		// Call authenticate with the strategy name used during registration
    		let user = await authenticator.authenticate("user-pass", request);
    
    		let session = await sessionStorage.getSession(
    			request.headers.get("cookie"),
    		);
    
    		session.set("user", user);
    
    		return redirect("/", {
    				headers: {
    				"Set-Cookie": await sessionStorage.commitSession(session),
    			},
    		});
    	} catch (error) {
    		if (error instanceof Error) {
    			return data({ error: error.message });
    		}
    		throw error;
    	}
    }
  9. Check authentication status in a route loader

    main

    To protect a route, use a loader function to check if a user exists in the session. If the user is authenticated, you can redirect them to a dashboard or allow access; otherwise, you can redirect them to login.

    // app/routes/login.tsx
    import { data, redirect } from "react-router";
    import { sessionStorage } from "~/services/auth.server";
    import type { Route } from "./+types";
    
    export async function loader({ request }: Route.LoaderArgs) { 
    	let session = await sessionStorage.getSession(request.headers.get("cookie"));
    	let user = session.get("user");
    
    	// If user is already logged in, redirect to dashboard
    	if (user) return redirect("/dashboard");
    
    	// Otherwise, allow the login page to render
    	return data(null);
    }
  10. Logout the user

    main

    To log a user out, you must manage the session destruction. Since Remix Auth does not manage the session storage itself, you should use your session storage implementation to destroy the session and redirect the user (e.g., to /login).

    export async function action({ request }: Route.ActionArgs) {
    	let session = await sessionStorage.getSession(request.headers.get("cookie"));
    	return redirect("/login", {
    		headers: { "Set-Cookie": await sessionStorage.destroySession(session) },
    	});
    }
  11. Create a custom Strategy

    main

    To create a custom authentication method, extend the Strategy class from remix-auth/strategy. You must implement the authenticate method and call this.verify(options) within it to execute the verification logic provided by the application.

    Strategy<User, VerifyOptions> is generic over the User type and the options passed to the verify function.

    import { Strategy } from "remix-auth/strategy";
    
    export namespace MyStrategy {
    	export interface ConstructorOptions {
    		// The values you will pass to the constructor
    	}
    
    	export interface VerifyOptions {
    		// The values you will pass to the verify function
    	}
    }
    
    export class MyStrategy<User> extends Strategy<User, MyStrategy.VerifyOptions> {
    	name = "my-strategy";
    
    	constructor(
    		protected options: MyStrategy.ConstructorOptions,
    		verify: Strategy.VerifyFunction<User, MyStrategy.VerifyOptions>,
    	) {
    		super(verify);
    	}
    
    	async authenticate(request: Request): Promise<User> {
    		return await this.verify({
    			/* your verify options here */
    		});
    	}
    }