@lenne.tech/nest-server 11.7.0 → 11.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/config.env.js +17 -1
  2. package/dist/config.env.js.map +1 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +35 -15
  4. package/dist/core/modules/auth/core-auth.controller.d.ts +1 -0
  5. package/dist/core/modules/auth/core-auth.controller.js +29 -3
  6. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  7. package/dist/core/modules/auth/core-auth.module.js +14 -1
  8. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  9. package/dist/core/modules/auth/core-auth.resolver.d.ts +1 -0
  10. package/dist/core/modules/auth/core-auth.resolver.js +21 -3
  11. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  12. package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.d.ts +4 -0
  13. package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.js +17 -0
  14. package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.js.map +1 -0
  15. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +9 -0
  16. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +74 -0
  17. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -0
  18. package/dist/core/modules/auth/interfaces/auth-provider.interface.d.ts +7 -0
  19. package/dist/core/modules/auth/interfaces/auth-provider.interface.js +5 -0
  20. package/dist/core/modules/auth/interfaces/auth-provider.interface.js.map +1 -0
  21. package/dist/core/modules/auth/interfaces/core-auth-user.interface.d.ts +1 -0
  22. package/dist/core/modules/auth/services/core-auth.service.d.ts +10 -1
  23. package/dist/core/modules/auth/services/core-auth.service.js +141 -9
  24. package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
  25. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +31 -0
  26. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +153 -0
  27. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -0
  28. package/dist/core/modules/better-auth/better-auth-migration-status.model.d.ts +10 -0
  29. package/dist/core/modules/better-auth/better-auth-migration-status.model.js +57 -0
  30. package/dist/core/modules/better-auth/better-auth-migration-status.model.js.map +1 -0
  31. package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js +1 -1
  32. package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js.map +1 -1
  33. package/dist/core/modules/better-auth/better-auth-user.mapper.d.ts +33 -0
  34. package/dist/core/modules/better-auth/better-auth-user.mapper.js +395 -0
  35. package/dist/core/modules/better-auth/better-auth-user.mapper.js.map +1 -1
  36. package/dist/core/modules/better-auth/better-auth.config.js +29 -10
  37. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  38. package/dist/core/modules/better-auth/better-auth.middleware.d.ts +1 -0
  39. package/dist/core/modules/better-auth/better-auth.middleware.js +55 -1
  40. package/dist/core/modules/better-auth/better-auth.middleware.js.map +1 -1
  41. package/dist/core/modules/better-auth/better-auth.module.d.ts +1 -1
  42. package/dist/core/modules/better-auth/better-auth.module.js +46 -18
  43. package/dist/core/modules/better-auth/better-auth.module.js.map +1 -1
  44. package/dist/core/modules/better-auth/better-auth.resolver.js +0 -11
  45. package/dist/core/modules/better-auth/better-auth.resolver.js.map +1 -1
  46. package/dist/core/modules/better-auth/better-auth.service.d.ts +22 -1
  47. package/dist/core/modules/better-auth/better-auth.service.js +209 -8
  48. package/dist/core/modules/better-auth/better-auth.service.js.map +1 -1
  49. package/dist/core/modules/better-auth/better-auth.types.d.ts +2 -0
  50. package/dist/core/modules/better-auth/better-auth.types.js.map +1 -1
  51. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +1 -0
  52. package/dist/core/modules/better-auth/core-better-auth.controller.js +15 -2
  53. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  54. package/dist/core/modules/better-auth/core-better-auth.resolver.d.ts +7 -0
  55. package/dist/core/modules/better-auth/core-better-auth.resolver.js +72 -12
  56. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  57. package/dist/core/modules/better-auth/index.d.ts +1 -0
  58. package/dist/core/modules/better-auth/index.js +1 -0
  59. package/dist/core/modules/better-auth/index.js.map +1 -1
  60. package/dist/core/modules/user/core-user.service.d.ts +7 -1
  61. package/dist/core/modules/user/core-user.service.js +57 -3
  62. package/dist/core/modules/user/core-user.service.js.map +1 -1
  63. package/dist/core/modules/user/interfaces/core-user-service-options.interface.d.ts +4 -0
  64. package/dist/core/modules/user/interfaces/core-user-service-options.interface.js +3 -0
  65. package/dist/core/modules/user/interfaces/core-user-service-options.interface.js.map +1 -0
  66. package/dist/core.module.d.ts +3 -0
  67. package/dist/core.module.js +136 -55
  68. package/dist/core.module.js.map +1 -1
  69. package/dist/index.d.ts +5 -0
  70. package/dist/index.js +5 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/server/modules/auth/auth.resolver.js +2 -0
  73. package/dist/server/modules/auth/auth.resolver.js.map +1 -1
  74. package/dist/server/modules/better-auth/better-auth.module.d.ts +1 -1
  75. package/dist/server/modules/better-auth/better-auth.module.js +2 -1
  76. package/dist/server/modules/better-auth/better-auth.module.js.map +1 -1
  77. package/dist/server/modules/better-auth/better-auth.resolver.d.ts +5 -0
  78. package/dist/server/modules/better-auth/better-auth.resolver.js +27 -11
  79. package/dist/server/modules/better-auth/better-auth.resolver.js.map +1 -1
  80. package/dist/server/modules/user/user.controller.js +0 -8
  81. package/dist/server/modules/user/user.controller.js.map +1 -1
  82. package/dist/server/modules/user/user.service.d.ts +3 -1
  83. package/dist/server/modules/user/user.service.js +7 -3
  84. package/dist/server/modules/user/user.service.js.map +1 -1
  85. package/dist/tsconfig.build.tsbuildinfo +1 -1
  86. package/package.json +1 -1
  87. package/src/config.env.ts +32 -2
  88. package/src/core/common/interfaces/server-options.interface.ts +304 -58
  89. package/src/core/modules/auth/core-auth.controller.ts +94 -6
  90. package/src/core/modules/auth/core-auth.module.ts +15 -1
  91. package/src/core/modules/auth/core-auth.resolver.ts +71 -3
  92. package/src/core/modules/auth/exceptions/legacy-auth-disabled.exception.ts +35 -0
  93. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +109 -0
  94. package/src/core/modules/auth/interfaces/auth-provider.interface.ts +86 -0
  95. package/src/core/modules/auth/interfaces/core-auth-user.interface.ts +6 -0
  96. package/src/core/modules/auth/services/core-auth.service.ts +245 -6
  97. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +283 -0
  98. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +255 -0
  99. package/src/core/modules/better-auth/README.md +565 -208
  100. package/src/core/modules/better-auth/better-auth-migration-status.model.ts +73 -0
  101. package/src/core/modules/better-auth/better-auth-rate-limiter.service.ts +1 -1
  102. package/src/core/modules/better-auth/better-auth-user.mapper.ts +737 -0
  103. package/src/core/modules/better-auth/better-auth.config.ts +45 -15
  104. package/src/core/modules/better-auth/better-auth.middleware.ts +85 -2
  105. package/src/core/modules/better-auth/better-auth.module.ts +83 -27
  106. package/src/core/modules/better-auth/better-auth.resolver.ts +0 -11
  107. package/src/core/modules/better-auth/better-auth.service.ts +367 -12
  108. package/src/core/modules/better-auth/better-auth.types.ts +16 -0
  109. package/src/core/modules/better-auth/core-better-auth.controller.ts +44 -3
  110. package/src/core/modules/better-auth/core-better-auth.resolver.ts +136 -16
  111. package/src/core/modules/better-auth/index.ts +1 -0
  112. package/src/core/modules/user/core-user.service.ts +131 -4
  113. package/src/core/modules/user/interfaces/core-user-service-options.interface.ts +15 -0
  114. package/src/core.module.ts +264 -76
  115. package/src/index.ts +5 -0
  116. package/src/server/modules/auth/auth.resolver.ts +8 -0
  117. package/src/server/modules/better-auth/better-auth.module.ts +9 -3
  118. package/src/server/modules/better-auth/better-auth.resolver.ts +18 -11
  119. package/src/server/modules/user/user.controller.ts +1 -9
  120. package/src/server/modules/user/user.service.ts +4 -2
