@lenne.tech/nest-server 11.13.1 → 11.13.3
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.
- package/dist/config.env.js +242 -4
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
- package/dist/core/modules/auth/core-auth.module.js +5 -2
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/roles.guard.d.ts +2 -0
- package/dist/core/modules/auth/guards/roles.guard.js +23 -2
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
- package/dist/core/modules/better-auth/better-auth-roles.guard.d.ts +10 -0
- package/dist/core/modules/better-auth/better-auth-roles.guard.js +136 -0
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.js +34 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -0
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +7 -0
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +27 -0
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +14 -16
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.d.ts +13 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +54 -9
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core.module.d.ts +1 -0
- package/dist/core.module.js +82 -2
- package/dist/core.module.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/config.env.ts +271 -5
- package/src/core/common/interfaces/server-options.interface.ts +46 -0
- package/src/core/modules/auth/core-auth.module.ts +9 -3
- package/src/core/modules/auth/guards/roles.guard.ts +40 -5
- package/src/core/modules/better-auth/CUSTOMIZATION.md +520 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +15 -0
- package/src/core/modules/better-auth/README.md +24 -1
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +205 -0
- package/src/core/modules/better-auth/better-auth.config.ts +51 -0
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +6 -0
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +16 -0
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +40 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +24 -24
- package/src/core/modules/better-auth/core-better-auth.module.ts +86 -9
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core.module.ts +120 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
3
|
+
|
|
4
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
5
|
+
import { ErrorCode } from '../error-code';
|
|
6
|
+
import { BetterAuthTokenService } from './better-auth-token.service';
|
|
7
|
+
import { BetterAuthenticatedUser } from './better-auth.types';
|
|
8
|
+
import { CoreBetterAuthModule } from './core-better-auth.module';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* BetterAuth Roles Guard
|
|
12
|
+
*
|
|
13
|
+
* A simplified roles guard for BetterAuth (IAM-only) mode that does NOT extend AuthGuard.
|
|
14
|
+
* This avoids the mixin inheritance DI issues that occur with the standard RolesGuard.
|
|
15
|
+
*
|
|
16
|
+
* In IAM-only mode, authentication is handled by CoreBetterAuthMiddleware which:
|
|
17
|
+
* 1. Validates JWT tokens or session tokens
|
|
18
|
+
* 2. Sets req.user with the authenticated user (with _authenticatedViaBetterAuth flag)
|
|
19
|
+
*
|
|
20
|
+
* If the middleware hasn't set req.user (e.g., in test environments), this guard will
|
|
21
|
+
* try to verify the token directly using BetterAuthTokenService.
|
|
22
|
+
*
|
|
23
|
+
* No Passport integration is needed because BetterAuth handles all token validation.
|
|
24
|
+
*
|
|
25
|
+
* IMPORTANT: This guard has NO constructor dependencies. This is intentional because
|
|
26
|
+
* NestJS DI has issues resolving Reflector/ModuleRef for APP_GUARD providers in dynamic modules.
|
|
27
|
+
* Instead, we use Reflect.getMetadata directly to read decorator metadata, and access
|
|
28
|
+
* BetterAuthTokenService via CoreBetterAuthModule static reference.
|
|
29
|
+
*/
|
|
30
|
+
@Injectable()
|
|
31
|
+
export class BetterAuthRolesGuard implements CanActivate {
|
|
32
|
+
private readonly logger = new Logger(BetterAuthRolesGuard.name);
|
|
33
|
+
private tokenService: BetterAuthTokenService | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get BetterAuthTokenService lazily from CoreBetterAuthModule
|
|
37
|
+
* This avoids DI issues while still allowing token verification
|
|
38
|
+
*/
|
|
39
|
+
private getTokenService(): BetterAuthTokenService | null {
|
|
40
|
+
if (!this.tokenService) {
|
|
41
|
+
this.tokenService = CoreBetterAuthModule.getTokenServiceInstance();
|
|
42
|
+
}
|
|
43
|
+
return this.tokenService;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Try to verify a BetterAuth token if user isn't already on the request.
|
|
48
|
+
* This handles cases where middleware didn't run (e.g., test environments).
|
|
49
|
+
*/
|
|
50
|
+
private async verifyToken(request: any): Promise<BetterAuthenticatedUser | null> {
|
|
51
|
+
const tokenService = this.getTokenService();
|
|
52
|
+
if (!tokenService) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const { token } = tokenService.extractTokenFromRequest(request);
|
|
58
|
+
if (!token) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return await tokenService.verifyAndLoadUser(token);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this.logger.debug(`Token verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
69
|
+
// Get roles from decorator metadata using Reflect.getMetadata directly
|
|
70
|
+
// This avoids the need for NestJS Reflector which causes DI issues in dynamic modules
|
|
71
|
+
const handlerRoles = Reflect.getMetadata('roles', context.getHandler()) as string[] | undefined;
|
|
72
|
+
const classRoles = Reflect.getMetadata('roles', context.getClass()) as string[] | undefined;
|
|
73
|
+
|
|
74
|
+
// Combine handler and class roles (handler takes precedence, like Reflector.getAll)
|
|
75
|
+
const reflectorRoles: (string[] | undefined)[] = [handlerRoles, classRoles];
|
|
76
|
+
const roles: string[] = reflectorRoles[0]
|
|
77
|
+
? reflectorRoles[1]
|
|
78
|
+
? [...reflectorRoles[0], ...reflectorRoles[1]]
|
|
79
|
+
: reflectorRoles[0]
|
|
80
|
+
: reflectorRoles[1];
|
|
81
|
+
|
|
82
|
+
// Check if locked - always deny
|
|
83
|
+
if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
|
|
84
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// If no roles required, or S_EVERYONE is set, allow access without authentication
|
|
88
|
+
if (!roles || !roles.some((value) => !!value) || roles.includes(RoleEnum.S_EVERYONE)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Get request and check for user (set by BetterAuth middleware)
|
|
93
|
+
const request = this.getRequest(context);
|
|
94
|
+
let user = request?.user;
|
|
95
|
+
|
|
96
|
+
// If user isn't set (e.g., middleware didn't run in test environment),
|
|
97
|
+
// try to verify the token directly
|
|
98
|
+
if (!user) {
|
|
99
|
+
user = await this.verifyToken(request);
|
|
100
|
+
if (user && request) {
|
|
101
|
+
// Store the verified user on the request for downstream handlers
|
|
102
|
+
request.user = user;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if user is authenticated
|
|
107
|
+
if (!user) {
|
|
108
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check S_USER role - any authenticated user is allowed
|
|
112
|
+
if (roles.includes(RoleEnum.S_USER)) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check S_SELF role - user is accessing their own data
|
|
117
|
+
if (roles.includes(RoleEnum.S_SELF)) {
|
|
118
|
+
// Get the target object's ID from params or args
|
|
119
|
+
const targetId = this.getTargetId(context);
|
|
120
|
+
if (targetId && user.id === targetId) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check S_CREATOR role - user created the object
|
|
126
|
+
if (roles.includes(RoleEnum.S_CREATOR)) {
|
|
127
|
+
// This requires the object to have a createdBy field
|
|
128
|
+
// Usually checked in services, but we can't access the object here
|
|
129
|
+
// Let it pass and check in the service/resolver
|
|
130
|
+
this.logger.debug('S_CREATOR check deferred to service layer');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check S_VERIFIED role - user's email is verified
|
|
134
|
+
if (roles.includes(RoleEnum.S_VERIFIED)) {
|
|
135
|
+
if (!user.verified && !user.verifiedAt && !user.emailVerified) {
|
|
136
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check if user has required role
|
|
142
|
+
if (user.hasRole?.(roles)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check if user's roles array includes any of the required roles
|
|
147
|
+
if (user.roles && Array.isArray(user.roles)) {
|
|
148
|
+
const hasRequiredRole = roles.some((role) => user.roles.includes(role));
|
|
149
|
+
if (hasRequiredRole) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// User doesn't have required role
|
|
155
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get request from execution context
|
|
160
|
+
* Handles both GraphQL and HTTP contexts
|
|
161
|
+
*/
|
|
162
|
+
private getRequest(context: ExecutionContext): any {
|
|
163
|
+
// Try GraphQL context first
|
|
164
|
+
try {
|
|
165
|
+
const gqlContext = GqlExecutionContext.create(context);
|
|
166
|
+
const ctx = gqlContext.getContext();
|
|
167
|
+
if (ctx?.req) {
|
|
168
|
+
return ctx.req;
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// GraphQL context not available
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Fallback to HTTP context
|
|
175
|
+
return context.switchToHttp().getRequest();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get target ID from context for S_SELF checks
|
|
180
|
+
*/
|
|
181
|
+
private getTargetId(context: ExecutionContext): null | string {
|
|
182
|
+
// Try GraphQL args
|
|
183
|
+
try {
|
|
184
|
+
const gqlContext = GqlExecutionContext.create(context);
|
|
185
|
+
const args = gqlContext.getArgs();
|
|
186
|
+
if (args?.id) {
|
|
187
|
+
return args.id;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// GraphQL context not available
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Try HTTP params
|
|
194
|
+
try {
|
|
195
|
+
const request = context.switchToHttp().getRequest();
|
|
196
|
+
if (request?.params?.id) {
|
|
197
|
+
return request.params.id;
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// HTTP context not available
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -14,6 +14,21 @@ import { IBetterAuth } from '../../common/interfaces/server-options.interface';
|
|
|
14
14
|
*/
|
|
15
15
|
export type BetterAuthInstance = ReturnType<typeof betterAuth>;
|
|
16
16
|
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Performance-optimized password hashing using Node.js native crypto.scrypt
|
|
19
|
+
//
|
|
20
|
+
// Better-Auth's default uses @noble/hashes scrypt which runs on the main
|
|
21
|
+
// event loop. Under concurrent load this blocks all requests while hashing.
|
|
22
|
+
// Node.js crypto.scrypt() offloads the work to the libuv thread pool,
|
|
23
|
+
// allowing the event loop to remain responsive.
|
|
24
|
+
//
|
|
25
|
+
// Parameters match Better-Auth's defaults exactly:
|
|
26
|
+
// N=16384, r=16, p=1, dkLen=64, 16-byte salt
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const SCRYPT_PARAMS = { maxmem: 128 * 16384 * 16 * 2, N: 16384, p: 1, r: 16 };
|
|
30
|
+
const SCRYPT_KEY_LENGTH = 64;
|
|
31
|
+
|
|
17
32
|
/**
|
|
18
33
|
* Generates a cryptographically secure random secret.
|
|
19
34
|
* Used as fallback when no BETTER_AUTH_SECRET is configured.
|
|
@@ -27,6 +42,38 @@ function generateSecureSecret(): string {
|
|
|
27
42
|
return crypto.randomBytes(32).toString('base64');
|
|
28
43
|
}
|
|
29
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Hash a password using Node.js native crypto.scrypt (libuv thread pool).
|
|
47
|
+
* Output format matches Better-Auth: "salt:hash" (both hex encoded).
|
|
48
|
+
*/
|
|
49
|
+
async function nativeScryptHash(password: string): Promise<string> {
|
|
50
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
51
|
+
const normalized = password.normalize('NFKC');
|
|
52
|
+
const key = await new Promise<Buffer>((resolve, reject) => {
|
|
53
|
+
crypto.scrypt(normalized, salt, SCRYPT_KEY_LENGTH, SCRYPT_PARAMS, (err, derivedKey) => {
|
|
54
|
+
if (err) reject(err);
|
|
55
|
+
else resolve(derivedKey);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
return `${salt}:${key.toString('hex')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Verify a password against a Better-Auth scrypt hash using Node.js native crypto.scrypt.
|
|
63
|
+
*/
|
|
64
|
+
async function nativeScryptVerify(data: { hash: string; password: string }): Promise<boolean> {
|
|
65
|
+
const [salt, storedKey] = data.hash.split(':');
|
|
66
|
+
if (!salt || !storedKey) return false;
|
|
67
|
+
const normalized = data.password.normalize('NFKC');
|
|
68
|
+
const key = await new Promise<Buffer>((resolve, reject) => {
|
|
69
|
+
crypto.scrypt(normalized, salt, SCRYPT_KEY_LENGTH, SCRYPT_PARAMS, (err, derivedKey) => {
|
|
70
|
+
if (err) reject(err);
|
|
71
|
+
else resolve(derivedKey);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
return crypto.timingSafeEqual(key, Buffer.from(storedKey, 'hex'));
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
/**
|
|
31
78
|
* Cached auto-generated secret for the current server instance.
|
|
32
79
|
* Generated once at a module load to ensure consistency within a single run.
|
|
@@ -268,6 +315,10 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
|
|
|
268
315
|
// Can be disabled by setting config.emailAndPassword.enabled = false
|
|
269
316
|
emailAndPassword: {
|
|
270
317
|
enabled: config.emailAndPassword?.enabled !== false,
|
|
318
|
+
password: {
|
|
319
|
+
hash: nativeScryptHash,
|
|
320
|
+
verify: nativeScryptVerify,
|
|
321
|
+
},
|
|
271
322
|
},
|
|
272
323
|
plugins,
|
|
273
324
|
secret: validation.resolvedSecret || config.secret,
|
|
@@ -332,6 +332,12 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
|
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// If Better Auth returned 404, the path is not handled by Better Auth.
|
|
336
|
+
// Call next() to let NestJS controllers handle it (e.g., custom controller endpoints).
|
|
337
|
+
if (response.status === 404) {
|
|
338
|
+
return next();
|
|
339
|
+
}
|
|
340
|
+
|
|
335
341
|
// Convert Web Standard Response to Express response using shared helper
|
|
336
342
|
await sendWebResponse(res, response);
|
|
337
343
|
} catch (error) {
|
|
@@ -409,11 +409,27 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
409
409
|
return elapsed < cooldown;
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Maximum entries in the lastSendTimes map to prevent unbounded growth.
|
|
414
|
+
* At 10,000 entries with email strings as keys, this uses ~1-2 MB max.
|
|
415
|
+
*/
|
|
416
|
+
private static readonly MAX_SEND_TIMES_ENTRIES = 10000;
|
|
417
|
+
|
|
412
418
|
/**
|
|
413
419
|
* Track that a verification email was sent to this address
|
|
414
420
|
*/
|
|
415
421
|
protected trackSend(email: string): void {
|
|
416
422
|
const key = email.toLowerCase();
|
|
423
|
+
|
|
424
|
+
// Evict oldest entry if map is at capacity (before adding new one)
|
|
425
|
+
if (!this.lastSendTimes.has(key) && this.lastSendTimes.size >= CoreBetterAuthEmailVerificationService.MAX_SEND_TIMES_ENTRIES) {
|
|
426
|
+
// Map preserves insertion order - first key is the oldest
|
|
427
|
+
const oldestKey = this.lastSendTimes.keys().next().value;
|
|
428
|
+
if (oldestKey) {
|
|
429
|
+
this.lastSendTimes.delete(oldestKey);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
417
433
|
this.lastSendTimes.set(key, Date.now());
|
|
418
434
|
|
|
419
435
|
// Schedule cleanup to prevent memory leak
|
|
@@ -46,6 +46,7 @@ interface RateLimitEntry {
|
|
|
46
46
|
const DEFAULT_CONFIG: Required<IBetterAuthRateLimit> = {
|
|
47
47
|
enabled: false,
|
|
48
48
|
max: 10,
|
|
49
|
+
maxEntries: 10000,
|
|
49
50
|
message: 'Too many requests, please try again later.',
|
|
50
51
|
skipEndpoints: ['/session', '/callback'],
|
|
51
52
|
strictEndpoints: ['/sign-in', '/sign-up', '/forgot-password', '/reset-password'],
|
|
@@ -143,6 +144,11 @@ export class CoreBetterAuthRateLimiter {
|
|
|
143
144
|
let entry = this.store.get(key);
|
|
144
145
|
|
|
145
146
|
if (!entry || now >= entry.resetTime) {
|
|
147
|
+
// Evict oldest entries if store exceeds maxEntries
|
|
148
|
+
if (!entry && this.store.size >= this.config.maxEntries) {
|
|
149
|
+
this.evictOldest();
|
|
150
|
+
}
|
|
151
|
+
|
|
146
152
|
// Create new entry or reset expired one
|
|
147
153
|
entry = {
|
|
148
154
|
count: 1,
|
|
@@ -234,6 +240,40 @@ export class CoreBetterAuthRateLimiter {
|
|
|
234
240
|
}
|
|
235
241
|
}
|
|
236
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Evict the oldest entries when the store exceeds maxEntries.
|
|
245
|
+
* First removes all expired entries, then removes entries closest to expiry
|
|
246
|
+
* until the store is at 90% capacity.
|
|
247
|
+
*/
|
|
248
|
+
private evictOldest(): void {
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
let evicted = 0;
|
|
251
|
+
|
|
252
|
+
// First pass: remove all expired entries
|
|
253
|
+
for (const [key, entry] of this.store.entries()) {
|
|
254
|
+
if (now >= entry.resetTime) {
|
|
255
|
+
this.store.delete(key);
|
|
256
|
+
evicted++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// If still over limit, remove entries with earliest resetTime (oldest)
|
|
261
|
+
if (this.store.size >= this.config.maxEntries) {
|
|
262
|
+
const targetSize = Math.floor(this.config.maxEntries * 0.9);
|
|
263
|
+
const entries = [...this.store.entries()].sort((a, b) => a[1].resetTime - b[1].resetTime);
|
|
264
|
+
|
|
265
|
+
for (const [key] of entries) {
|
|
266
|
+
if (this.store.size <= targetSize) break;
|
|
267
|
+
this.store.delete(key);
|
|
268
|
+
evicted++;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (evicted > 0) {
|
|
273
|
+
this.logger.warn(`Evicted ${evicted} rate limit entries (store was at capacity: ${this.config.maxEntries})`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
237
277
|
/**
|
|
238
278
|
* Determine if an endpoint should skip rate limiting
|
|
239
279
|
*/
|
|
@@ -370,27 +370,38 @@ export class CoreBetterAuthUserMapper {
|
|
|
370
370
|
return false;
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
// Better-Auth stores account.userId as ObjectId that references users._id
|
|
374
|
+
const userMongoId = legacyUser._id as ObjectId;
|
|
375
|
+
|
|
376
|
+
// FAST PATH: Check if credential account already exists BEFORE expensive bcrypt
|
|
377
|
+
// For already-migrated users this avoids ~130ms of bcrypt.compare() per sign-in
|
|
378
|
+
const existingAccount = await accountsCollection.findOne({
|
|
379
|
+
providerId: 'credential',
|
|
380
|
+
userId: userMongoId,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (existingAccount) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// No password provided - cannot verify, cannot migrate
|
|
388
|
+
if (!plainPassword) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
373
392
|
// IMPORTANT: Verify the provided password matches the legacy hash
|
|
374
393
|
// This prevents migration with a wrong password
|
|
375
394
|
// Legacy Auth uses two formats for backwards compatibility:
|
|
376
395
|
// 1. bcrypt(password) - direct hash
|
|
377
396
|
// 2. bcrypt(sha256(password)) - sha256 then bcrypt
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
this.logger.warn(`Migration password verification failed for ${maskEmail(userEmail)}`);
|
|
384
|
-
return false;
|
|
385
|
-
}
|
|
386
|
-
} else {
|
|
387
|
-
// No password provided - cannot verify, cannot migrate
|
|
397
|
+
const directMatch = await bcrypt.compare(plainPassword, legacyUser.password);
|
|
398
|
+
const sha256Match = !directMatch ? await bcrypt.compare(sha256(plainPassword), legacyUser.password) : false;
|
|
399
|
+
if (!directMatch && !sha256Match) {
|
|
400
|
+
// Security: Wrong password provided for migration - reject
|
|
401
|
+
this.logger.warn(`Migration password verification failed for ${maskEmail(userEmail)}`);
|
|
388
402
|
return false;
|
|
389
403
|
}
|
|
390
404
|
|
|
391
|
-
// Better-Auth stores account.userId as ObjectId that references users._id
|
|
392
|
-
// The id field is a secondary string identifier used in API responses
|
|
393
|
-
const userMongoId = legacyUser._id as ObjectId;
|
|
394
405
|
const userIdHex = userMongoId.toHexString();
|
|
395
406
|
|
|
396
407
|
// Update user with Better-Auth fields if not already present
|
|
@@ -413,17 +424,6 @@ export class CoreBetterAuthUserMapper {
|
|
|
413
424
|
);
|
|
414
425
|
}
|
|
415
426
|
|
|
416
|
-
// Check if credential account already exists
|
|
417
|
-
// Better-Auth stores userId as ObjectId referencing users._id
|
|
418
|
-
const existingAccount = await accountsCollection.findOne({
|
|
419
|
-
providerId: 'credential',
|
|
420
|
-
userId: userMongoId,
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
if (existingAccount) {
|
|
424
|
-
return true;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
427
|
// Create the credential account with Better-Auth compatible scrypt hash
|
|
428
428
|
const passwordHash = await this.hashPasswordForBetterAuth(plainPassword);
|
|
429
429
|
|
|
@@ -17,7 +17,7 @@ import { IBetterAuth } from '../../common/interfaces/server-options.interface';
|
|
|
17
17
|
import { BrevoService } from '../../common/services/brevo.service';
|
|
18
18
|
import { ConfigService } from '../../common/services/config.service';
|
|
19
19
|
import { RolesGuardRegistry } from '../auth/guards/roles-guard-registry';
|
|
20
|
-
import {
|
|
20
|
+
import { BetterAuthRolesGuard } from './better-auth-roles.guard';
|
|
21
21
|
import { BetterAuthTokenService } from './better-auth-token.service';
|
|
22
22
|
import { BetterAuthInstance, createBetterAuthInstance } from './better-auth.config';
|
|
23
23
|
import { DefaultBetterAuthResolver } from './better-auth.resolver';
|
|
@@ -228,6 +228,13 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
228
228
|
// Static reference to email verification service for Better-Auth hooks (outside DI context)
|
|
229
229
|
private static emailVerificationService: CoreBetterAuthEmailVerificationService | null = null;
|
|
230
230
|
private static mongoConnection: Connection | null = null;
|
|
231
|
+
// Safety Net: Track if forRoot() has already been called to detect duplicate registration
|
|
232
|
+
private static forRootCalled = false;
|
|
233
|
+
private static cachedDynamicModule: DynamicModule | null = null;
|
|
234
|
+
// Static references for lazy GraphQL driver (autoRegister: false) and BetterAuthRolesGuard
|
|
235
|
+
private static serviceInstance: CoreBetterAuthService | null = null;
|
|
236
|
+
private static userMapperInstance: CoreBetterAuthUserMapper | null = null;
|
|
237
|
+
private static tokenServiceInstance: BetterAuthTokenService | null = null;
|
|
231
238
|
|
|
232
239
|
/**
|
|
233
240
|
* Gets the controller class to use (custom or default)
|
|
@@ -243,9 +250,38 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
243
250
|
return this.customResolver || DefaultBetterAuthResolver;
|
|
244
251
|
}
|
|
245
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Gets the static CoreBetterAuthService instance.
|
|
255
|
+
* Used by the lazy GraphQL driver when autoRegister: false.
|
|
256
|
+
* Safe because onConnect is called only after module initialization.
|
|
257
|
+
*/
|
|
258
|
+
static getServiceInstance(): CoreBetterAuthService | null {
|
|
259
|
+
return this.serviceInstance;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Gets the static CoreBetterAuthUserMapper instance.
|
|
264
|
+
* Used by the lazy GraphQL driver when autoRegister: false.
|
|
265
|
+
* Safe because onConnect is called only after module initialization.
|
|
266
|
+
*/
|
|
267
|
+
static getUserMapperInstance(): CoreBetterAuthUserMapper | null {
|
|
268
|
+
return this.userMapperInstance;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Gets the static BetterAuthTokenService instance.
|
|
273
|
+
* Used by BetterAuthRolesGuard for token verification when user isn't already on request.
|
|
274
|
+
* Safe because guards are invoked only after module initialization.
|
|
275
|
+
*/
|
|
276
|
+
static getTokenServiceInstance(): BetterAuthTokenService | null {
|
|
277
|
+
return this.tokenServiceInstance;
|
|
278
|
+
}
|
|
279
|
+
|
|
246
280
|
constructor(
|
|
247
281
|
@Optional() private readonly betterAuthService?: CoreBetterAuthService,
|
|
248
282
|
@Optional() private readonly rateLimiter?: CoreBetterAuthRateLimiter,
|
|
283
|
+
@Optional() private readonly userMapper?: CoreBetterAuthUserMapper,
|
|
284
|
+
@Optional() private readonly tokenService?: BetterAuthTokenService,
|
|
249
285
|
) {}
|
|
250
286
|
|
|
251
287
|
onModuleInit() {
|
|
@@ -254,6 +290,17 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
254
290
|
CoreBetterAuthModule.logger.log('CoreBetterAuthModule ready');
|
|
255
291
|
}
|
|
256
292
|
|
|
293
|
+
// Store static references for lazy GraphQL driver (autoRegister: false) and BetterAuthRolesGuard
|
|
294
|
+
if (this.betterAuthService) {
|
|
295
|
+
CoreBetterAuthModule.serviceInstance = this.betterAuthService;
|
|
296
|
+
}
|
|
297
|
+
if (this.userMapper) {
|
|
298
|
+
CoreBetterAuthModule.userMapperInstance = this.userMapper;
|
|
299
|
+
}
|
|
300
|
+
if (this.tokenService) {
|
|
301
|
+
CoreBetterAuthModule.tokenServiceInstance = this.tokenService;
|
|
302
|
+
}
|
|
303
|
+
|
|
257
304
|
// Configure rate limiter with stored config
|
|
258
305
|
if (this.rateLimiter && CoreBetterAuthModule.currentConfig?.rateLimit) {
|
|
259
306
|
this.rateLimiter.configure(CoreBetterAuthModule.currentConfig.rateLimit);
|
|
@@ -368,6 +415,24 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
368
415
|
* @returns Dynamic module configuration
|
|
369
416
|
*/
|
|
370
417
|
static forRoot(options: CoreBetterAuthModuleOptions): DynamicModule {
|
|
418
|
+
// Safety Net: Detect duplicate forRoot() calls
|
|
419
|
+
// NestJS deduplicates DynamicModules by module class — the FIRST import wins,
|
|
420
|
+
// subsequent imports are silently ignored. This means custom controller/resolver
|
|
421
|
+
// from the second call would be lost. Return cached module with a warning.
|
|
422
|
+
if (this.forRootCalled && !process.env.VITEST) {
|
|
423
|
+
this.logger.warn(
|
|
424
|
+
'CoreBetterAuthModule.forRoot() was called more than once. ' +
|
|
425
|
+
'The second call is ignored by NestJS (DynamicModule deduplication). ' +
|
|
426
|
+
'Custom controller/resolver from the second call will NOT be registered. ' +
|
|
427
|
+
'Solutions: (1) Use betterAuth.controller/resolver in config, or ' +
|
|
428
|
+
'(2) Set betterAuth.autoRegister: false and import your module separately.',
|
|
429
|
+
);
|
|
430
|
+
if (this.cachedDynamicModule) {
|
|
431
|
+
return this.cachedDynamicModule;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
this.forRootCalled = true;
|
|
435
|
+
|
|
371
436
|
const {
|
|
372
437
|
config: rawConfig,
|
|
373
438
|
controller,
|
|
@@ -423,7 +488,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
423
488
|
if (config === null || config?.enabled === false) {
|
|
424
489
|
this.logger.debug('BetterAuth is disabled - skipping initialization');
|
|
425
490
|
this.betterAuthEnabled = false;
|
|
426
|
-
|
|
491
|
+
this.cachedDynamicModule = {
|
|
427
492
|
exports: [BETTER_AUTH_INSTANCE, CoreBetterAuthService, CoreBetterAuthUserMapper, CoreBetterAuthRateLimiter, BetterAuthTokenService, CoreBetterAuthChallengeService],
|
|
428
493
|
module: CoreBetterAuthModule,
|
|
429
494
|
providers: [
|
|
@@ -442,6 +507,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
442
507
|
// because they require ConfigService and have no purpose when BetterAuth is disabled
|
|
443
508
|
],
|
|
444
509
|
};
|
|
510
|
+
return this.cachedDynamicModule;
|
|
445
511
|
}
|
|
446
512
|
|
|
447
513
|
// Enable middleware registration
|
|
@@ -453,12 +519,13 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
453
519
|
// Always use deferred initialization to ensure MongoDB is ready
|
|
454
520
|
// This prevents timing issues during application startup
|
|
455
521
|
// Pass server-level URLs for Passkey auto-detection (using effective values from ConfigService fallback)
|
|
456
|
-
|
|
522
|
+
this.cachedDynamicModule = this.createDeferredModule(config, {
|
|
457
523
|
fallbackSecrets: effectiveFallbackSecrets,
|
|
458
524
|
serverAppUrl: effectiveServerAppUrl,
|
|
459
525
|
serverBaseUrl: effectiveServerBaseUrl,
|
|
460
526
|
serverEnv: effectiveServerEnv,
|
|
461
527
|
});
|
|
528
|
+
return this.cachedDynamicModule;
|
|
462
529
|
}
|
|
463
530
|
|
|
464
531
|
/**
|
|
@@ -622,6 +689,13 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
622
689
|
this.shouldRegisterRolesGuardGlobally = false;
|
|
623
690
|
this.rolesGuardExplicitlyDisabled = false;
|
|
624
691
|
this.emailVerificationService = null;
|
|
692
|
+
this.mongoConnection = null;
|
|
693
|
+
// Safety Net: Reset duplicate-call detection
|
|
694
|
+
this.forRootCalled = false;
|
|
695
|
+
this.cachedDynamicModule = null;
|
|
696
|
+
// Lazy GraphQL driver: Reset service references
|
|
697
|
+
this.serviceInstance = null;
|
|
698
|
+
this.userMapperInstance = null;
|
|
625
699
|
// Reset shared RolesGuard registry (shared with CoreAuthModule)
|
|
626
700
|
RolesGuardRegistry.reset();
|
|
627
701
|
}
|
|
@@ -822,17 +896,20 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
822
896
|
},
|
|
823
897
|
CoreBetterAuthChallengeService,
|
|
824
898
|
this.getResolverClass(),
|
|
825
|
-
// Conditionally register
|
|
826
|
-
// In Legacy mode, RolesGuard is
|
|
899
|
+
// Conditionally register BetterAuthRolesGuard globally for IAM-only setups
|
|
900
|
+
// In Legacy mode, RolesGuard is registered globally via CoreAuthModule instead
|
|
827
901
|
// Uses shared RolesGuardRegistry to prevent duplicate registration across modules
|
|
902
|
+
//
|
|
903
|
+
// Note: We use BetterAuthRolesGuard (not RolesGuard) in IAM-only mode because:
|
|
904
|
+
// - RolesGuard extends AuthGuard (a Passport mixin) which has DI metadata conflicts
|
|
905
|
+
// - BetterAuthRolesGuard is a simpler guard that only checks roles
|
|
906
|
+
// - Authentication is handled by CoreBetterAuthMiddleware (JWT/session validation)
|
|
828
907
|
...(this.shouldRegisterRolesGuardGlobally && !RolesGuardRegistry.isRegistered()
|
|
829
908
|
? (() => {
|
|
830
909
|
RolesGuardRegistry.markRegistered('CoreBetterAuthModule');
|
|
831
910
|
return [
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
useClass: RolesGuard,
|
|
835
|
-
},
|
|
911
|
+
BetterAuthRolesGuard,
|
|
912
|
+
{ provide: APP_GUARD, useExisting: BetterAuthRolesGuard },
|
|
836
913
|
];
|
|
837
914
|
})()
|
|
838
915
|
: []),
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* - DefaultBetterAuthResolver: Default resolver implementation (use as fallback)
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
export * from './better-auth-roles.guard';
|
|
25
26
|
export * from './better-auth-token.service';
|
|
26
27
|
export * from './better-auth.config';
|
|
27
28
|
export * from './better-auth.resolver';
|