-
-
Notifications
You must be signed in to change notification settings - Fork 108
Enhance authentication strategies with improved type safety and additional options #387
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
base: main
Are you sure you want to change the base?
Changes from 13 commits
41d7971
ae5816e
5e9d736
10912fa
b3f73fa
6fa2421
51fe1b8
59f619f
ab9ab0d
c4831aa
186f404
297c56f
acd1e7e
33410b4
8ad03ba
f6429a5
a73004c
dc82011
436bd52
c797874
56b7c09
0314a26
0b72af1
2d23908
d744754
e5e01a7
44ab2e0
7315f73
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,70 +1,106 @@ | ||
| import { beforeEach, describe, expect, mock, test } from "bun:test"; | ||
| import { describe, test, expect } from "bun:test"; | ||
| import { Authenticator } from "./index.js"; | ||
| import { Strategy } from "./strategy.js"; | ||
| import { FormStrategy } from "./strategies/form.js"; | ||
| import { OAuth2Strategy } from "./strategies/oauth2.js"; | ||
|
|
||
| class LoginStrategy<SessionData> extends Strategy< | ||
| SessionData, | ||
| LoginStrategy.CallbackOptions | ||
| > { | ||
| async authenticate( | ||
| request: Request, | ||
| usernameField: string, | ||
| passwordField = "password", | ||
| ): Promise<SessionData> { | ||
| let formData = await request.formData(); | ||
| return await this.callback({ | ||
| form: formData, | ||
| fields: { | ||
| username: usernameField, | ||
| password: passwordField, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| class MockStrategy<User> extends Strategy<User, Record<string, never>> { | ||
| name = "mock"; | ||
|
|
||
| async authenticate() { | ||
| let user = await this.verify({}); | ||
| if (user) return user; | ||
| throw new Error("Invalid credentials"); | ||
| namespace LoginStrategy { | ||
| export interface CallbackOptions { | ||
| form: FormData; | ||
| fields: { username: string; password: string }; | ||
| } | ||
|
|
||
| custom() { | ||
| return "custom"; | ||
| export interface AuthenticateOptions { | ||
| usernameField: string; | ||
| passwordField: string; | ||
| } | ||
| } | ||
|
|
||
| describe(Authenticator.name, () => { | ||
| beforeEach(() => mock.restore()); | ||
|
|
||
| test("#constructor", () => { | ||
| let auth = new Authenticator(); | ||
| expect(auth).toBeInstanceOf(Authenticator); | ||
| describe(Authenticator, () => { | ||
| const auth = new Authenticator({ | ||
| strategies: { | ||
| form: new FormStrategy(async ({ form }) => { | ||
| let username = form.get("username") as string; | ||
| let password = form.get("password") as string; | ||
|
|
||
| if (username && password) return { userId: "124" }; | ||
| throw new Error("Invalid signup data"); | ||
| }), | ||
|
|
||
| login: new LoginStrategy(async ({ form, fields }) => { | ||
| let username = form.get(fields.username) as string; | ||
| let password = form.get(fields.password) as string; | ||
|
|
||
| if (username && password) return { userId: "124" }; | ||
| throw new Error("Invalid signup data"); | ||
| }), | ||
|
|
||
| oauth2: new OAuth2Strategy( | ||
| { | ||
| clientId: "your-client-id", | ||
| clientSecret: "your-client-secret", | ||
| redirectURI: "https://your-app.com/auth/callback", | ||
| tokenEndpoint: "https://provider.com/oauth/token", | ||
| authorizationEndpoint: "https://provider.com/oauth/authorize", | ||
| }, | ||
| async ({ tokens }) => tokens.accessToken(), | ||
| ), | ||
| }, | ||
| }); | ||
|
|
||
| test("#use", () => { | ||
| let auth = new Authenticator(); | ||
|
|
||
| expect(auth.use(new MockStrategy(async () => ({ id: 1 })))).toBe(auth); | ||
| test("authenticate without options", async () => { | ||
| let formData = new FormData(); | ||
| formData.append("username", "user"); | ||
| formData.append("password", "pass"); | ||
|
|
||
| expect( | ||
| auth.authenticate("mock", new Request("http://remix.auth/test")), | ||
| ).resolves.toEqual({ id: 1 }); | ||
| }); | ||
|
|
||
| test("#unuse", () => { | ||
| let auth = new Authenticator().use(new MockStrategy(async () => null)); | ||
| let request = new Request("https://example.com/form", { | ||
| method: "POST", | ||
| body: formData, | ||
| }); | ||
|
|
||
| expect(auth.unuse("mock")).toBe(auth); | ||
| let sessionData = await auth.authenticate("form", request); | ||
|
|
||
| expect( | ||
| async () => | ||
| await auth.authenticate("mock", new Request("http://remix.auth/test")), | ||
| ).toThrow(new ReferenceError("Strategy mock not found.")); | ||
| expect(sessionData).toEqual({ userId: "124" }); | ||
| }); | ||
|
|
||
| test("#authenticate", async () => { | ||
| let auth = new Authenticator().use( | ||
| new MockStrategy(async () => ({ id: 1 })), | ||
| ); | ||
| test("authenticate with REQUIRED options", async () => { | ||
| let formData = new FormData(); | ||
| formData.append("user", "user"); | ||
| formData.append("password", "pass"); | ||
|
|
||
| expect( | ||
| await auth.authenticate("mock", new Request("http://remix.auth/test")), | ||
| ).toEqual({ id: 1 }); | ||
| }); | ||
| let request = new Request("https://example.com/login", { | ||
| method: "POST", | ||
| body: formData, | ||
| }); | ||
|
|
||
| test("#get", () => { | ||
| let auth = new Authenticator(); | ||
| let sessionData = await auth.authenticate("login", request, "user"); | ||
|
|
||
| let strategy = new MockStrategy(async () => ({ id: 1 })); | ||
| auth.use(strategy); | ||
|
|
||
| let getted = auth.get<MockStrategy<{ id: number }>>("mock"); | ||
| expect(sessionData).toEqual({ userId: "124" }); | ||
| }); | ||
|
|
||
| expect(getted).toBe(strategy); | ||
| // biome-ignore lint/style/noNonNullAssertion: It's a test | ||
| expect(getted!.custom()).toBe("custom"); | ||
| test("access strategies", () => { | ||
| expect(auth.strategies.form).toBeInstanceOf(FormStrategy); | ||
| expect(auth.strategies.login).toBeInstanceOf(LoginStrategy); | ||
| expect(auth.strategies.oauth2).toBeInstanceOf(OAuth2Strategy); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,117 +7,111 @@ import type { Strategy } from "./strategy.js"; | |||||||||||||||||||||||||||
| * requests. Each strategy is registered with a name, which is used to identify | ||||||||||||||||||||||||||||
| * it during the authentication process. | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * @typeParam User - The type of user object that will be returned after authentication | ||||||||||||||||||||||||||||
| * @param StrategyRecord - A record of strategies where the key is the strategy name and the value is the strategy instance | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * @example | ||||||||||||||||||||||||||||
| * ```ts | ||||||||||||||||||||||||||||
| * import { Authenticator } from "remix-auth"; | ||||||||||||||||||||||||||||
| * import { FormStrategy } from "remix-auth-form"; | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * // Create an instance of the authenticator | ||||||||||||||||||||||||||||
| * const authenticator = new Authenticator<User>(); | ||||||||||||||||||||||||||||
| * const authenticator = new Authenticator({ | ||||||||||||||||||||||||||||
| * strategies: { | ||||||||||||||||||||||||||||
| * login: new FormStrategy(async ({ form }) => { | ||||||||||||||||||||||||||||
| * // Implement your authentication logic here | ||||||||||||||||||||||||||||
| * return findUserByCredentials(form); | ||||||||||||||||||||||||||||
| * }), | ||||||||||||||||||||||||||||
| * }, | ||||||||||||||||||||||||||||
| * }); | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * // Register a strategy | ||||||||||||||||||||||||||||
| * authenticator.use(new FormStrategy(async ({ form }) => { | ||||||||||||||||||||||||||||
| * // Implement your authentication logic here | ||||||||||||||||||||||||||||
| * return findUserByCredentials(form); | ||||||||||||||||||||||||||||
| * })); | ||||||||||||||||||||||||||||
| * ``` | ||||||||||||||||||||||||||||
| * let sessionData = await authenticator.authenticate("login", request); | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| export class Authenticator<User = unknown> { | ||||||||||||||||||||||||||||
| export class Authenticator< | ||||||||||||||||||||||||||||
| StrategyRecord extends Record<string, Strategy<any, any>>, | ||||||||||||||||||||||||||||
| > { | ||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * A map of the configured strategies, where the key is the name of the | ||||||||||||||||||||||||||||
| * strategy and the value is the strategy instance | ||||||||||||||||||||||||||||
| * @private | ||||||||||||||||||||||||||||
| * A readonly record of the configured strategies, where the key is the name | ||||||||||||||||||||||||||||
| * of the strategy and the value is the strategy instance | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| private strategies = new Map<string, Strategy<User, never>>(); | ||||||||||||||||||||||||||||
| #strategies: Readonly<StrategyRecord>; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| constructor(options: { strategies: StrategyRecord }) { | ||||||||||||||||||||||||||||
| this.#strategies = Object.freeze(options.strategies); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * Registers an authentication strategy with the authenticator. | ||||||||||||||||||||||||||||
| * Authenticates a request using the specified strategy. | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * This method delegates the authentication process to the named strategy and | ||||||||||||||||||||||||||||
| * returns the authenticated user data if successful. | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * @param strategy - The strategy instance to register | ||||||||||||||||||||||||||||
| * @param name - Optional custom name for the strategy. If not provided, the strategy's name property will be used | ||||||||||||||||||||||||||||
| * @returns The authenticator instance for method chaining | ||||||||||||||||||||||||||||
| * @param strategy - The name of the strategy to use for authentication | ||||||||||||||||||||||||||||
| * @param request - The request object to authenticate | ||||||||||||||||||||||||||||
| * @param ...args - Additional arguments required by the strategy's authenticate method | ||||||||||||||||||||||||||||
| * @returns Promise resolving to the session data | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * @example | ||||||||||||||||||||||||||||
| * ```ts | ||||||||||||||||||||||||||||
| * // Register with default name | ||||||||||||||||||||||||||||
| * auth.use(new FormStrategy(verify)); | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * // Register with custom name | ||||||||||||||||||||||||||||
| * auth.use(new FormStrategy(verify), "admin-form"); | ||||||||||||||||||||||||||||
| * ``` | ||||||||||||||||||||||||||||
| * async function action({ request }: ActionFunctionArgs) { | ||||||||||||||||||||||||||||
| * try { | ||||||||||||||||||||||||||||
| * const sessionData = await auth.authenticate("login", request); | ||||||||||||||||||||||||||||
| * // User is authenticated, do something with the session data | ||||||||||||||||||||||||||||
| * return redirect("/dashboard"); | ||||||||||||||||||||||||||||
| * } catch (error) { | ||||||||||||||||||||||||||||
| * // Handle authentication error | ||||||||||||||||||||||||||||
| * return json({ error: error.message }); | ||||||||||||||||||||||||||||
| * } | ||||||||||||||||||||||||||||
| * } | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| use(strategy: Strategy<User, never>, name?: string): Authenticator<User> { | ||||||||||||||||||||||||||||
| this.strategies.set(name ?? strategy.name, strategy); | ||||||||||||||||||||||||||||
| return this; | ||||||||||||||||||||||||||||
| async authenticate<StrategyName extends keyof StrategyRecord>( | ||||||||||||||||||||||||||||
| strategyName: StrategyName, | ||||||||||||||||||||||||||||
| ...args: Parameters<StrategyRecord[StrategyName]["authenticate"]> | ||||||||||||||||||||||||||||
| ): Promise<Authenticator.StrategySessionData<StrategyRecord>> { | ||||||||||||||||||||||||||||
| const strategy = this.#strategies[strategyName]; | ||||||||||||||||||||||||||||
| return strategy.authenticate.apply( | ||||||||||||||||||||||||||||
| strategy, | ||||||||||||||||||||||||||||
| args as Parameters<StrategyRecord[StrategyName]["authenticate"]>, | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * Removes a previously registered strategy from the authenticator. | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * @param name - The name of the strategy to remove | ||||||||||||||||||||||||||||
| * @returns The authenticator instance for method chaining | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * Retrieves a readonly record of all registered strategies. | ||||||||||||||||||||||||||||
| * Useful if the strategy exposes additional methods. | ||||||||||||||||||||||||||||
| * @example | ||||||||||||||||||||||||||||
| * ```ts | ||||||||||||||||||||||||||||
| * // Remove a strategy | ||||||||||||||||||||||||||||
| * auth.unuse("form"); | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * // Chain multiple removals | ||||||||||||||||||||||||||||
| * auth.unuse("form").unuse("oauth2"); | ||||||||||||||||||||||||||||
| * ``` | ||||||||||||||||||||||||||||
| * let loginStrategy = auth.strategies.login; | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| unuse(name: string): Authenticator { | ||||||||||||||||||||||||||||
| this.strategies.delete(name); | ||||||||||||||||||||||||||||
| return this; | ||||||||||||||||||||||||||||
| get strategies() { | ||||||||||||||||||||||||||||
| return this.#strategies; | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
| return this.#strategies; | |
| // Return a mapped object omitting 'authenticate' from each strategy | |
| return Object.freeze( | |
| Object.fromEntries( | |
| Object.entries(this.#strategies).map(([key, strategy]) => { | |
| // Omit 'authenticate' using destructuring | |
| const { authenticate, ...rest } = strategy as any; | |
| return [key, rest]; | |
| }), | |
| ), | |
| ) as Readonly<{ | |
| [K in keyof StrategyRecord]: Omit<StrategyRecord[K], "authenticate">; | |
| }>; |
Uh oh!
There was an error while loading. Please reload this page.