@@ -1,4 +1,11 @@
1
- import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
1
+ import {
2
+ BadRequestException,
3
+ Injectable,
4
+ Logger,
5
+ NotFoundException,
6
+ Optional,
7
+ UnauthorizedException,
8
+ } from '@nestjs/common';
2
9
  import { JwtService } from '@nestjs/jwt';
3
10
  import bcrypt = require('bcrypt');
4
11
  import { randomUUID } from 'crypto';
@@ -8,6 +15,8 @@ import { getStringIds } from '../../../common/helpers/db.helper';
8
15
  import { prepareServiceOptions } from '../../../common/helpers/service.helper';
9
16
  import { ServiceOptions } from '../../../common/interfaces/service-options.interface';
10
17
  import { ConfigService } from '../../../common/services/config.service';
18
+ import { BetterAuthUserMapper } from '../../better-auth/better-auth-user.mapper';
19
+ import { BetterAuthService } from '../../better-auth/better-auth.service';
11
20
  import { CoreAuthModel } from '../core-auth.model';
12
21
  import { CoreAuthSignInInput } from '../inputs/core-auth-sign-in.input';
13
22
  import { CoreAuthSignUpInput } from '../inputs/core-auth-sign-up.input';
@@ -29,9 +38,23 @@ export interface GetResultOptions {
29
38
 
30
39
  /**
31
40
  * CoreAuthService to handle user authentication
41
+ *
42
+ * When Better-Auth (IAM) is enabled, this service delegates authentication to IAM
43
+ * while maintaining backwards compatibility by returning Legacy JWT format tokens.
44
+ *
45
+ * Migration strategy:
46
+ * - New users: Created directly in IAM with scrypt password hash
47
+ * - Existing Legacy users: Lazily migrated on first sign-in
48
+ * (Legacy bcrypt password verified, then IAM account created with scrypt hash)
49
+ *
50
+ * @deprecated The signIn and signUp methods are deprecated when IAM is enabled.
51
+ * Use the IAM REST endpoints (/iam/sign-in/email, /iam/sign-up/email) directly
52
+ * for new implementations. Legacy endpoints remain for backwards compatibility.
32
53
  */
33
54
  @Injectable()
34
55
  export class CoreAuthService {
56
+ private readonly logger = new Logger(CoreAuthService.name);
57
+
35
58
  /**
36
59
  * Integrate services
37
60
  */
@@ -39,6 +62,8 @@ export class CoreAuthService {
39
62
  protected readonly userService: CoreAuthUserService,
40
63
  protected readonly jwtService: JwtService,
41
64
  protected readonly configService: ConfigService,
65
+ @Optional() protected readonly betterAuthService?: BetterAuthService,
66
+ @Optional() protected readonly betterAuthUserMapper?: BetterAuthUserMapper,
42
67
  ) {}
43
68
 
44
69
  /**
@@ -99,6 +124,14 @@ export class CoreAuthService {
99
124
 
100
125
  /**
101
126
  * User sign in via email
127
+ *
128
+ * When IAM is enabled, this method:
129
+ * 1. For migrated users (have iamId): Verifies password via IAM
130
+ * 2. For non-migrated users: Verifies via Legacy, then migrates to IAM
131
+ *
132
+ * Always returns Legacy JWT format for backwards compatibility.
133
+ *
134
+ * @deprecated When IAM is enabled, prefer using /iam/sign-in/email REST endpoint directly.
102
135
  */
103
136
  async signIn(input: CoreAuthSignInInput, serviceOptions?: ServiceOptions): Promise<CoreAuthModel> {
104
137
  // Check input
@@ -106,6 +139,9 @@ export class CoreAuthService {
106
139
  throw new BadRequestException('Missing input');
107
140
  }
108
141
 
142
+ // Check if user enumeration prevention is enabled
143
+ const preventUserEnumeration = this.configService.getFastButReadOnly('auth.preventUserEnumeration', false);
144
+
109
145
  // Prepare service options
110
146
  const serviceOptionsForUserService = prepareServiceOptions(serviceOptions, {
111
147
  // We need password, so we can't use prepare output handling and have to deactivate it
@@ -119,12 +155,47 @@ export class CoreAuthService {
119
155
  const { deviceDescription, deviceId, email, password } = input;
120
156
 
121
157
  // Get user
122
- const user = await this.userService.getViaEmail(email, serviceOptionsForUserService);
158
+ let user: ICoreAuthUser;
159
+ try {
160
+ user = await this.userService.getViaEmail(email, serviceOptionsForUserService);
161
+ } catch (error) {
162
+ if (error instanceof NotFoundException) {
163
+ throw new UnauthorizedException(preventUserEnumeration ? 'Invalid credentials' : 'Unknown email');
164
+ }
165
+ throw error;
166
+ }
123
167
  if (!user) {
124
- throw new UnauthorizedException('Unknown email');
168
+ throw new UnauthorizedException(preventUserEnumeration ? 'Invalid credentials' : 'Unknown email');
125
169
  }
126
- if (!((await bcrypt.compare(password, user.password)) || (await bcrypt.compare(sha256(password), user.password)))) {
127
- throw new UnauthorizedException('Wrong password');
170
+
171
+ // Determine if IAM delegation is available
172
+ const iamEnabled = this.isIamEnabled();
173
+
174
+ if (iamEnabled && user.iamId) {
175
+ // User is already migrated to IAM - verify via IAM
176
+ const iamVerified = await this.verifyPasswordViaIam(email, password);
177
+ if (!iamVerified) {
178
+ throw new UnauthorizedException(preventUserEnumeration ? 'Invalid credentials' : 'Wrong password');
179
+ }
180
+ this.logger.debug(`User ${email} authenticated via IAM (already migrated)`);
181
+ } else {
182
+ // Verify via Legacy (bcrypt)
183
+ // Check if user has a password (social login only users don't have one)
184
+ if (!user.password) {
185
+ throw new UnauthorizedException(
186
+ preventUserEnumeration ? 'Invalid credentials' : 'No password set for this account',
187
+ );
188
+ }
189
+ if (
190
+ !((await bcrypt.compare(password, user.password)) || (await bcrypt.compare(sha256(password), user.password)))
191
+ ) {
192
+ throw new UnauthorizedException(preventUserEnumeration ? 'Invalid credentials' : 'Wrong password');
193
+ }
194
+
195
+ // If IAM is enabled but user not migrated, migrate them now
196
+ if (iamEnabled && !user.iamId) {
197
+ await this.migrateUserToIam(user, email, password);
198
+ }
128
199
  }
129
200
 
130
201
  // Return tokens and user with currentUser set so securityCheck knows user is requesting own data
@@ -136,6 +207,13 @@ export class CoreAuthService {
136
207
 
137
208
  /**
138
209
  * Register a new user account
210
+ *
211
+ * When IAM is enabled, this method:
212
+ * 1. Creates the user in IAM first (with scrypt password hash)
213
+ * 2. Creates/links the Legacy user with iamId
214
+ * 3. Returns Legacy JWT format for backwards compatibility
215
+ *
216
+ * @deprecated When IAM is enabled, prefer using /iam/sign-up/email REST endpoint directly.
139
217
  */
140
218
  async signUp(input: CoreAuthSignUpInput, serviceOptions?: ServiceOptions): Promise<CoreAuthModel> {
141
219
  // Prepare service options
@@ -146,7 +224,19 @@ export class CoreAuthService {
146
224
 
147
225
  // Get and check user
148
226
  try {
149
- const user = await this.userService.create(input, serviceOptionsForUserService);
227
+ // Determine if IAM delegation is available
228
+ const iamEnabled = this.isIamEnabled();
229
+
230
+ let user: ICoreAuthUser;
231
+
232
+ if (iamEnabled) {
233
+ // Create via IAM first, then create/link Legacy user
234
+ user = await this.createUserViaIam(input, serviceOptionsForUserService);
235
+ } else {
236
+ // Create via Legacy
237
+ user = await this.userService.create(input, serviceOptionsForUserService);
238
+ }
239
+
150
240
  if (!user) {
151
241
  throw new BadRequestException('Email address already in use');
152
242
  }
@@ -332,4 +422,153 @@ export class CoreAuthService {
332
422
  // Return new token
333
423
  return newRefreshToken;
334
424
  }
425
+
426
+ // ===================================================================================================================
427
+ // IAM Delegation Helper Methods
428
+ // ===================================================================================================================
429
+
430
+ /**
431
+ * Checks if IAM (Better-Auth) delegation is available and enabled
432
+ */
433
+ protected isIamEnabled(): boolean {
434
+ return !!(this.betterAuthService?.isEnabled() && this.betterAuthUserMapper);
435
+ }
436
+
437
+ /**
438
+ * Verifies password via IAM for already-migrated users
439
+ *
440
+ * @param email - User email
441
+ * @param password - Plain password to verify
442
+ * @returns true if password is valid, false otherwise
443
+ */
444
+ protected async verifyPasswordViaIam(email: string, password: string): Promise<boolean> {
445
+ if (!this.betterAuthService) {
446
+ return false;
447
+ }
448
+
449
+ const api = this.betterAuthService.getApi();
450
+ if (!api) {
451
+ return false;
452
+ }
453
+
454
+ try {
455
+ const response = await api.signInEmail({
456
+ body: { email, password },
457
+ });
458
+
459
+ // Check if response indicates successful authentication
460
+ return !!(response && 'user' in response && response.user);
461
+ } catch (error) {
462
+ this.logger.debug(
463
+ `IAM password verification failed for ${email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
464
+ );
465
+ return false;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Migrates a Legacy user to IAM
471
+ *
472
+ * This creates the IAM user and account with a scrypt password hash,
473
+ * then links the Legacy user via iamId.
474
+ *
475
+ * @param user - The Legacy user to migrate
476
+ * @param email - User email
477
+ * @param plainPassword - Plain password (needed to create scrypt hash)
478
+ */
479
+ protected async migrateUserToIam(user: ICoreAuthUser, email: string, plainPassword: string): Promise<void> {
480
+ if (!this.betterAuthUserMapper) {
481
+ return;
482
+ }
483
+
484
+ try {
485
+ // Create IAM account with the plain password (creates scrypt hash)
486
+ const migrated = await this.betterAuthUserMapper.migrateAccountToIam(email, plainPassword);
487
+
488
+ if (migrated) {
489
+ this.logger.log(`Migrated Legacy user ${email} to IAM`);
490
+
491
+ // Refresh user to get updated iamId
492
+ const updatedUser = await this.userService.getViaEmail(email, { force: true });
493
+ if (updatedUser?.iamId) {
494
+ // Update the user object in place so subsequent operations see the iamId
495
+ (user as any).iamId = updatedUser.iamId;
496
+ }
497
+ }
498
+ } catch (error) {
499
+ // Log but don't throw - migration failure shouldn't block login
500
+ this.logger.warn(
501
+ `Failed to migrate user ${email} to IAM: ${error instanceof Error ? error.message : 'Unknown error'}`,
502
+ );
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Creates a user via IAM and links to Legacy user
508
+ *
509
+ * @param input - Sign-up input data
510
+ * @param serviceOptions - Service options for user service
511
+ * @returns The created Legacy user (linked to IAM)
512
+ */
513
+ protected async createUserViaIam(input: CoreAuthSignUpInput, serviceOptions: ServiceOptions): Promise<ICoreAuthUser> {
514
+ if (!this.betterAuthService || !this.betterAuthUserMapper) {
515
+ throw new BadRequestException('IAM service not available');
516
+ }
517
+
518
+ const api = this.betterAuthService.getApi();
519
+ if (!api) {
520
+ throw new BadRequestException('IAM API not available');
521
+ }
522
+
523
+ try {
524
+ // Create user in IAM first
525
+ // Note: firstName and lastName are project-specific fields, may not exist on CoreAuthSignUpInput
526
+ const inputAny = input as any;
527
+ const name = [inputAny.firstName, inputAny.lastName].filter(Boolean).join(' ') || input.email.split('@')[0];
528
+ const response = await api.signUpEmail({
529
+ body: {
530
+ email: input.email,
531
+ name,
532
+ password: input.password,
533
+ },
534
+ });
535
+
536
+ if (!response || !('user' in response) || !response.user) {
537
+ throw new BadRequestException('Email address already in use');
538
+ }
539
+
540
+ // Link or create Legacy user with iamId
541
+ const iamUser = response.user as { email: string; id: string; name?: string };
542
+ const syncedUser = await this.betterAuthUserMapper.linkOrCreateUser(iamUser as any, {
543
+ firstName: inputAny.firstName,
544
+ lastName: inputAny.lastName,
545
+ });
546
+
547
+ if (!syncedUser) {
548
+ throw new BadRequestException('Failed to create user');
549
+ }
550
+
551
+ // Sync password to Legacy (enables backwards compatibility)
552
+ // Pass plain password so bcrypt hash can be created for Legacy Auth
553
+ await this.betterAuthUserMapper.syncPasswordToLegacy(iamUser.id, input.email, input.password);
554
+
555
+ this.logger.log(`Created user ${input.email} via IAM`);
556
+
557
+ // Get the full user from our database
558
+ const user = await this.userService.getViaEmail(input.email, serviceOptions);
559
+ if (!user) {
560
+ throw new BadRequestException('Failed to retrieve created user');
561
+ }
562
+
563
+ return user;
564
+ } catch (error) {
565
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
566
+ this.logger.debug(`IAM sign-up error for ${input.email}: ${errorMessage}`);
567
+
568
+ if (errorMessage.includes('already exists') || errorMessage.includes('already in use')) {
569
+ throw new BadRequestException('Email address already in use');
570
+ }
571
+ throw error;
572
+ }
573
+ }
335
574
  }
@@ -0,0 +1,283 @@
1
+ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2
+
3
+ import { IAuthRateLimit } from '../../../common/interfaces/server-options.interface';
4
+ import { ConfigService } from '../../../common/services/config.service';
5
+
6
+ /**
7
+ * Rate limit entry for tracking requests
8
+ */
9
+ interface RateLimitEntry {
10
+ count: number;
11
+ resetTime: number;
12
+ }
13
+
14
+ /**
15
+ * Result of a rate limit check
16
+ *
17
+ * @internal This interface is identical to BetterAuthRateLimiter's RateLimitResult.
18
+ * Use the exported RateLimitResult from better-auth module if needed externally.
19
+ */
20
+ interface RateLimitResult {
21
+ /** Whether the request is allowed */
22
+ allowed: boolean;
23
+ /** Current request count in the window */
24
+ current: number;
25
+ /** Maximum requests allowed */
26
+ limit: number;
27
+ /** Number of remaining requests in the window */
28
+ remaining: number;
29
+ /** Seconds until the rate limit resets */
30
+ resetIn: number;
31
+ }
32
+
33
+ /**
34
+ * Default rate limiting configuration
35
+ */
36
+ const DEFAULT_CONFIG: Required<IAuthRateLimit> = {
37
+ enabled: false,
38
+ max: 10,
39
+ message: 'Too many requests, please try again later.',
40
+ windowSeconds: 60,
41
+ };
42
+
43
+ /**
44
+ * In-memory rate limiter for Legacy Auth endpoints
45
+ *
46
+ * This service provides rate limiting to protect against brute-force attacks
47
+ * on authentication endpoints. It uses an in-memory store with automatic cleanup.
48
+ *
49
+ * Features:
50
+ * - Configurable request limits and time windows
51
+ * - Automatic cleanup of expired entries
52
+ * - IP-based tracking
53
+ * - Auto-configuration from ConfigService
54
+ *
55
+ * Configuration via config.env.ts:
56
+ * ```typescript
57
+ * auth: {
58
+ * rateLimit: {
59
+ * enabled: true,
60
+ * max: 10,
61
+ * windowSeconds: 60,
62
+ * message: 'Too many login attempts, please try again later.',
63
+ * }
64
+ * }
65
+ * ```
66
+ *
67
+ * @since 11.7.x
68
+ */
69
+ @Injectable()
70
+ export class LegacyAuthRateLimiter implements OnModuleInit {
71
+ private readonly logger = new Logger(LegacyAuthRateLimiter.name);
72
+ private readonly store = new Map<string, RateLimitEntry>();
73
+ private config: Required<IAuthRateLimit> = DEFAULT_CONFIG;
74
+ private cleanupInterval: NodeJS.Timeout | null = null;
75
+
76
+ constructor() {
77
+ // Start cleanup interval (every 5 minutes)
78
+ this.startCleanup();
79
+ }
80
+
81
+ /**
82
+ * Auto-configure from ConfigService on module initialization
83
+ */
84
+ onModuleInit(): void {
85
+ const rateLimitConfig = ConfigService.getFastButReadOnly<IAuthRateLimit>('auth.rateLimit');
86
+ if (rateLimitConfig) {
87
+ this.configure(rateLimitConfig);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Configure the rate limiter
93
+ *
94
+ * Follows the "presence implies enabled" pattern:
95
+ * - If config is undefined/null: rate limiting is disabled (backward compatible)
96
+ * - If config is an object (even empty {}): rate limiting is enabled by default
97
+ * - Unless `enabled: false` is explicitly set to disable while pre-configuring
98
+ *
99
+ * @param config - Rate limiting configuration (presence implies enabled)
100
+ */
101
+ configure(config: IAuthRateLimit | null | undefined): void {
102
+ // If config is not provided, rate limiting stays disabled (backward compatible)
103
+ if (config === undefined || config === null) {
104
+ return;
105
+ }
106
+
107
+ // Presence of config implies enabled, unless explicitly disabled
108
+ const enabled = config.enabled !== false;
109
+
110
+ this.config = {
111
+ ...DEFAULT_CONFIG,
112
+ ...config,
113
+ enabled,
114
+ };
115
+
116
+ if (this.config.enabled) {
117
+ this.logger.debug(
118
+ `Legacy Auth rate limiting enabled: ${this.config.max} requests per ${this.config.windowSeconds}s`,
119
+ );
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Check if a request is allowed under the rate limit
125
+ *
126
+ * @param ip - Client IP address
127
+ * @param endpoint - Endpoint name (e.g., 'signIn', 'signUp')
128
+ * @returns Rate limit check result
129
+ */
130
+ check(ip: string, endpoint: string): RateLimitResult {
131
+ // If rate limiting is disabled, always allow
132
+ if (!this.config.enabled) {
133
+ return {
134
+ allowed: true,
135
+ current: 0,
136
+ limit: Infinity,
137
+ remaining: Infinity,
138
+ resetIn: 0,
139
+ };
140
+ }
141
+
142
+ const limit = this.config.max;
143
+ const key = `${ip}:${endpoint}`;
144
+ const now = Date.now();
145
+
146
+ // Get or create entry
147
+ let entry = this.store.get(key);
148
+
149
+ if (!entry || now >= entry.resetTime) {
150
+ // Create new entry or reset expired one
151
+ entry = {
152
+ count: 1,
153
+ resetTime: now + this.config.windowSeconds * 1000,
154
+ };
155
+ this.store.set(key, entry);
156
+
157
+ return {
158
+ allowed: true,
159
+ current: 1,
160
+ limit,
161
+ remaining: limit - 1,
162
+ resetIn: this.config.windowSeconds,
163
+ };
164
+ }
165
+
166
+ // Increment count
167
+ entry.count++;
168
+
169
+ const resetIn = Math.ceil((entry.resetTime - now) / 1000);
170
+ const allowed = entry.count <= limit;
171
+ const remaining = Math.max(0, limit - entry.count);
172
+
173
+ if (!allowed) {
174
+ this.logger.warn(`Rate limit exceeded for IP ${this.maskIp(ip)} on ${endpoint}: ${entry.count}/${limit}`);
175
+ }
176
+
177
+ return {
178
+ allowed,
179
+ current: entry.count,
180
+ limit,
181
+ remaining,
182
+ resetIn,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Get the configured error message
188
+ */
189
+ getMessage(): string {
190
+ return this.config.message;
191
+ }
192
+
193
+ /**
194
+ * Check if rate limiting is enabled
195
+ */
196
+ isEnabled(): boolean {
197
+ return this.config.enabled;
198
+ }
199
+
200
+ /**
201
+ * Reset rate limit for a specific IP (useful for testing or admin override)
202
+ *
203
+ * @param ip - Client IP address
204
+ */
205
+ reset(ip: string): void {
206
+ for (const key of this.store.keys()) {
207
+ if (key.startsWith(`${ip}:`)) {
208
+ this.store.delete(key);
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Clear all rate limit entries (useful for testing)
215
+ */
216
+ clear(): void {
217
+ this.store.clear();
218
+ }
219
+
220
+ /**
221
+ * Get statistics about the rate limiter
222
+ */
223
+ getStats(): { activeEntries: number; enabled: boolean } {
224
+ return {
225
+ activeEntries: this.store.size,
226
+ enabled: this.config.enabled,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Stop the cleanup interval (for graceful shutdown)
232
+ */
233
+ onModuleDestroy(): void {
234
+ if (this.cleanupInterval) {
235
+ clearInterval(this.cleanupInterval);
236
+ this.cleanupInterval = null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Mask IP address for logging (privacy)
242
+ */
243
+ private maskIp(ip: string): string {
244
+ if (ip.includes('.')) {
245
+ // IPv4: show first two octets
246
+ const parts = ip.split('.');
247
+ return `${parts[0]}.${parts[1]}.*.*`;
248
+ }
249
+ // IPv6: show first segment
250
+ const parts = ip.split(':');
251
+ return `${parts[0]}:****`;
252
+ }
253
+
254
+ /**
255
+ * Start periodic cleanup of expired entries
256
+ */
257
+ private startCleanup(): void {
258
+ // Clean up every 5 minutes
259
+ this.cleanupInterval = setInterval(
260
+ () => {
261
+ const now = Date.now();
262
+ let cleaned = 0;
263
+
264
+ for (const [key, entry] of this.store.entries()) {
265
+ if (now >= entry.resetTime) {
266
+ this.store.delete(key);
267
+ cleaned++;
268
+ }
269
+ }
270
+
271
+ if (cleaned > 0) {
272
+ this.logger.debug(`Cleaned up ${cleaned} expired rate limit entries`);
273
+ }
274
+ },
275
+ 5 * 60 * 1000,
276
+ );
277
+
278
+ // Prevent the interval from keeping the process alive
279
+ if (this.cleanupInterval.unref) {
280
+ this.cleanupInterval.unref();
281
+ }
282
+ }
283
+ }