Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
41d7971
Enhance authentication strategies with improved type safety, and add …
sergiodxa Oct 9, 2025
ae5816e
Remove unnecessary error handling for unknown strategies
sergiodxa Oct 9, 2025
5e9d736
Merge branch 'main' into type-safety
sergiodxa Oct 9, 2025
10912fa
Improve code formatting and consistency in Authenticator class
sergiodxa Oct 9, 2025
b3f73fa
Merge branch 'main' into type-safety
sergiodxa Oct 10, 2025
6fa2421
Merge branch 'main' into type-safety
sergiodxa Oct 10, 2025
51fe1b8
Merge branch 'main' into type-safety
sergiodxa Nov 12, 2025
59f619f
Update dependencies
sergiodxa Nov 12, 2025
ab9ab0d
Implement OAuth2 strategy with state management and redirect handling
sergiodxa Nov 12, 2025
c4831aa
Add FormStrategy for form-based authentication
sergiodxa Nov 12, 2025
186f404
Add exports for oauth2 and form strategies
sergiodxa Nov 12, 2025
297c56f
Fix typo
sergiodxa Nov 12, 2025
acd1e7e
Correct error type in authentication test and update comments for cla…
sergiodxa Nov 12, 2025
33410b4
Document StateStore instance property design to clarify race conditio…
Copilot Nov 12, 2025
8ad03ba
Update import paths for SetCookie and Cookie to use @remix-run/headers
sergiodxa Nov 20, 2025
f6429a5
Improve documentation for OAuth2Strategy methods
sergiodxa Nov 20, 2025
a73004c
Improve type safety for authenticate method return type
sergiodxa Nov 20, 2025
dc82011
Include username in successful authentication response
sergiodxa Nov 20, 2025
436bd52
Enhance type safety for StateStore and OAuth2Strategy
sergiodxa Nov 20, 2025
c797874
Remove unused AuthenticateOptions interface from LoginStrategy namespace
sergiodxa Nov 20, 2025
56b7c09
Enhance type safety for strategies getter in Authenticator class
sergiodxa Nov 20, 2025
0314a26
Pass options to authorizationParams for improved flexibility
sergiodxa Nov 20, 2025
0b72af1
Enhance type safety for state management in OAuth2Strategy tests
sergiodxa Nov 20, 2025
2d23908
Add type-fest dependency for improved type safety
sergiodxa Nov 20, 2025
d744754
Add arrayify internal utility function for array conversion
sergiodxa Nov 20, 2025
e5e01a7
Add support for additional OIDC parameters in OAuth2Strategy
sergiodxa Nov 20, 2025
44ab2e0
Use @remix-run/response instead of custom redirect helper
sergiodxa Dec 4, 2025
7315f73
Apply suggestions from code review
sergiodxa Jan 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 214 additions & 121 deletions README.md

Large diffs are not rendered by default.

103 changes: 61 additions & 42 deletions bun.lock

Large diffs are not rendered by default.

22 changes: 14 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@
"type": "git"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.1",
"@mjackson/headers": "^0.11.1",
"@arethetypeswrong/cli": "^0.18.2",
"@total-typescript/tsconfig": "^1.0.4",
"@types/bun": "^1.1.14",
"msw": "^2.7.0",
"oxlint": "^1.22.0",
"@types/bun": "^1.3.2",
"msw": "^2.12.1",
"oxlint": "^1.28.0",
"prettier": "^3.6.2",
"typedoc": "^0.28.0",
"typedoc-plugin-mdn-links": "^5.0.1",
"typedoc": "^0.28.14",
"typedoc-plugin-mdn-links": "^5.0.10",
"typescript": "^5.5.4"
},
"exports": {
".": "./build/index.js",
"./strategy": "./build/strategy.js",
"./oauth2": "./build/strategies/oauth2.js",
"./form": "./build/strategies/form.js",
"./package.json": "./package.json"
},
"bugs": {
Expand Down Expand Up @@ -55,5 +56,10 @@
"exports": "bun run ./scripts/exports.ts"
},
"sideEffects": false,
"type": "module"
"type": "module",
"dependencies": {
"@edgefirst-dev/data": "^0.0.4",
"@remix-run/headers": "^0.16.0",
"arctic": "^3.7.0"
}
}
134 changes: 85 additions & 49 deletions src/index.test.ts
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);
});
});
150 changes: 72 additions & 78 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>> {
Comment thread
sergiodxa marked this conversation as resolved.
Outdated
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;

Copilot AI Dec 10, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The strategies getter returns a type that omits the authenticate method from each strategy, but the actual implementation returns this.#strategies which still contains the authenticate method. This creates a type mismatch where the runtime object has more methods than the type indicates. Either the return type should not omit authenticate, or the getter should return a mapped object that actually omits it.

Suggested change
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">;
}>;

Copilot uses AI. Check for mistakes.
}
}

export namespace Authenticator {
/**
* Retrieves a registered strategy by name.
* Infers the session data type from a record of strategies.
*
* This utility type extracts the session data type associated with the
* strategies in the provided record. It is useful for ensuring type safety
* when working with authenticated session data.
*
* @param name - The name of the strategy to retrieve
* @returns The strategy instance if found, null otherwise
* @typeParam S - The specific strategy type to cast to
* @typeParam SR - A record of strategies from which to infer the session data type
*
* @example
* ```ts
* // Get a strategy
* const formStrategy = auth.get<FormStrategy>("form");
* ```
* type SessionData = Authenticator.StrategySessionData<typeof auth.strategies>;
*/
get<S extends Strategy<User, never>>(name: string): S | null {
return (this.strategies.get(name) as S) ?? null;
}
export type StrategySessionData<
SR extends Record<string, Strategy<any, any>>,
> = SR[keyof SR] extends Strategy<infer SD, any> ? SD : never;

/**
* Authenticates a request using the specified strategy.
* Infers the session data type from an Authenticator instance.
*
* This method delegates the authentication process to the named strategy and
* returns the authenticated user data if successful.
* This utility type extracts the session data type associated with the
* strategies registered in the Authenticator. It is useful for ensuring
* type safety when working with authenticated session data.
*
* @param strategy - The name of the strategy to use for authentication
* @param request - The request object to authenticate
* @returns Promise resolving to the authenticated user
* @throws {ReferenceError} If the specified strategy is not found
* @typeParam T - The Authenticator instance from which to infer the session data type
*
* @example
* ```ts
* async function action({ request }: ActionFunctionArgs) {
* try {
* const user = await auth.authenticate("form", request);
* // User is authenticated, do something with the user data
* return redirect("/dashboard");
* } catch (error) {
* // Handle authentication error
* return json({ error: error.message });
* }
* }
* ```
* type SessionData = Authenticator.infer<typeof auth>;
*/
authenticate(strategy: string, request: Request): Promise<User> {
let instance = this.get(strategy);
if (!instance) throw new ReferenceError(`Strategy ${strategy} not found.`);
return instance.authenticate(new Request(request.url, request));
}
export type infer<T> =
T extends Authenticator<infer SR> ? StrategySessionData<SR> : never;
}
Loading
Loading