@lenne.tech/nest-server 11.11.1 → 11.13.0

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 (113) hide show
  1. package/dist/config.env.js +1 -0
  2. package/dist/config.env.js.map +1 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +16 -0
  4. package/dist/core/modules/auth/core-auth.controller.js +1 -1
  5. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  6. package/dist/core/modules/auth/core-auth.resolver.js +1 -1
  7. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  8. package/dist/core/modules/better-auth/better-auth-token.service.js +1 -4
  9. package/dist/core/modules/better-auth/better-auth-token.service.js.map +1 -1
  10. package/dist/core/modules/better-auth/better-auth.config.d.ts +13 -0
  11. package/dist/core/modules/better-auth/better-auth.config.js +114 -17
  12. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  13. package/dist/core/modules/better-auth/better-auth.resolver.d.ts +7 -3
  14. package/dist/core/modules/better-auth/better-auth.resolver.js +16 -6
  15. package/dist/core/modules/better-auth/better-auth.resolver.js.map +1 -1
  16. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +4 -2
  17. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +63 -18
  18. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth-auth.model.d.ts +1 -0
  20. package/dist/core/modules/better-auth/core-better-auth-auth.model.js +7 -0
  21. package/dist/core/modules/better-auth/core-better-auth-auth.model.js.map +1 -1
  22. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.d.ts +41 -0
  23. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +107 -0
  24. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -0
  25. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +48 -0
  26. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +241 -0
  27. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -0
  28. package/dist/core/modules/better-auth/core-better-auth-models.d.ts +2 -1
  29. package/dist/core/modules/better-auth/core-better-auth-models.js +8 -4
  30. package/dist/core/modules/better-auth/core-better-auth-models.js.map +1 -1
  31. package/dist/core/modules/better-auth/core-better-auth-signup-validator.service.d.ts +18 -0
  32. package/dist/core/modules/better-auth/core-better-auth-signup-validator.service.js +82 -0
  33. package/dist/core/modules/better-auth/core-better-auth-signup-validator.service.js.map +1 -0
  34. package/dist/core/modules/better-auth/core-better-auth-token.helper.d.ts +16 -0
  35. package/dist/core/modules/better-auth/core-better-auth-token.helper.js +66 -0
  36. package/dist/core/modules/better-auth/core-better-auth-token.helper.js.map +1 -0
  37. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +0 -1
  38. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +15 -8
  39. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  40. package/dist/core/modules/better-auth/core-better-auth-web.helper.d.ts +3 -3
  41. package/dist/core/modules/better-auth/core-better-auth-web.helper.js +64 -44
  42. package/dist/core/modules/better-auth/core-better-auth-web.helper.js.map +1 -1
  43. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +13 -1
  44. package/dist/core/modules/better-auth/core-better-auth.controller.js +108 -49
  45. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  46. package/dist/core/modules/better-auth/core-better-auth.middleware.d.ts +0 -1
  47. package/dist/core/modules/better-auth/core-better-auth.middleware.js +57 -39
  48. package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
  49. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +6 -0
  50. package/dist/core/modules/better-auth/core-better-auth.module.js +129 -24
  51. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  52. package/dist/core/modules/better-auth/core-better-auth.resolver.d.ts +12 -5
  53. package/dist/core/modules/better-auth/core-better-auth.resolver.js +64 -17
  54. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  55. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +4 -1
  56. package/dist/core/modules/better-auth/core-better-auth.service.js +143 -23
  57. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  58. package/dist/core/modules/better-auth/index.d.ts +4 -0
  59. package/dist/core/modules/better-auth/index.js +4 -0
  60. package/dist/core/modules/better-auth/index.js.map +1 -1
  61. package/dist/core/modules/error-code/error-codes.d.ts +45 -0
  62. package/dist/core/modules/error-code/error-codes.js +40 -0
  63. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  64. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  65. package/dist/core/modules/user/core-user.model.js +11 -0
  66. package/dist/core/modules/user/core-user.model.js.map +1 -1
  67. package/dist/server/modules/better-auth/better-auth.controller.d.ts +3 -1
  68. package/dist/server/modules/better-auth/better-auth.controller.js +12 -3
  69. package/dist/server/modules/better-auth/better-auth.controller.js.map +1 -1
  70. package/dist/server/modules/better-auth/better-auth.resolver.d.ts +7 -3
  71. package/dist/server/modules/better-auth/better-auth.resolver.js +16 -6
  72. package/dist/server/modules/better-auth/better-auth.resolver.js.map +1 -1
  73. package/dist/server/modules/error-code/error-codes.d.ts +5 -0
  74. package/dist/server/modules/user/user.model.d.ts +5 -0
  75. package/dist/templates/email-verification-de.ejs +78 -0
  76. package/dist/templates/email-verification-en.ejs +78 -0
  77. package/dist/test/test.helper.d.ts +4 -0
  78. package/dist/test/test.helper.js +54 -1
  79. package/dist/test/test.helper.js.map +1 -1
  80. package/dist/tsconfig.build.tsbuildinfo +1 -1
  81. package/package.json +10 -10
  82. package/src/config.env.ts +2 -0
  83. package/src/core/common/interfaces/server-options.interface.ts +240 -0
  84. package/src/core/modules/auth/core-auth.controller.ts +2 -2
  85. package/src/core/modules/auth/core-auth.resolver.ts +2 -2
  86. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +113 -0
  87. package/src/core/modules/better-auth/README.md +72 -7
  88. package/src/core/modules/better-auth/better-auth-token.service.ts +5 -8
  89. package/src/core/modules/better-auth/better-auth.config.ts +282 -29
  90. package/src/core/modules/better-auth/better-auth.resolver.ts +16 -5
  91. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +100 -22
  92. package/src/core/modules/better-auth/core-better-auth-auth.model.ts +10 -0
  93. package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +323 -0
  94. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +433 -0
  95. package/src/core/modules/better-auth/core-better-auth-models.ts +6 -3
  96. package/src/core/modules/better-auth/core-better-auth-signup-validator.service.ts +178 -0
  97. package/src/core/modules/better-auth/core-better-auth-token.helper.ts +200 -0
  98. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +18 -14
  99. package/src/core/modules/better-auth/core-better-auth-web.helper.ts +119 -69
  100. package/src/core/modules/better-auth/core-better-auth.controller.ts +197 -84
  101. package/src/core/modules/better-auth/core-better-auth.middleware.ts +93 -64
  102. package/src/core/modules/better-auth/core-better-auth.module.ts +215 -38
  103. package/src/core/modules/better-auth/core-better-auth.resolver.ts +140 -20
  104. package/src/core/modules/better-auth/core-better-auth.service.ts +210 -32
  105. package/src/core/modules/better-auth/index.ts +4 -0
  106. package/src/core/modules/error-code/error-codes.ts +45 -0
  107. package/src/core/modules/user/core-user.model.ts +15 -0
  108. package/src/server/modules/better-auth/better-auth.controller.ts +6 -2
  109. package/src/server/modules/better-auth/better-auth.resolver.ts +16 -5
  110. package/src/templates/email-verification-de.ejs +78 -0
  111. package/src/templates/email-verification-en.ejs +78 -0
  112. package/src/test/README.md +190 -0
  113. package/src/test/test.helper.ts +82 -1
