Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

All notable changes to `@nestbolt/authentication` will be documented in this file.

## 0.1.0
## v0.1.1

### Bug Fixes

- **forRootAsync DI bypass** — `forRootAsync` was using `new options.userRepository()` which bypassed NestJS dependency injection, leaving repository dependencies (e.g. `DataSource`) undefined. Now uses `ModuleRef.create()` to properly instantiate repositories through the DI container.
- **forRootAsync missing PASSWORD_RESET_REPOSITORY** — The conditional `PASSWORD_RESET_REPOSITORY` registration present in `forRoot` was absent from `forRootAsync`. Now registers it via `ModuleRef.create()` when configured, or `null` when not (compatible with `@Optional()` injection).
- **TwoFactorController.enable() crash on empty body** — Calling `POST /user/two-factor-authentication` without a request body caused a `TypeError` because `@Body()` returned `undefined`. Now handles optional body with safe navigation (`body?.force ?? false`).

## v0.1.0

### Features

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nestbolt/authentication",
"version": "0.1.0",
"version": "0.1.1",
"packageManager": "pnpm@9.15.4",
"description": "Frontend-agnostic authentication backend for NestJS with 2FA, password reset, email verification, and more",
"main": "dist/index.js",
Expand Down
46 changes: 34 additions & 12 deletions src/authentication.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DynamicModule, Module, Provider, Type } from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import {
Expand Down Expand Up @@ -154,6 +155,32 @@ export class AuthenticationModule {
}

static forRootAsync(asyncOptions: AuthenticationAsyncOptions): DynamicModule {
const optionsProvider: Provider = {
provide: AUTHENTICATION_OPTIONS,
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject ?? [],
};

const repositoryProviders: Provider[] = [
{
provide: USER_REPOSITORY,
useFactory: async (options: AuthenticationModuleOptions, moduleRef: ModuleRef) =>
moduleRef.create(options.userRepository),
inject: [AUTHENTICATION_OPTIONS, ModuleRef],
},
{
provide: PASSWORD_RESET_REPOSITORY,
useFactory: async (options: AuthenticationModuleOptions, moduleRef: ModuleRef) =>
options.passwordResetRepository
? moduleRef.create(options.passwordResetRepository)
: null,
inject: [AUTHENTICATION_OPTIONS, ModuleRef],
},
Comment on lines +171 to +178
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

In forRootAsync, PASSWORD_RESET_REPOSITORY is always registered and returns null when options.passwordResetRepository is not configured. This changes Nest’s provider resolution semantics: an explicit null provider in this module will override any PASSWORD_RESET_REPOSITORY provider supplied by imported modules, making it impossible to satisfy the optional dependency via imports alone. Since @Optional() already handles a missing provider, consider only conditionally registering PASSWORD_RESET_REPOSITORY when options.passwordResetRepository is set (matching forRoot).

Suggested change
{
provide: PASSWORD_RESET_REPOSITORY,
useFactory: async (options: AuthenticationModuleOptions, moduleRef: ModuleRef) =>
options.passwordResetRepository
? moduleRef.create(options.passwordResetRepository)
: null,
inject: [AUTHENTICATION_OPTIONS, ModuleRef],
},
...(asyncOptions.passwordResetRepository
? [
{
provide: PASSWORD_RESET_REPOSITORY,
useFactory: async (
options: AuthenticationModuleOptions,
moduleRef: ModuleRef,
) => moduleRef.create(options.passwordResetRepository),
inject: [AUTHENTICATION_OPTIONS, ModuleRef],
},
]
: []),

Copilot uses AI. Check for mistakes.
];

// With async options, controllers/services are registered eagerly since
// the features array is not available at static definition time.
// The FeatureEnabledGuard on each controller gates access at runtime.
return {
module: AuthenticationModule,
global: true,
Expand Down Expand Up @@ -184,33 +211,28 @@ export class AuthenticationModule {
TwoFactorChallengeController,
],
providers: [
{
provide: AUTHENTICATION_OPTIONS,
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject ?? [],
},
{
provide: USER_REPOSITORY,
useFactory: (options: AuthenticationModuleOptions) => {
return new options.userRepository();
},
inject: [AUTHENTICATION_OPTIONS],
},
optionsProvider,
...repositoryProviders,
// Core services
AuthService,
EncryptionService,
RecoveryCodeService,
ConfirmPasswordService,
// Passport strategies
LocalStrategy,
JwtStrategy,
JwtRefreshStrategy,
// Guards
JwtAuthGuard,
LoginThrottleGuard,
TwoFactorThrottleGuard,
VerificationThrottleGuard,
FeatureEnabledGuard,
PasswordConfirmedGuard,
GuestGuard,
// Interceptors
CanonicalizeUsernameInterceptor,
// Feature services (gated at runtime by FeatureEnabledGuard)
RegistrationService,
PasswordResetService,
EmailVerificationService,
Expand Down
4 changes: 2 additions & 2 deletions src/controllers/two-factor.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export class TwoFactorController {
@Post("two-factor-authentication")
@UseGuards(PasswordConfirmedGuard)
@HttpCode(HttpStatus.OK)
async enable(@CurrentUser() user: AuthUser, @Body() body: { force?: boolean }) {
await this.twoFactorService.enable(user, body.force ?? false);
async enable(@CurrentUser() user: AuthUser, @Body() body?: { force?: boolean }) {
await this.twoFactorService.enable(user, body?.force ?? false);
return { message: "Two-factor authentication enabled." };
}

Expand Down
51 changes: 48 additions & 3 deletions test/authentication.module.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { ModuleRef } from "@nestjs/core";
import { AuthenticationModule } from "../src/authentication.module";
import { Feature } from "../src/interfaces";
import { AuthController } from "../src/controllers/auth.controller";
Expand Down Expand Up @@ -384,7 +385,7 @@ describe("AuthenticationModule", () => {
expect(userRepoProvider.inject).toContain(AUTHENTICATION_OPTIONS);
});

it("should create user repository instance from options", () => {
it("should create user repository instance via ModuleRef", async () => {
const result = AuthenticationModule.forRootAsync({
useFactory: () => baseOptions,
});
Expand All @@ -393,8 +394,52 @@ describe("AuthenticationModule", () => {
const userRepoProvider = providers.find(
(p: any) => typeof p === "object" && p.provide === USER_REPOSITORY,
);
const instance = userRepoProvider.useFactory(baseOptions);
expect(userRepoProvider.inject).toContain(ModuleRef);

const mockModuleRef = {
create: vi.fn().mockResolvedValue(new MockUserRepository()),
};
const instance = await userRepoProvider.useFactory(baseOptions, mockModuleRef);
expect(instance).toBeInstanceOf(MockUserRepository);
expect(mockModuleRef.create).toHaveBeenCalledWith(MockUserRepository);
});

it("should provide PASSWORD_RESET_REPOSITORY via ModuleRef when configured", async () => {
const optsWithReset = {
...baseOptions,
passwordResetRepository: MockPasswordResetRepository as any,
};
const result = AuthenticationModule.forRootAsync({
useFactory: () => optsWithReset,
});
const providers = result.providers as any[];

const resetRepoProvider = providers.find(
(p: any) => typeof p === "object" && p.provide === PASSWORD_RESET_REPOSITORY,
);
expect(resetRepoProvider).toBeDefined();

const mockModuleRef = {
create: vi.fn().mockResolvedValue(new MockPasswordResetRepository()),
};
const instance = await resetRepoProvider.useFactory(optsWithReset, mockModuleRef);
expect(instance).toBeInstanceOf(MockPasswordResetRepository);
expect(mockModuleRef.create).toHaveBeenCalledWith(MockPasswordResetRepository);
});

it("should return null for PASSWORD_RESET_REPOSITORY when not configured", async () => {
const result = AuthenticationModule.forRootAsync({
useFactory: () => baseOptions,
});
const providers = result.providers as any[];

const resetRepoProvider = providers.find(
(p: any) => typeof p === "object" && p.provide === PASSWORD_RESET_REPOSITORY,
);
const mockModuleRef = { create: vi.fn() };
const instance = await resetRepoProvider.useFactory(baseOptions, mockModuleRef);
expect(instance).toBeNull();
expect(mockModuleRef.create).not.toHaveBeenCalled();
Comment on lines +430 to +442
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

The async test currently asserts that PASSWORD_RESET_REPOSITORY is provided and resolves to null when not configured. If the module is updated to omit the provider when passwordResetRepository is absent (to match forRoot and avoid overriding imported providers), this test should instead assert the provider is undefined / not present.

Copilot uses AI. Check for mistakes.
});

it("should use empty inject array when inject is not provided", () => {
Expand Down