diff --git a/README.md b/README.md index f3eb5c9..14cfe64 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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(sessionStorage); +// strategies will return +export let authenticator = new Authenticator(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. @@ -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"; @@ -124,76 +109,41 @@ 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) }); @@ -201,92 +151,159 @@ export async function action({ request }: ActionFunctionArgs) { // 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(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(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 extends Strategy { + name = "my-strategy"; + + async authenticate( + request: Request, + options: Strategy.AuthenticateOptions + ): Promise { + // 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 extends Strategy { + name = "my-strategy"; + + async authenticate( + request: Request, + options: Strategy.AuthenticateOptions + ): Promise { + 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(sessionStorage, { - throwOnError: true, -}); +export class MyStrategy extends Strategy { + name = "my-strategy"; + + async authenticate( + request: Request, + options: Strategy.AuthenticateOptions + ): Promise { + 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 extends Strategy { + name = "my-strategy"; + + async authenticate( + request: Request, + options: Strategy.AuthenticateOptions + ): Promise { + 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 extends Strategy { + name = "my-strategy"; + + async authenticate( + request: Request, + options: Strategy.AuthenticateOptions + ): Promise { + 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) diff --git a/bun.lockb b/bun.lockb index 30c521b..0c33c42 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/authenticator.md b/docs/authenticator.md deleted file mode 100644 index ab23607..0000000 --- a/docs/authenticator.md +++ /dev/null @@ -1,113 +0,0 @@ -# Authenticator - -The Authenticator is the most important and the simplest part of Remix Auth. This is how you define what strategies to use and how you use them without the need to write any strategy specific code in your routes. - -## Usage - -To use it you need to import it first, you may want to create a new file in your `app` folder so you don't need to import it in every file you want to use it. - -Let's say we have a file at `app/auth.server.ts` with the following code: - -```ts -import { Authenticator } from "remix-auth"; -import { sessionStorage } from "~/session.server"; - -type User = { id: string; name: string; email: string }; - -export let authenticator = new Authenticator(sessionStorage); -``` - -Some important things to note here: - -We create a type `User` and pass it to the Authenticator constructor. This type is what the user object will look like, all strategies will need to follow this same interface in the object returned after authenticating the user. You can get this type from your API schema or ORM models. - -We are importing `sessionStorage` from `app/session.server.ts`, in this file you need to create a new session storage and export the whole object. - -You may also want to export only `getSession`, `commitSession` and `destroySession` to use them in your routes. - -```ts -import { createCookieSessionStorage } from "@remix-run/node"; - -export let sessionStorage = createCookieSessionStorage({ - cookie: { - name: "_session", - sameSite: "lax", - path: "/", - httpOnly: true, - secrets: ["s3cr3t"], - secure: process.env.NODE_ENV === "production", - }, -}); - -export let { getSession, commitSession, destroySession } = sessionStorage; -``` - -This session storage can be a cookie based, a file system based, a memory based or a custom one made with `createSessionStorage`. - -## Setup a strategy - -Once you have your authenticator instance defined, you will need to create a new strategy and tell your authenticator to use it. - -Let's update our `app/auth.server.ts` file to look like this: - -```ts -import { Authenticator, LocalStrategy } from "remix-auth"; -import { sessionStorage } from "~/session.server"; - -type User = { id: string; name: string; email: string }; - -export let authenticator = new Authenticator(sessionStorage); - -authenticator.use( - new LocalStrategy({ loginURL: "/login" }, async (username, password) => { - // the result of this call must follow the User type defined above - return getUserSomehow(username, password); - }) -); -``` - -## Setup your routes - -This will depend a lot on what strategy you are using since each one may have different requirements. Continuing our example of the `LocalStrategy`, we need to create a `/login` route and call our authenticator there. - -```tsx -import type { ActionArgs, LoaderArgs } from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; -import { Form } from "@remix-run/react"; -import { authenticator } from "~/auth.server"; // import our authenticator -import { getSession, commitSession } from "~/session.server"; - -export async function action({ request }: ActionArgs) { - // Authenticate the request and redirect to /dashboard if user is - // authenticated or to /login if it's not - authenticator.authenticate("local", request, { - successRedirect: "/dashboard", - failureRedirect: "/login", - }); -}; - -export async function loader({ request }: LoaderArgs) { - // Check if the user is already logged-in (this checks the key user in the session) - let user = await authenticator.isAuthenticated(request); - // If the user is logged-in, redirect to the dashboard directly - if (user) return redirect("/dashboard"); - // If we don't have a user return an empty JSON response (or something else) - return json({}); -}; - -export default function Login() { - // In the view, we will render our login form and do a POST against /login to - // trigger our action and authenticate the user, you may also want to change - // it to use the Form component from Remix in case you provide a custom loading - // state to your form - return ( -
- - - -
- ); -} -``` - -And that's it, you have now setup your first strategy and can use it in your routes. diff --git a/docs/avoid-redirects.md b/docs/avoid-redirects.md deleted file mode 100644 index 8fde717..0000000 --- a/docs/avoid-redirects.md +++ /dev/null @@ -1,33 +0,0 @@ -# Avoid Redirects - -When using this package the authentication happens completely server-side, this means that if you are using a redirect-based flow like OAuth2 the user will leave your app in order to complete the authorization flow on the 3rd party provider. - -1. User starts login flow -2. Remix Auth redirects the user to the provider -3. User authenticates itself on the provider and authorizes the app -4. Provider redirects the user back to the app -5. Remix Auth completes the authentication and redirects the user to `/` - -This is the normal OAuth2 flow most webapps will follow, but Remix let's you do it better since most login flows can use a loader to start the process which means you can render this: - -```tsx -Sign in with GitHub -``` - -And that will correctly start the authentication flow, another option is to use a form. - -```tsx -
- -
-``` - -Either a GET or POST, if you used an action, will work. But the main benefit of using a form over a simple link is that you can use the Remix's Form component to improve the UX. - -```tsx -
- -
-``` - -This way Remix will try to use Fetch to do the GET request. If it's the first time the user logins into your app it will be redirected to the provider as usual, otherwise Remix will follow all the redirects client-side so you can render a loading state using the `useTransition` hook and the user will never leave your app. diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index 7e29954..0000000 --- a/docs/testing.md +++ /dev/null @@ -1,52 +0,0 @@ -# Testing - -When you're working on a feature, you might want to test it. There are multiple ways of testing in Remix, and while most of the time a Cypress test will be the best way to go, sometimes you might want to test the route in isolation. - -If you are using Jest you can import a loader or action from any route file and run it inside your test. - -```ts -import { Request } from "remix"; -import { loader } from "~/routes/dashboard"; - -describe("Dashboard", () => { - test("Loader", () => { - let request = new Request("/dashboard"); - let response = await loader({ request, context: {}, params: {} }); - expect(response.status).toBe(200); - }); -}); -``` - -If your route is calling `Authenticator#isAuthenticated` you may want to test what happens if the user is logged-in or not. - -Since the Authenticator read from a session storage object you created and the session is read from the request cookies you can fake it. - -```ts -import { Request } from "remix"; -import { sessionStorage } from "~/session.server"; -import { authenticator } from "~/authenticator.server"; -import { loader } from "~/routes/dashboard"; - -describe("Dashboard", () => { - test("Loader - is signed in", () => { - let session = await sessionStorage.getSession(); // get a new Session object - session.set(authenticator.sessionKey, fakeUser); // set a fake user in the session - let request = new Request("/dashboard", { - // Add a cookie header to the request with the session committed - headers: { Cookie: await sessionStorage.commitSession(session) }, - }); - // This loader will now believe the user is logged in - let response = await loader({ request, context: {}, params: {} }); - expect(response.status).toBe(200); - }); - - test("Loader - is not signed in", () => { - let request = new Request("/dashboard"); - // This loader will now believe the user is not logged in - let response = await loader({ request, context: {}, params: {} }); - expect(response.status).toBe(200); - }); -}); -``` - -This way you can still use the Remix Auth and test the routes as you would normally. diff --git a/package.json b/package.json index 0cce005..1ed2571 100644 --- a/package.json +++ b/package.json @@ -38,25 +38,20 @@ "uuid": "^10.0.0" }, "peerDependencies": { - "@remix-run/react": "^1.0.0 || ^2.0.0", "@remix-run/server-runtime": "^1.0.0 || ^2.0.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.16.4", - "@babel/preset-react": "^7.13.13", "@biomejs/biome": "^1.8.3", - "@remix-run/node": "^2.0.1", - "@remix-run/react": "^2.0.1", - "@remix-run/serve": "^2.0.1", - "@remix-run/server-runtime": "^2.0.1", + "@remix-run/node": "^2.12.1", + "@remix-run/server-runtime": "^1.0.0 || ^2.0.0", "@total-typescript/tsconfig": "^1.0.4", "@types/bun": "^1.1.6", - "@types/react": "^18.2.20", "@types/uuid": "^10.0.0", "consola": "^3.2.3", - "react": "^18.2.0", "typedoc": "^0.26.5", "typedoc-plugin-mdn-links": "^3.2.6", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "zod": "^3.23.8" } } diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..0fd9403 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { createCookie } from "@remix-run/node"; +import { Authenticator } from "./index.js"; +import { Strategy } from "./strategy.js"; + +class MockStrategy extends Strategy> { + name = "mock"; + + async authenticate() { + let user = await this.verify({}); + if (user) return user; + throw new Error("Invalid credentials"); + } +} + +const cookie = createCookie("auth", { secrets: ["s3cr3t"] }); + +describe(Authenticator.name, () => { + beforeEach(() => mock.restore()); + + test("#constructor", () => { + let auth = new Authenticator(cookie); + expect(auth).toBeInstanceOf(Authenticator); + }); + + test("#use", () => { + let auth = new Authenticator(cookie); + + expect(auth.use(new MockStrategy(async () => ({ id: 1 })))).toBe(auth); + + expect( + auth.authenticate("mock", new Request("http://remix.auth/test")), + ).resolves.toEqual({ id: 1 }); + }); + + test("#unuse", () => { + let auth = new Authenticator(cookie).use( + new MockStrategy(async () => null), + ); + + expect(auth.unuse("mock")).toBe(auth); + + expect( + async () => + await auth.authenticate("mock", new Request("http://remix.auth/test")), + ).toThrow(new ReferenceError("Strategy mock not found.")); + }); + + test("#authenticate", async () => { + let auth = new Authenticator(cookie).use( + new MockStrategy(async () => ({ id: 1 })), + ); + + expect( + await auth.authenticate("mock", new Request("http://remix.auth/test"), { + context: {}, + }), + ).toEqual({ id: 1 }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 7ee8ad8..71daf5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,82 @@ -// biome-ignore lint/performance/noReExportAll: -// biome-ignore lint/performance/noBarrelFile: -export * from "./lib/authenticator.js"; +import type { Cookie } from "@remix-run/server-runtime"; +import type { Strategy } from "./strategy.js"; -// biome-ignore lint/performance/noReExportAll: -// biome-ignore lint/performance/noBarrelFile: -export * from "./lib/error.js"; +export class Authenticator { + /** + * A map of the configured strategies, the key is the name of the strategy + * @private + */ + private strategies = new Map>(); -// biome-ignore lint/performance/noReExportAll: -// biome-ignore lint/performance/noBarrelFile: -export * from "./lib/strategy.js"; + /** + * Create a new instance of the Authenticator. + * + * It receives a instance of a Cookie created using Remix's createCookie. + * + * It optionally receives an object with extra options. The supported options + * are: + * @example + * import { createCookie } from "@remix-run/node"; + * let cookie = createCookie("auth", { path: "/", maxAge: 3600 }); + * let auth = new Authenticator(cookie); + * @example + * import { createCookie } from "@remix-run/cloudflare"; + * let cookie = createCookie("auth", { path: "/", maxAge: 3600 }); + * let auth = new Authenticator(cookie); + * @example + * import { createCookie } from "@remix-run/deno"; + * let cookie = createCookie("auth", { path: "/", maxAge: 3600 }); + * let auth = new Authenticator(cookie); + */ + constructor(private cookie: Cookie) {} + + /** + * Call this method with the Strategy, the optional name allows you to setup + * the same strategy multiple times with different names. + * It returns the Authenticator instance for concatenation. + * @example + * auth.use(new SomeStrategy({}, (user) => Promise.resolve(user))); + * auth.use(new SomeStrategy({}, (user) => Promise.resolve(user)), "another"); + */ + use(strategy: Strategy, name?: string): Authenticator { + this.strategies.set(name ?? strategy.name, strategy); + return this; + } + + /** + * Call this method with the name of the strategy you want to remove. + * It returns the Authenticator instance for concatenation. + * @example + * auth.unuse("another").unuse("some"); + */ + unuse(name: string): Authenticator { + this.strategies.delete(name); + return this; + } + + /** + * Call this to authenticate a request using some strategy. You pass the name + * of the strategy you want to use and the request to authenticate. + * @example + * async function action({ request }: ActionFunctionArgs) { + * let user = await auth.authenticate("some", request); + * }; + * @example + * async function action({ request, context }: ActionFunctionArgs) { + * let user = await auth.authenticate("some", request, { context }); + * }; + */ + authenticate( + strategy: string, + request: Request, + options: Pick = {}, + ): Promise { + const obj = this.strategies.get(strategy); + if (!obj) throw new ReferenceError(`Strategy ${strategy} not found.`); + return obj.authenticate(new Request(request.url, request), { + ...options, + cookie: this.cookie, + name: strategy, + }); + } +} diff --git a/src/lib/authenticator.test.ts b/src/lib/authenticator.test.ts deleted file mode 100644 index 21d69a8..0000000 --- a/src/lib/authenticator.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; -import { createCookieSessionStorage } from "@remix-run/node"; -import { SessionStorage, redirect } from "@remix-run/server-runtime"; -import { - AuthenticateOptions, - Authenticator, - AuthorizationError, - Strategy, -} from "../index.js"; - -class MockStrategy extends Strategy> { - name = "mock"; - - async authenticate( - request: Request, - sessionStorage: SessionStorage, - options: AuthenticateOptions, - ) { - let user = await this.verify({}); - if (user) return await this.success(user, request, sessionStorage, options); - return await this.failure( - "Invalid credentials", - request, - sessionStorage, - options, - new Error("Invalid credentials"), - ); - } -} - -describe(Authenticator.name, () => { - let sessionStorage = createCookieSessionStorage({ - cookie: { secrets: ["s3cr3t"] }, - }); - - beforeEach(() => { - mock.restore(); - }); - - test("should be able to add a new strategy calling use", async () => { - let request = new Request("http://.../test"); - let response = new Response("It works!", { - // @ts-expect-error this should work - url: "", - }); - - let authenticator = new Authenticator(sessionStorage); - - expect(authenticator.use(new MockStrategy(async () => response))).toBe( - authenticator, - ); - expect(await authenticator.authenticate("mock", request)).toEqual(response); - }); - - test("should be able to remove a strategy calling unuse", async () => { - let response = new Response("It works!"); - - let authenticator = new Authenticator(sessionStorage); - authenticator.use(new MockStrategy(async () => response)); - - expect(authenticator.unuse("mock")).toBe(authenticator); - }); - - test("should throw if the strategy was not found", async () => { - let request = new Request("http://.../test"); - let authenticator = new Authenticator(sessionStorage); - - expect(() => authenticator.authenticate("unknown", request)).toThrow( - "Strategy unknown not found.", - ); - }); - - test("should store the strategy provided name in the session if no custom name provided", async () => { - let user = { id: "123" }; - let session = await sessionStorage.getSession(); - let request = new Request("http://.../test", { - headers: { Cookie: await sessionStorage.commitSession(session) }, - }); - - let authenticator = new Authenticator(sessionStorage, { - sessionStrategyKey: "strategy-name", - }); - authenticator.use(new MockStrategy(async () => user)); - - try { - await authenticator.authenticate("mock", request, { - successRedirect: "/", - }); - } catch (error) { - if (!(error instanceof Response)) throw error; - let cookie = error.headers.get("Set-Cookie"); - let responseSession = await sessionStorage.getSession(cookie); - let strategy = responseSession.get(authenticator.sessionStrategyKey); - expect(strategy).toBe("mock"); - } - }); - - test("should store the provided strategy name in the session", async () => { - let user = { id: "123" }; - let session = await sessionStorage.getSession(); - let request = new Request("http://.../test", { - headers: { Cookie: await sessionStorage.commitSession(session) }, - }); - - let authenticator = new Authenticator(sessionStorage, { - sessionStrategyKey: "strategy-name", - }); - authenticator.use(new MockStrategy(async () => user), "mock2"); - - try { - await authenticator.authenticate("mock2", request, { - successRedirect: "/", - }); - } catch (error) { - if (!(error instanceof Response)) throw error; - let cookie = error.headers.get("Set-Cookie"); - let responseSession = await sessionStorage.getSession(cookie); - let strategy = responseSession.get(authenticator.sessionStrategyKey); - expect(strategy).toBe("mock2"); - } - }); - - test("should redirect after logout", async () => { - let user = { id: "123" }; - let session = await sessionStorage.getSession(); - session.set("user", user); - session.set("strategy", "test"); - - let request = new Request("http://.../test", { - headers: { Cookie: await sessionStorage.commitSession(session) }, - }); - - expect( - new Authenticator(sessionStorage, { - sessionKey: "user", - }).logout(request, { redirectTo: "/login" }), - ).rejects.toEqual( - redirect("/login", { - headers: { "Set-Cookie": await sessionStorage.destroySession(session) }, - }), - ); - }); - - describe("isAuthenticated", () => { - test("should return the user if it's on the session", async () => { - let user = { id: "123" }; - let session = await sessionStorage.getSession(); - session.set("user", user); - - let request = new Request("http://.../test", { - headers: { Cookie: await sessionStorage.commitSession(session) }, - }); - - expect( - new Authenticator(sessionStorage, { - sessionKey: "user", - }).isAuthenticated(request), - ).resolves.toEqual(user); - }); - - test("should return null if user isn't on the session", () => { - let request = new Request("http://.../test"); - - expect( - new Authenticator(sessionStorage).isAuthenticated(request), - ).resolves.toEqual(null); - }); - - test("should throw a redirect if failureRedirect is defined", () => { - let request = new Request("http://.../test"); - let response = redirect("/login"); - - expect( - new Authenticator(sessionStorage).isAuthenticated(request, { - failureRedirect: "/login", - }), - ).rejects.toEqual(response); - }); - - test("should throw a redirect if successRedirect is defined", async () => { - let user = { id: "123" }; - let session = await sessionStorage.getSession(); - session.set("user", user); - - let request = new Request("http://.../test", { - headers: { Cookie: await sessionStorage.commitSession(session) }, - }); - - let response = redirect("/dashboard"); - - expect( - new Authenticator(sessionStorage).isAuthenticated(request, { - successRedirect: "/dashboard", - }), - ).rejects.toEqual(response); - }); - - test("should accept headers as an option", async () => { - let user = { id: "123" }; - let session = await sessionStorage.getSession(); - session.set("user", user); - - let request = new Request("http://.../test", { - headers: { Cookie: await sessionStorage.commitSession(session) }, - }); - - let response = redirect("/dashboard", { - headers: { "X-Custom-Header": "true" }, - }); - - expect( - new Authenticator(sessionStorage).isAuthenticated(request, { - successRedirect: "/dashboard", - headers: { "X-Custom-Header": "true" }, - }), - ).rejects.toEqual(response); - }); - }); - - describe("authenticate", () => { - test("should throw an error if throwOnError is enabled", async () => { - let request = new Request("http://.../test"); - let authenticator = new Authenticator(sessionStorage); - - authenticator.use(new MockStrategy(async () => null)); - - let error = await authenticator - .authenticate("mock", request, { throwOnError: true }) - .catch((error) => error); - - if (!(error instanceof AuthorizationError)) throw error; - - expect(error).toEqual(new AuthorizationError("Invalid credentials")); - expect((error as AuthorizationError).cause).toEqual( - new Error("Invalid credentials"), - ); - }); - }); -}); diff --git a/src/lib/authenticator.ts b/src/lib/authenticator.ts deleted file mode 100644 index f510397..0000000 --- a/src/lib/authenticator.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { - type Session, - type SessionStorage, - isSession, - redirect, -} from "@remix-run/server-runtime"; -import { type AuthenticateOptions, Strategy } from "./strategy.js"; - -export type AuthenticateCallback = (user: User) => Promise; - -/** - * Extra options for the authenticator. - */ -export interface AuthenticatorOptions { - sessionKey?: AuthenticateOptions["sessionKey"]; - sessionErrorKey?: AuthenticateOptions["sessionErrorKey"]; - sessionStrategyKey?: AuthenticateOptions["sessionStrategyKey"]; - throwOnError?: AuthenticateOptions["throwOnError"]; -} - -export class Authenticator { - /** - * A map of the configured strategies, the key is the name of the strategy - * @private - */ - private strategies = new Map>(); - - public readonly sessionKey: NonNullable; - public readonly sessionErrorKey: NonNullable< - AuthenticatorOptions["sessionErrorKey"] - >; - public readonly sessionStrategyKey: NonNullable< - AuthenticateOptions["sessionStrategyKey"] - >; - private readonly throwOnError: AuthenticatorOptions["throwOnError"]; - - /** - * Create a new instance of the Authenticator. - * - * It receives a instance of the SessionStorage. This session storage could - * be created using any method exported by Remix, this includes: - * - `createSessionStorage` - * - `createFileSystemSessionStorage` - * - `createCookieSessionStorage` - * - `createMemorySessionStorage` - * - * It optionally receives an object with extra options. The supported options - * are: - * - `sessionKey`: The key used to store and read the user in the session storage. - * @example - * import { sessionStorage } from "./session.server"; - * let authenticator = new Authenticator(sessionStorage); - * @example - * import { sessionStorage } from "./session.server"; - * let authenticator = new Authenticator(sessionStorage, { - * sessionKey: "token", - * }); - */ - constructor( - private sessionStorage: SessionStorage, - options: AuthenticatorOptions = {}, - ) { - this.sessionKey = options.sessionKey || "user"; - this.sessionErrorKey = options.sessionErrorKey || "auth:error"; - this.sessionStrategyKey = options.sessionStrategyKey || "strategy"; - this.throwOnError = options.throwOnError ?? false; - } - - /** - * Call this method with the Strategy, the optional name allows you to setup - * the same strategy multiple times with different names. - * It returns the Authenticator instance for concatenation. - * @example - * authenticator - * .use(new SomeStrategy({}, (user) => Promise.resolve(user))) - * .use(new SomeStrategy({}, (user) => Promise.resolve(user)), "another"); - */ - use(strategy: Strategy, name?: string): Authenticator { - this.strategies.set(name ?? strategy.name, strategy); - return this; - } - - /** - * Call this method with the name of the strategy you want to remove. - * It returns the Authenticator instance for concatenation. - * @example - * authenticator.unuse("another").unuse("some"); - */ - unuse(name: string): Authenticator { - this.strategies.delete(name); - return this; - } - - /** - * Call this to authenticate a request using some strategy. You pass the name - * of the strategy you want to use and the request to authenticate. - * @example - * async function action({ request }: ActionFunctionArgs) { - * let user = await authenticator.authenticate("some", request); - * }; - * @example - * async function action({ request }: ActionFunctionArgs) { - * return authenticator.authenticate("some", request, { - * successRedirect: "/private", - * failureRedirect: "/login", - * }); - * }; - */ - authenticate( - strategy: string, - request: Request, - options: Pick< - AuthenticateOptions, - "failureRedirect" | "throwOnError" | "context" - > & { - successRedirect: AuthenticateOptions["successRedirect"]; - }, - ): Promise; - authenticate( - strategy: string, - request: Request, - options: Pick< - AuthenticateOptions, - "successRedirect" | "throwOnError" | "context" - > & { - failureRedirect: AuthenticateOptions["failureRedirect"]; - }, - ): Promise; - authenticate( - strategy: string, - request: Request, - options?: Pick< - AuthenticateOptions, - "successRedirect" | "failureRedirect" | "throwOnError" | "context" - >, - ): Promise; - authenticate( - strategy: string, - request: Request, - options: Pick< - AuthenticateOptions, - "successRedirect" | "failureRedirect" | "throwOnError" | "context" - > = {}, - ): Promise { - const strategyObj = this.strategies.get(strategy); - if (!strategyObj) throw new Error(`Strategy ${strategy} not found.`); - return strategyObj.authenticate( - new Request(request.url, request), - this.sessionStorage, - { - throwOnError: this.throwOnError, - ...options, - name: strategy, - sessionKey: this.sessionKey, - sessionErrorKey: this.sessionErrorKey, - sessionStrategyKey: this.sessionStrategyKey, - }, - ); - } - - /** - * Call this to check if the user is authenticated. It will return a Promise - * with the user object or null, you can use this to check if the user is - * logged-in or not without triggering the whole authentication flow. - * @example - * async function loader({ request }: LoaderFunctionArgs) { - * // if the user is not authenticated, redirect to login - * let user = await authenticator.isAuthenticated(request, { - * failureRedirect: "/login", - * }); - * // do something with the user - * return json(privateData); - * } - * @example - * async function loader({ request }: LoaderFunctionArgs) { - * // if the user is authenticated, redirect to /dashboard - * await authenticator.isAuthenticated(request, { - * successRedirect: "/dashboard" - * }); - * return json(publicData); - * } - * @example - * async function loader({ request }: LoaderFunctionArgs) { - * // manually handle what happens if the user is or not authenticated - * let user = await authenticator.isAuthenticated(request); - * if (!user) return json(publicData); - * return sessionLoader(request); - * } - */ - async isAuthenticated( - request: Request | Session, - options?: { - successRedirect?: never; - failureRedirect?: never; - headers?: never; - }, - ): Promise; - async isAuthenticated( - request: Request | Session, - options: { - successRedirect: string; - failureRedirect?: never; - headers?: HeadersInit; - }, - ): Promise; - async isAuthenticated( - request: Request | Session, - options: { - successRedirect?: never; - failureRedirect: string; - headers?: HeadersInit; - }, - ): Promise; - async isAuthenticated( - request: Request | Session, - options: { - successRedirect: string; - failureRedirect: string; - headers?: HeadersInit; - }, - ): Promise; - async isAuthenticated( - request: Request | Session, - options: - | { successRedirect?: never; failureRedirect?: never; headers?: never } - | { - successRedirect: string; - failureRedirect?: never; - headers?: HeadersInit; - } - | { - successRedirect?: never; - failureRedirect: string; - headers?: HeadersInit; - } - | { - successRedirect: string; - failureRedirect: string; - headers?: HeadersInit; - } = {}, - ): Promise { - let session = isSession(request) - ? request - : await this.sessionStorage.getSession(request.headers.get("Cookie")); - - let user: User | null = session.get(this.sessionKey) ?? null; - - if (user) { - if (options.successRedirect) { - throw redirect(options.successRedirect, { headers: options.headers }); - } - return user; - } - - if (options.failureRedirect) { - throw redirect(options.failureRedirect, { headers: options.headers }); - } - return null; - } - - /** - * Destroy the user session throw a redirect to another URL. - * @example - * async function action({ request }: ActionFunctionArgs) { - * await authenticator.logout(request, { redirectTo: "/login" }); - * } - */ - async logout( - request: Request | Session, - options: { redirectTo: string; headers?: HeadersInit }, - ): Promise { - let session = isSession(request) - ? request - : await this.sessionStorage.getSession(request.headers.get("Cookie")); - - let headers = new Headers(options.headers); - headers.append( - "Set-Cookie", - await this.sessionStorage.destroySession(session), - ); - - throw redirect(options.redirectTo, { headers }); - } -} diff --git a/src/lib/error.ts b/src/lib/error.ts deleted file mode 100644 index 9b39c99..0000000 --- a/src/lib/error.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class AuthorizationError extends Error { - constructor( - message?: string, - public override cause?: Error, - ) { - super(message); - } -} diff --git a/src/lib/strategy.ts b/src/lib/strategy.ts deleted file mode 100644 index 062ac99..0000000 --- a/src/lib/strategy.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - type AppLoadContext, - type SessionStorage, - json, - redirect, -} from "@remix-run/server-runtime"; -import { AuthorizationError } from "./error.js"; - -/** - * Extra information from the Authenticator to the strategy - */ -export interface AuthenticateOptions { - /** - * The key of the session used to set the user data. - */ - sessionKey: string; - /** - * In what key of the session the errors will be set. - * @default "auth:error" - */ - sessionErrorKey: string; - /** - * The key of the session used to set the strategy used to authenticate the - * user. - */ - sessionStrategyKey: string; - /** - * The name used to register the strategy - */ - name: string; - /** - * To what URL redirect in case of a successful authentication. - * If not defined, it will return the user data. - */ - successRedirect?: string; - /** - * To what URL redirect in case of a failed authentication. - * If not defined, it will return null - */ - failureRedirect?: string; - /** - * Set if the strategy should throw an error instead of a Response in case of - * a failed authentication. - * @default false - */ - throwOnError?: boolean; - /** - * The context object received by the loader or action. - * This can be used by the strategy if needed. - */ - context?: AppLoadContext; -} - -/** - * A function which will be called to find the user using the information the - * strategy got from the request. - * - * @param params The params from the strategy. - * @returns The user data. - * @throws {AuthorizationError} If the user was not found. Any other error will be ignored and thrown again by the strategy. - */ -export type StrategyVerifyCallback = ( - params: VerifyParams, -) => Promise; - -/** - * The Strategy class is the base class every strategy should extend. - * - * This class receives two generics, a User and a VerifyParams. - * - User is the type of the user data. - * - VerifyParams is the type of the params the verify callback will receive from the strategy. - * - * This class also defines as protected two methods, `success` and `failure`. - * - `success` is called when the authentication was successful. - * - `failure` is called when the authentication failed. - * These methods helps you return or throw the correct value, response or error - * from within the strategy `authenticate` method. - */ -export abstract class Strategy { - /** - * The name of the strategy. - * This will be used by the Authenticator to identify and retrieve the - * strategy. - */ - public abstract name: string; - - public constructor( - protected verify: StrategyVerifyCallback, - ) {} - - /** - * The authentication flow of the strategy. - * - * This method receives the Request to authenticator and the session storage - * to use from the Authenticator. It may receive a custom callback. - * - * At the end of the flow, it will return a Response to be used by the - * application. - */ - public abstract authenticate( - request: Request, - sessionStorage: SessionStorage, - options: AuthenticateOptions, - ): Promise; - - /** - * Throw an AuthorizationError or a redirect to the failureRedirect. - * @param message The error message to set in the session. - * @param request The request to get the cookie out of. - * @param sessionStorage The session storage to retrieve the session from. - * @param options The strategy options. - * @throws {AuthorizationError} If the throwOnError is set to true. - * @throws {Response} If the failureRedirect is set or throwOnError is false. - * @returns {Promise} - */ - protected async failure( - message: string, - request: Request, - sessionStorage: SessionStorage, - options: AuthenticateOptions, - cause?: Error, - ): Promise { - // if a failureRedirect is not set, we throw a 401 Response or an error - if (!options.failureRedirect) { - if (options.throwOnError) throw new AuthorizationError(message, cause); - throw json<{ message: string }>({ message }, 401); - } - - let session = await sessionStorage.getSession( - request.headers.get("Cookie"), - ); - - // if we do have a failureRedirect, we redirect to it and set the error - // in the session errorKey - session.flash(options.sessionErrorKey, { message }); - throw redirect(options.failureRedirect, { - headers: { "Set-Cookie": await sessionStorage.commitSession(session) }, - }); - } - - /** - * Returns the user data or throw a redirect to the successRedirect. - * @param user The user data to set in the session. - * @param request The request to get the cookie out of. - * @param sessionStorage The session storage to retrieve the session from. - * @param options The strategy options. - * @returns {Promise} The user data. - * @throws {Response} If the successRedirect is set, it will redirect to it. - */ - protected async success( - user: User, - request: Request, - sessionStorage: SessionStorage, - options: AuthenticateOptions, - ): Promise { - // if a successRedirect is not set, we return the user - if (!options.successRedirect) return user; - - let session = await sessionStorage.getSession( - request.headers.get("Cookie"), - ); - - // if we do have a successRedirect, we redirect to it and set the user - // in the session sessionKey - session.set(options.sessionKey, user); - session.set(options.sessionStrategyKey, options.name ?? this.name); - throw redirect(options.successRedirect, { - headers: { "Set-Cookie": await sessionStorage.commitSession(session) }, - }); - } -} diff --git a/src/strategy.test.ts b/src/strategy.test.ts new file mode 100644 index 0000000..c0ba4bd --- /dev/null +++ b/src/strategy.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { createCookie } from "@remix-run/node"; +import { z } from "zod"; +import { Strategy } from "./strategy"; + +type User = number; +type VerifyOptions = { userId: string }; + +class SimpleStrategy extends Strategy { + name = "mock"; + + public authenticate(request: Request): Promise { + let url = new URL(request.url); + let userId = url.searchParams.get("userId"); + if (!userId) throw new Error("Invalid credentials"); + return this.verify({ userId }); + } +} + +describe(SimpleStrategy.name, () => { + test("#constructor", () => { + let strategy = new SimpleStrategy(async ({ userId }) => Number(userId)); + expect(strategy).toBeInstanceOf(Strategy); + }); + + test("#authenticate (success)", async () => { + let strategy = new SimpleStrategy(async ({ userId }) => Number(userId)); + let request = new Request("http://remix.auth/test?userId=1"); + expect(strategy.authenticate(request)).resolves.toBe(1); + }); + + test("#authenticate (failure)", async () => { + let strategy = new SimpleStrategy(async ({ userId }) => Number(userId)); + let request = new Request("http://remix.auth/test"); + expect(() => strategy.authenticate(request)).toThrow("Invalid credentials"); + }); +}); + +class CookieStrategy extends Strategy { + name = "cookie"; + + public async authenticate( + request: Request, + { cookie }: Strategy.AuthenticateOptions, + ): Promise { + let data = await cookie.parse(request.headers.get("Cookie")); + let userId = z.string().nullable().parse(data); + if (!userId) throw new Error("Invalid credentials"); + return this.verify({ userId }); + } +} + +describe(CookieStrategy.name, () => { + let cookie = createCookie("auth", { secrets: ["s3cr3t"] }); + + test("#constructor", () => { + let strategy = new CookieStrategy(async ({ userId }) => Number(userId)); + expect(strategy).toBeInstanceOf(Strategy); + }); + + test("#authenticate (success)", async () => { + let strategy = new CookieStrategy(async ({ userId }) => Number(userId)); + let request = new Request("http://remix.auth/test", { + headers: { Cookie: await cookie.serialize("1") }, + }); + + expect( + strategy.authenticate(request, { name: "cookie", cookie }), + ).resolves.toBe(1); + }); + + test("#authenticate (failure)", async () => { + let strategy = new CookieStrategy(async ({ userId }) => Number(userId)); + let request = new Request("http://remix.auth/test"); + + expect(() => + strategy.authenticate(request, { name: "cookie", cookie }), + ).toThrow("Invalid credentials"); + }); +}); diff --git a/src/strategy.ts b/src/strategy.ts new file mode 100644 index 0000000..6fd0a5f --- /dev/null +++ b/src/strategy.ts @@ -0,0 +1,69 @@ +import type { AppLoadContext, Cookie } from "@remix-run/server-runtime"; + +export namespace Strategy { + /** + * Extra information from the Authenticator to the strategy + */ + export interface AuthenticateOptions { + /** + * The name used to register the strategy + */ + name: string; + /** + * A cookie that can be used by the strategy to store data in case it has + * to redirect the user to another page before the authentication is done. + */ + cookie: Cookie; + /** + * The context object received by the loader or action. + * This can be used by the strategy if needed. + */ + context?: AppLoadContext; + } + + /** + * A function which will be called to find the user using the information the + * strategy got from the request. + * + * @param params The params from the strategy. + * @returns The user data. + * @throws {AuthorizationError} If the user was not found. Any other error will be ignored and thrown again by the strategy. + */ + export type VerifyCallback = ( + params: VerifyParams, + ) => Promise; +} + +/** + * The Strategy class is the base class every strategy should extend. + * + * This class receives two generics, a User and a VerifyParams. + * - User is the type of the user data. + * - VerifyParams is the type of the params the verify callback will receive from the strategy. + */ +export abstract class Strategy { + /** + * The name of the strategy. + * This will be used by the Authenticator to identify and retrieve the + * strategy. + */ + public abstract name: string; + + public constructor( + protected verify: Strategy.VerifyCallback, + ) {} + + /** + * The authentication flow of the strategy. + * + * This method receives the Request to authenticator and the session storage + * to use from the Authenticator. It may receive a custom callback. + * + * At the end of the flow, it will return a Response to be used by the + * application. + */ + public abstract authenticate( + request: Request, + options: Strategy.AuthenticateOptions, + ): Promise; +} diff --git a/typedoc.json b/typedoc.json index 97ffc70..074caf3 100644 --- a/typedoc.json +++ b/typedoc.json @@ -2,8 +2,8 @@ "$schema": "https://typedoc.org/schema.json", "includeVersion": true, "entryPoints": ["./src/index.ts"], - "out": "pages", - "json": "pages/index.json", + "out": "docs", + "json": "docs/index.json", "cleanOutputDir": true, "plugin": ["typedoc-plugin-mdn-links"], "categorizeByGroup": false