@@ -1,4 +1,4 @@
1
- import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
1
+ import { BadRequestException, Logger, Optional, UnauthorizedException } from '@nestjs/common';
2
2
  import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
3
3
  import { Request, Response } from 'express';
4
4
 
@@ -15,6 +15,7 @@ import {
15
15
  requires2FA,
16
16
  } from './better-auth.types';
17
17
  import { CoreBetterAuthAuthModel } from './core-better-auth-auth.model';
18
+ import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
18
19
  import { CoreBetterAuthMigrationStatusModel } from './core-better-auth-migration-status.model';
19
20
  import {
20
21
  CoreBetterAuth2FASetupModel,
@@ -24,7 +25,9 @@ import {
24
25
  CoreBetterAuthSessionModel,
25
26
  CoreBetterAuthUserModel,
26
27
  } from './core-better-auth-models';
28
+ import { CoreBetterAuthSignUpValidatorService } from './core-better-auth-signup-validator.service';
27
29
  import { BetterAuthSessionUser, CoreBetterAuthUserMapper, MappedUser } from './core-better-auth-user.mapper';
30
+ import { convertExpressHeaders } from './core-better-auth-web.helper';
28
31
  import { CoreBetterAuthService } from './core-better-auth.service';
29
32
 
30
33
  /**
@@ -69,8 +72,72 @@ export class CoreBetterAuthResolver {
69
72
  constructor(
70
73
  protected readonly betterAuthService: CoreBetterAuthService,
71
74
  protected readonly userMapper: CoreBetterAuthUserMapper,
75
+ @Optional() protected readonly signUpValidator?: CoreBetterAuthSignUpValidatorService,
76
+ @Optional() protected readonly emailVerificationService?: CoreBetterAuthEmailVerificationService,
72
77
  ) {}
73
78
 
79
+ // ===========================================================================
80
+ // Token Resolution
81
+ // ===========================================================================
82
+
83
+ /**
84
+ * Resolves a session token to a JWT when cookies are disabled and JWT is enabled.
85
+ * Delegates to CoreBetterAuthService.resolveJwtToken().
86
+ *
87
+ * @param token - The token from BetterAuth response (may be session token or JWT)
88
+ * @returns A proper JWT token, or the original token if conversion is not needed/possible
89
+ */
90
+ protected async resolveJwtToken(token: string | undefined): Promise<string | undefined> {
91
+ return this.betterAuthService.resolveJwtToken(token);
92
+ }
93
+
94
+ // ===========================================================================
95
+ // Email Verification
96
+ // ===========================================================================
97
+
98
+ /**
99
+ * Check if email verification is required and the user's email is verified.
100
+ * Throws UnauthorizedException with EMAIL_VERIFICATION_REQUIRED if:
101
+ * - emailVerificationService is available AND enabled
102
+ * - AND the user's email is NOT verified
103
+ *
104
+ * Override this method to customize the email verification check behavior.
105
+ *
106
+ * @param sessionUser - The user from Better-Auth sign-in response
107
+ * @throws UnauthorizedException if email is not verified and verification is required
108
+ */
109
+ protected checkEmailVerification(sessionUser: BetterAuthSessionUser): void {
110
+ if (
111
+ this.emailVerificationService?.isEnabled()
112
+ && !sessionUser.emailVerified
113
+ ) {
114
+ this.logger.debug(`[SignIn] Email not verified for ${maskEmail(sessionUser.email)}, blocking login`);
115
+ throw new UnauthorizedException(ErrorCode.EMAIL_VERIFICATION_REQUIRED);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Check email verification by looking up the user by email address.
121
+ *
122
+ * This is used in the 2FA path where the sign-in response does NOT include user data
123
+ * (only a 2FA challenge), so we cannot use checkEmailVerification(sessionUser).
124
+ * Instead, we look up the user via CoreBetterAuthService.isUserEmailVerified().
125
+ *
126
+ * @param email - The email address to check
127
+ * @throws UnauthorizedException if email is not verified and verification is required
128
+ */
129
+ protected async checkEmailVerificationByEmail(email: string): Promise<void> {
130
+ if (!this.emailVerificationService?.isEnabled()) {
131
+ return;
132
+ }
133
+
134
+ const verified = await this.betterAuthService.isUserEmailVerified(email);
135
+ if (verified === false) {
136
+ this.logger.debug(`[SignIn/2FA] Email not verified for ${maskEmail(email)}, blocking login`);
137
+ throw new UnauthorizedException(ErrorCode.EMAIL_VERIFICATION_REQUIRED);
138
+ }
139
+ }
140
+
74
141
  // ===========================================================================
75
142
  // Queries
76
143
  // ===========================================================================
@@ -122,6 +189,7 @@ export class CoreBetterAuthResolver {
122
189
  @Roles(RoleEnum.S_EVERYONE)
123
190
  betterAuthFeatures(): CoreBetterAuthFeaturesModel {
124
191
  return {
192
+ emailVerification: this.emailVerificationService?.isEnabled() ?? false,
125
193
  enabled: this.betterAuthService.isEnabled(),
126
194
  jwt: this.betterAuthService.isJwtEnabled(),
127
195
  passkey: this.betterAuthService.isPasskeyEnabled(),
@@ -189,6 +257,11 @@ export class CoreBetterAuthResolver {
189
257
  * but not in IAM, they will be automatically migrated on first IAM sign-in.
190
258
  *
191
259
  * Override this method to add custom pre/post sign-in logic.
260
+ *
261
+ * NOTE: The `_ctx` parameter is intentionally kept but unused in the base implementation.
262
+ * It provides override flexibility for consuming projects that need access to the Express
263
+ * Request/Response context (e.g., for IP logging, custom cookie handling, audit trails).
264
+ * DO NOT REMOVE this parameter - it would break existing project overrides.
192
265
  */
193
266
  @Mutation(() => CoreBetterAuthAuthModel, {
194
267
  description: 'Sign in via Better-Auth (email/password)',
@@ -197,8 +270,8 @@ export class CoreBetterAuthResolver {
197
270
  async betterAuthSignIn(
198
271
  @Args('email') email: string,
199
272
  @Args('password') password: string,
200
-
201
- @Context() _ctx: { req: Request; res: Response },
273
+ // Kept for override flexibility - projects may need req/res in their overrides
274
+ @Context() _ctx?: { req: Request; res: Response },
202
275
  ): Promise<CoreBetterAuthAuthModel> {
203
276
  this.ensureEnabled();
204
277
 
@@ -245,6 +318,8 @@ export class CoreBetterAuthResolver {
245
318
 
246
319
  // Check for 2FA requirement
247
320
  if (requires2FA(response)) {
321
+ // Defense-in-depth: Check email verification even for 2FA users
322
+ await this.checkEmailVerificationByEmail(email);
248
323
  return {
249
324
  requiresTwoFactor: true,
250
325
  success: false,
@@ -255,13 +330,19 @@ export class CoreBetterAuthResolver {
255
330
  // Get user data
256
331
  if (hasUser(response)) {
257
332
  const sessionUser: BetterAuthSessionUser = response.user;
333
+
334
+ // Check email verification requirement before allowing login
335
+ this.checkEmailVerification(sessionUser);
336
+
258
337
  const mappedUser = await this.userMapper.mapSessionUser(sessionUser);
259
338
 
260
- // Return the session token for session-based authentication
261
- // Note: If JWT plugin is enabled, accessToken may be in response or in set-auth-jwt header
262
- // For GraphQL responses, we return the session token and let clients use it for session auth
339
+ // Return the best available token:
340
+ // 1. accessToken (JWT plugin enriched response)
341
+ // 2. token (top-level, some BetterAuth versions)
342
+ // 3. session.token (session-based fallback)
263
343
  const responseAny = response as any;
264
- const token = responseAny.accessToken || responseAny.token;
344
+ const rawToken = responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
345
+ const token = await this.resolveJwtToken(rawToken);
265
346
 
266
347
  return {
267
348
  requiresTwoFactor: false,
@@ -299,7 +380,7 @@ export class CoreBetterAuthResolver {
299
380
  /**
300
381
  * Direct sign-in attempt without migration logic (used after migration)
301
382
  */
302
- private async attemptSignInDirect(
383
+ protected async attemptSignInDirect(
303
384
  email: string,
304
385
  password: string,
305
386
  api: ReturnType<CoreBetterAuthService['getApi']>,
@@ -313,14 +394,24 @@ export class CoreBetterAuthResolver {
313
394
  }
314
395
 
315
396
  if (requires2FA(response)) {
397
+ // Defense-in-depth: Check email verification even for 2FA users
398
+ await this.checkEmailVerificationByEmail(email);
316
399
  return { requiresTwoFactor: true, success: false, user: null };
317
400
  }
318
401
 
319
402
  const sessionUser: BetterAuthSessionUser = response.user;
403
+
404
+ // Check email verification requirement before allowing login
405
+ this.checkEmailVerification(sessionUser);
406
+
320
407
  const mappedUser = await this.userMapper.mapSessionUser(sessionUser);
321
- // Return accessToken if available (JWT), otherwise fall back to session token
408
+ // Return the best available token:
409
+ // 1. accessToken (JWT plugin enriched response)
410
+ // 2. token (top-level, some BetterAuth versions)
411
+ // 3. session.token (session-based fallback)
322
412
  const responseAny = response as any;
323
- const token = responseAny.accessToken || responseAny.token;
413
+ const rawToken = responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
414
+ const token = await this.resolveJwtToken(rawToken);
324
415
 
325
416
  return {
326
417
  requiresTwoFactor: false,
@@ -335,6 +426,16 @@ export class CoreBetterAuthResolver {
335
426
  * Sign up via Better-Auth
336
427
  *
337
428
  * Override this method to add custom pre/post sign-up logic (e.g., sending welcome emails).
429
+ *
430
+ * By default, `termsAndPrivacyAccepted` must be `true` for sign-up to succeed.
431
+ * This can be configured via `betterAuth.signUpChecks` in your server config:
432
+ * - `signUpChecks: false` - Disable all sign-up checks
433
+ * - `signUpChecks: { requiredFields: ['termsAndPrivacyAccepted', 'ageConfirmed'] }` - Custom fields
434
+ *
435
+ * @param email - User email address
436
+ * @param password - User password
437
+ * @param name - Optional display name
438
+ * @param termsAndPrivacyAccepted - Whether user accepted terms and privacy policy (required by default)
338
439
  */
339
440
  @Mutation(() => CoreBetterAuthAuthModel, {
340
441
  description: 'Sign up via Better-Auth (email/password)',
@@ -344,9 +445,15 @@ export class CoreBetterAuthResolver {
344
445
  @Args('email') email: string,
345
446
  @Args('password') password: string,
346
447
  @Args('name', { nullable: true }) name?: string,
448
+ @Args('termsAndPrivacyAccepted', { nullable: true }) termsAndPrivacyAccepted?: boolean,
347
449
  ): Promise<CoreBetterAuthAuthModel> {
348
450
  this.ensureEnabled();
349
451
 
452
+ // Validate sign-up input (termsAndPrivacyAccepted is required by default)
453
+ if (this.signUpValidator) {
454
+ this.signUpValidator.validateSignUpInput({ termsAndPrivacyAccepted });
455
+ }
456
+
350
457
  const api = this.betterAuthService.getApi();
351
458
  if (!api) {
352
459
  throw new BadRequestException(ErrorCode.BETTERAUTH_API_NOT_AVAILABLE);
@@ -369,9 +476,26 @@ export class CoreBetterAuthResolver {
369
476
  const sessionUser: BetterAuthSessionUser = response.user;
370
477
 
371
478
  // Link or create user in our database
372
- await this.userMapper.linkOrCreateUser(sessionUser);
479
+ // Pass termsAndPrivacyAccepted to store the acceptance timestamp
480
+ await this.userMapper.linkOrCreateUser(sessionUser, { termsAndPrivacyAccepted });
373
481
  const mappedUser = await this.userMapper.mapSessionUser(sessionUser);
374
482
 
483
+ // If email verification is enabled, revoke the session and don't return session data
484
+ // The user must verify their email before they can use any session
485
+ if (this.emailVerificationService?.isEnabled()) {
486
+ const sessionToken = hasSession(response) ? response.session.token : undefined;
487
+ if (sessionToken) {
488
+ await this.betterAuthService.revokeSession(sessionToken);
489
+ }
490
+ this.logger.debug(`[SignUp] Email verification required for ${maskEmail(sessionUser.email)}, session revoked`);
491
+ return {
492
+ emailVerificationRequired: true,
493
+ requiresTwoFactor: false,
494
+ success: true,
495
+ user: mappedUser ? this.mapToUserModel(mappedUser) : null,
496
+ };
497
+ }
498
+
375
499
  return {
376
500
  requiresTwoFactor: false,
377
501
  session: hasSession(response) ? this.mapSessionInfo(response.session) : null,
@@ -387,6 +511,10 @@ export class CoreBetterAuthResolver {
387
511
  if (errorMessage.includes('already exists')) {
388
512
  throw new BadRequestException(ErrorCode.EMAIL_ALREADY_EXISTS);
389
513
  }
514
+ // Re-throw BadRequestException (e.g., from sign-up validation)
515
+ if (error instanceof BadRequestException) {
516
+ throw error;
517
+ }
390
518
  throw new BadRequestException(ErrorCode.SIGNUP_FAILED);
391
519
  }
392
520
  }
@@ -782,15 +910,7 @@ export class CoreBetterAuthResolver {
782
910
  * Convert Express headers to Web API Headers
783
911
  */
784
912
  protected convertHeaders(headers: Record<string, string | string[] | undefined>): Headers {
785
- const result = new Headers();
786
- for (const [key, value] of Object.entries(headers)) {
787
- if (typeof value === 'string') {
788
- result.set(key, value);
789
- } else if (Array.isArray(value)) {
790
- result.set(key, value.join(', '));
791
- }
792
- }
793
- return result;
913
+ return convertExpressHeaders(headers);
794
914
  }
795
915
 
796
916
  /**
@@ -4,11 +4,12 @@ import { Request } from 'express';
4
4
  import { importJWK, jwtVerify } from 'jose';
5
5
  import { Connection } from 'mongoose';
6
6
 
7
- import { maskCookieHeader, maskEmail, maskToken } from '../../common/helpers/logging.helper';
7
+ import { maskEmail, maskToken } from '../../common/helpers/logging.helper';
8
8
  import { IBetterAuth } from '../../common/interfaces/server-options.interface';
9
9
  import { ConfigService } from '../../common/services/config.service';
10
10
  import { BetterAuthInstance } from './better-auth.config';
11
11
  import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
12
+ import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
12
13
  import { BETTER_AUTH_INSTANCE } from './core-better-auth.module';
13
14
 
14
15
  /**
@@ -215,6 +216,48 @@ export class CoreBetterAuthService {
215
216
  // JWT Token Methods
216
217
  // ===================================================================================================================
217
218
 
219
+ /**
220
+ * Resolves a session token to a JWT when cookies are disabled and JWT is enabled.
221
+ *
222
+ * When cookies are disabled, BetterAuth's internal API may return a session token
223
+ * (opaque string like "TVRRtiL19h9q...") instead of a JWT. This method detects
224
+ * non-JWT tokens and converts them to proper JWTs via the BetterAuth JWT plugin.
225
+ *
226
+ * @param token - The token from BetterAuth response (may be session token or JWT)
227
+ * @returns A proper JWT token, or the original token if conversion is not needed/possible
228
+ */
229
+ async resolveJwtToken(token: string | undefined): Promise<string | undefined> {
230
+ if (!token) return undefined;
231
+
232
+ const cookiesEnabled = ConfigService.configFastButReadOnly?.cookies !== false;
233
+ if (cookiesEnabled || !this.isJwtEnabled()) {
234
+ return token;
235
+ }
236
+
237
+ // Already a JWT (three base64url segments separated by dots)
238
+ if (token.startsWith('eyJ') && token.split('.').length === 3) {
239
+ return token;
240
+ }
241
+
242
+ // Convert session token to JWT via BetterAuth JWT plugin
243
+ // BetterAuth identifies sessions via the session cookie, so we pass
244
+ // the session token as a cookie header (signed, as BetterAuth expects)
245
+ const cookieName = this.getSessionCookieName();
246
+ const secret = this.config?.secret || '';
247
+ const signedToken = secret ? signCookieValueIfNeeded(token, secret, true) : encodeURIComponent(token);
248
+ const jwt = await this.getToken({
249
+ headers: { cookie: `${cookieName}=${signedToken}` },
250
+ });
251
+
252
+ if (jwt) {
253
+ this.logger.debug('Resolved session token to JWT for cookie-less mode');
254
+ return jwt;
255
+ }
256
+
257
+ this.logger.warn('Failed to resolve session token to JWT - returning session token as fallback');
258
+ return token;
259
+ }
260
+
218
261
  /**
219
262
  * Gets a fresh JWT token for the current session.
220
263
  *
@@ -248,16 +291,8 @@ export class CoreBetterAuthService {
248
291
 
249
292
  try {
250
293
  // Convert headers to the format Better-Auth expects
251
- const headers = new Headers();
252
294
  const reqHeaders = 'headers' in req ? req.headers : {};
253
-
254
- for (const [key, value] of Object.entries(reqHeaders)) {
255
- if (typeof value === 'string') {
256
- headers.set(key, value);
257
- } else if (Array.isArray(value)) {
258
- headers.set(key, value.join(', '));
259
- }
260
- }
295
+ const headers = convertExpressHeaders(reqHeaders as Record<string, string | string[] | undefined>);
261
296
 
262
297
  // Call the token endpoint via Better-Auth API
263
298
  // The jwt plugin adds a getToken method to the API
@@ -305,26 +340,39 @@ export class CoreBetterAuthService {
305
340
 
306
341
  try {
307
342
  // Convert headers to the format Better-Auth expects
308
- const headers = new Headers();
309
343
  const reqHeaders = 'headers' in req ? req.headers : {};
344
+ const headers = convertExpressHeaders(reqHeaders as Record<string, string | string[] | undefined>);
310
345
 
311
- for (const [key, value] of Object.entries(reqHeaders)) {
312
- if (typeof value === 'string') {
313
- headers.set(key, value);
314
- } else if (Array.isArray(value)) {
315
- headers.set(key, value.join(', '));
346
+ // Sign cookies before sending to Better-Auth API
347
+ // Browser clients send unsigned cookies, but Better-Auth expects signed cookies
348
+ const cookieHeader = headers.get('cookie');
349
+ if (cookieHeader && this.config?.secret) {
350
+ const basePath = this.getBasePath()?.replace(/^\//, '').replace(/\//g, '.') || 'iam';
351
+ const sessionCookieName = `${basePath}.session_token`;
352
+ const cookies = parseCookieHeader(cookieHeader);
353
+ let modified = false;
354
+
355
+ for (const [name, value] of Object.entries(cookies)) {
356
+ // Sign the session token cookie if it's not already signed
357
+ if (name === sessionCookieName || name === 'token') {
358
+ const signedValue = signCookieValueIfNeeded(value, this.config.secret);
359
+ if (signedValue !== value) {
360
+ cookies[name] = signedValue;
361
+ modified = true;
362
+ }
363
+ }
316
364
  }
317
- }
318
365
 
319
- // Debug: Log the cookie header being sent to api.getSession (masked for security)
320
- const cookieHeader = headers.get('cookie');
321
- this.logger.debug(`getSession called with cookies: ${maskCookieHeader(cookieHeader)}`);
366
+ if (modified) {
367
+ const signedCookieHeader = Object.entries(cookies)
368
+ .map(([name, value]) => `${name}=${value}`)
369
+ .join('; ');
370
+ headers.set('cookie', signedCookieHeader);
371
+ }
372
+ }
322
373
 
323
374
  const response = await api.getSession({ headers });
324
375
 
325
- // Debug: Log the response from api.getSession
326
- this.logger.debug(`getSession response: ${JSON.stringify(response)?.substring(0, 200)}`);
327
-
328
376
  if (response && typeof response === 'object' && 'user' in response) {
329
377
  return response as SessionResult;
330
378
  }
@@ -347,11 +395,11 @@ export class CoreBetterAuthService {
347
395
  *
348
396
  * @example
349
397
  * ```typescript
350
- * // Get session token from cookie or header
351
- * const sessionToken = req.cookies['better-auth.session_token'];
398
+ * // Get session token from cookie (using basePath-based name)
399
+ * const sessionToken = req.cookies['iam.session_token'];
352
400
  * const success = await betterAuthService.revokeSession(sessionToken);
353
401
  * if (success) {
354
- * res.clearCookie('better-auth.session_token');
402
+ * res.clearCookie('iam.session_token');
355
403
  * }
356
404
  * ```
357
405
  */
@@ -521,6 +569,132 @@ export class CoreBetterAuthService {
521
569
  }
522
570
  }
523
571
 
572
+ /**
573
+ * Gets the most recent active (non-expired) session for a user.
574
+ *
575
+ * This is needed in JWT mode where the client only has a JWT but BetterAuth's
576
+ * plugin endpoints (2FA, Passkey, etc.) need a real session token for authentication.
577
+ *
578
+ * @param userId - The user ID to find an active session for
579
+ * @returns Session result with token, or null if no active session exists
580
+ */
581
+ async getActiveSessionForUser(userId: string): Promise<SessionResult> {
582
+ if (!this.isEnabled() || !this.connection?.db) {
583
+ return { session: null, user: null };
584
+ }
585
+
586
+ try {
587
+ const db = this.connection.db;
588
+ const sessionsCollection = db.collection('session');
589
+
590
+ // Find the most recent non-expired session for this user
591
+ const results = await sessionsCollection
592
+ .aggregate([
593
+ {
594
+ $match: {
595
+ $expr: {
596
+ $or: [
597
+ { $eq: ['$userId', userId] },
598
+ { $eq: [{ $toString: '$userId' }, userId] },
599
+ ],
600
+ },
601
+ expiresAt: { $gt: new Date() },
602
+ },
603
+ },
604
+ { $sort: { createdAt: -1 } },
605
+ { $limit: 1 },
606
+ {
607
+ $lookup: {
608
+ as: 'userDoc',
609
+ from: 'users',
610
+ let: { sessionUserId: '$userId' },
611
+ pipeline: [
612
+ {
613
+ $match: {
614
+ $expr: {
615
+ $or: [
616
+ { $eq: ['$_id', '$$sessionUserId'] },
617
+ { $eq: [{ $toString: '$_id' }, { $toString: '$$sessionUserId' }] },
618
+ { $eq: ['$id', { $toString: '$$sessionUserId' }] },
619
+ ],
620
+ },
621
+ },
622
+ },
623
+ ],
624
+ },
625
+ },
626
+ { $unwind: { path: '$userDoc', preserveNullAndEmptyArrays: true } },
627
+ ])
628
+ .toArray();
629
+
630
+ const result = results[0];
631
+
632
+ if (!result) {
633
+ this.logger.debug(`getActiveSessionForUser: no active session for user ${userId}`);
634
+ return { session: null, user: null };
635
+ }
636
+
637
+ const user = result.userDoc;
638
+
639
+ return {
640
+ session: {
641
+ expiresAt: result.expiresAt,
642
+ id: result.id || result._id?.toString(),
643
+ token: result.token,
644
+ userId: result.userId?.toString(),
645
+ },
646
+ user: user
647
+ ? {
648
+ email: user.email,
649
+ emailVerified: user.emailVerified,
650
+ id: user.id || user._id?.toString(),
651
+ name: user.name,
652
+ }
653
+ : null,
654
+ };
655
+ } catch (error) {
656
+ this.logger.debug(`getActiveSessionForUser error: ${error instanceof Error ? error.message : 'Unknown error'}`);
657
+ return { session: null, user: null };
658
+ }
659
+ }
660
+
661
+ // ===================================================================================================================
662
+ // User Lookup Methods
663
+ // ===================================================================================================================
664
+
665
+ /**
666
+ * Checks whether a user's email is verified via Better-Auth's internal adapter.
667
+ *
668
+ * Returns:
669
+ * - `true` if the user exists and their email is verified
670
+ * - `false` if the user exists and their email is NOT verified
671
+ * - `null` if the user could not be found or Better-Auth is not available
672
+ *
673
+ * This method is used by controller and resolver to check email verification
674
+ * in the 2FA path, where the sign-in response does not include user data.
675
+ *
676
+ * @param email - The email address to look up
677
+ * @returns `true`, `false`, or `null`
678
+ */
679
+ async isUserEmailVerified(email: string): Promise<boolean | null> {
680
+ const authInstance = this.getInstance();
681
+ if (!authInstance) {
682
+ return null;
683
+ }
684
+
685
+ try {
686
+ const context = await authInstance.$context;
687
+ const result = await context.internalAdapter.findUserByEmail(email);
688
+ if (!result?.user) {
689
+ return null;
690
+ }
691
+ return !!result.user.emailVerified;
692
+ } catch (error) {
693
+ this.logger.debug(`isUserEmailVerified error for ${maskEmail(email)}: ${error instanceof Error ? error.message : 'Unknown error'}`);
694
+ return null;
695
+ }
696
+ }
697
+
524
698
  // ===================================================================================================================
525
699
  // JWT Token Verification (for BetterAuth JWT tokens using JWKS)
526
700
  // ===================================================================================================================
@@ -686,22 +860,26 @@ export class CoreBetterAuthService {
686
860
  }
687
861
 
688
862
  /**
689
- * Extracts and verifies a JWT token from a request's Authorization header.
863
+ * Extracts and verifies a JWT token from a request's Authorization header,
864
+ * or verifies a directly provided token.
690
865
  *
691
866
  * @param req - Express request object
867
+ * @param token - Optional JWT token to verify directly (bypasses header extraction)
692
868
  * @returns The JWT payload with user info, or null if no valid token
693
869
  */
694
- async verifyJwtFromRequest(req: Request): Promise<null | {
870
+ async verifyJwtFromRequest(req: Request, token?: string): Promise<null | {
695
871
  [key: string]: any;
696
872
  email?: string;
697
873
  sub: string;
698
874
  }> {
699
- const authHeader = req.headers.authorization;
700
- if (!authHeader?.startsWith('Bearer ')) {
701
- return null;
875
+ if (!token) {
876
+ const authHeader = req.headers.authorization;
877
+ if (!authHeader?.startsWith('Bearer ')) {
878
+ return null;
879
+ }
880
+ token = authHeader.substring(7);
702
881
  }
703
882
 
704
- const token = authHeader.substring(7);
705
883
  return this.verifyJwtToken(token);
706
884
  }
707
885
  }
@@ -28,10 +28,14 @@ export * from './better-auth.resolver';
28
28
  export * from './better-auth.types';
29
29
  export * from './core-better-auth-api.middleware';
30
30
  export * from './core-better-auth-auth.model';
31
+ export * from './core-better-auth-cookie.helper';
32
+ export * from './core-better-auth-email-verification.service';
31
33
  export * from './core-better-auth-migration-status.model';
32
34
  export * from './core-better-auth-models';
33
35
  export * from './core-better-auth-rate-limit.middleware';
34
36
  export * from './core-better-auth-rate-limiter.service';
37
+ export * from './core-better-auth-signup-validator.service';
38
+ export * from './core-better-auth-token.helper';
35
39
  export * from './core-better-auth-user.mapper';
36
40
  export * from './core-better-auth-web.helper';
37
41
  export * from './core-better-auth.controller';
@@ -233,6 +233,51 @@ export const LtnsErrors = {
233
233
  },
234
234
  },
235
235
 
236
+ SIGNUP_TERMS_NOT_ACCEPTED: {
237
+ code: 'LTNS_0021',
238
+ message: 'Terms and privacy policy must be accepted',
239
+ translations: {
240
+ de: 'Die Nutzungsbedingungen und Datenschutzrichtlinie müssen akzeptiert werden.',
241
+ en: 'Terms and privacy policy must be accepted.',
242
+ },
243
+ },
244
+
245
+ SIGNUP_MISSING_REQUIRED_FIELDS: {
246
+ code: 'LTNS_0022',
247
+ message: 'Required sign-up fields are missing',
248
+ translations: {
249
+ de: 'Erforderliche Registrierungsfelder fehlen.',
250
+ en: 'Required sign-up fields are missing.',
251
+ },
252
+ },
253
+
254
+ EMAIL_VERIFICATION_REQUIRED: {
255
+ code: 'LTNS_0023',
256
+ message: 'Email verification required',
257
+ translations: {
258
+ de: 'Bitte verifizieren Sie Ihre E-Mail-Adresse.',
259
+ en: 'Please verify your email address.',
260
+ },
261
+ },
262
+
263
+ EMAIL_VERIFICATION_TOKEN_INVALID: {
264
+ code: 'LTNS_0024',
265
+ message: 'Email verification token is invalid or expired',
266
+ translations: {
267
+ de: 'Der E-Mail-Verifizierungslink ist ungültig oder abgelaufen.',
268
+ en: 'The email verification link is invalid or expired.',
269
+ },
270
+ },
271
+
272
+ EMAIL_ALREADY_VERIFIED: {
273
+ code: 'LTNS_0025',
274
+ message: 'Email is already verified',
275
+ translations: {
276
+ de: 'Die E-Mail-Adresse ist bereits verifiziert.',
277
+ en: 'The email address is already verified.',
278
+ },
279
+ },
280
+
236
281
  // =====================================================
237
282
  // Authorization Errors (LTNS_0100-LTNS_0199)
238
283
  // =====================================================
@@ -256,6 +256,21 @@ export abstract class CoreUserModel extends CorePersistenceModel {
256
256
  })
257
257
  twoFactorEnabled: boolean = undefined;
258
258
 
259
+ /**
260
+ * Date when terms and privacy policy were accepted
261
+ * Set during sign-up when user accepts terms and privacy policy
262
+ *
263
+ * @since 11.13.0
264
+ */
265
+ @UnifiedField({
266
+ description: 'Date when terms and privacy policy were accepted',
267
+ isOptional: true,
268
+ mongoose: { type: Date },
269
+ roles: RoleEnum.S_EVERYONE,
270
+ type: () => Date,
271
+ })
272
+ termsAndPrivacyAcceptedAt: Date = undefined;
273
+
259
274
  // ===================================================================================================================
260
275
  // Methods
261
276
  // ===================================================================================================================