@lenne.tech/nest-server 11.11.0 → 11.12.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 (45) hide show
  1. package/dist/core/modules/auth/core-auth.controller.js +1 -1
  2. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  3. package/dist/core/modules/auth/core-auth.resolver.js +1 -1
  4. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  5. package/dist/core/modules/better-auth/better-auth-token.service.js +1 -4
  6. package/dist/core/modules/better-auth/better-auth-token.service.js.map +1 -1
  7. package/dist/core/modules/better-auth/better-auth.config.js +55 -8
  8. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  9. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +2 -0
  10. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +22 -18
  11. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  12. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.d.ts +41 -0
  13. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +107 -0
  14. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -0
  15. package/dist/core/modules/better-auth/core-better-auth-token.helper.d.ts +16 -0
  16. package/dist/core/modules/better-auth/core-better-auth-token.helper.js +66 -0
  17. package/dist/core/modules/better-auth/core-better-auth-token.helper.js.map +1 -0
  18. package/dist/core/modules/better-auth/core-better-auth-web.helper.d.ts +2 -3
  19. package/dist/core/modules/better-auth/core-better-auth-web.helper.js +33 -22
  20. package/dist/core/modules/better-auth/core-better-auth-web.helper.js.map +1 -1
  21. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
  22. package/dist/core/modules/better-auth/core-better-auth.controller.js +17 -34
  23. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  24. package/dist/core/modules/better-auth/core-better-auth.middleware.d.ts +0 -1
  25. package/dist/core/modules/better-auth/core-better-auth.middleware.js +2 -20
  26. package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
  27. package/dist/core/modules/better-auth/core-better-auth.service.js +22 -2
  28. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  29. package/dist/core/modules/better-auth/index.d.ts +2 -0
  30. package/dist/core/modules/better-auth/index.js +2 -0
  31. package/dist/core/modules/better-auth/index.js.map +1 -1
  32. package/dist/tsconfig.build.tsbuildinfo +1 -1
  33. package/package.json +10 -10
  34. package/src/core/modules/auth/core-auth.controller.ts +2 -2
  35. package/src/core/modules/auth/core-auth.resolver.ts +2 -2
  36. package/src/core/modules/better-auth/better-auth-token.service.ts +5 -8
  37. package/src/core/modules/better-auth/better-auth.config.ts +139 -12
  38. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +44 -20
  39. package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +323 -0
  40. package/src/core/modules/better-auth/core-better-auth-token.helper.ts +200 -0
  41. package/src/core/modules/better-auth/core-better-auth-web.helper.ts +73 -36
  42. package/src/core/modules/better-auth/core-better-auth.controller.ts +43 -69
  43. package/src/core/modules/better-auth/core-better-auth.middleware.ts +4 -33
  44. package/src/core/modules/better-auth/core-better-auth.service.ts +31 -9
  45. package/src/core/modules/better-auth/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.11.0",
3
+ "version": "11.12.0",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -105,7 +105,7 @@
105
105
  "cookie-parser": "1.4.7",
106
106
  "dotenv": "17.2.3",
107
107
  "ejs": "3.1.10",
108
- "express": "5.1.0",
108
+ "express": "5.2.1",
109
109
  "graphql": "16.12.0",
110
110
  "graphql-query-complexity": "1.1.0",
111
111
  "graphql-subscriptions": "3.0.0",
@@ -117,7 +117,7 @@
117
117
  "mongoose": "9.1.5",
118
118
  "multer": "2.0.2",
119
119
  "node-mailjet": "6.0.11",
120
- "nodemailer": "7.0.12",
120
+ "nodemailer": "7.0.13",
121
121
  "passport": "0.7.0",
122
122
  "passport-jwt": "4.0.1",
123
123
  "reflect-metadata": "0.2.2",
@@ -131,20 +131,20 @@
131
131
  "@nestjs/cli": "11.0.16",
132
132
  "@nestjs/schematics": "11.0.9",
133
133
  "@nestjs/testing": "11.1.12",
134
- "@swc/cli": "0.7.9",
135
- "@swc/core": "1.15.10",
134
+ "@swc/cli": "0.7.10",
135
+ "@swc/core": "1.15.11",
136
136
  "@types/compression": "1.8.1",
137
137
  "@types/cookie-parser": "1.4.10",
138
138
  "@types/ejs": "3.1.5",
139
139
  "@types/express": "4.17.21",
140
140
  "@types/lodash": "4.17.23",
141
141
  "@types/multer": "2.0.0",
142
- "@types/node": "25.0.10",
143
- "@types/nodemailer": "7.0.5",
142
+ "@types/node": "25.2.0",
143
+ "@types/nodemailer": "7.0.9",
144
144
  "@types/passport": "1.0.17",
145
145
  "@types/supertest": "6.0.3",
146
- "@typescript-eslint/eslint-plugin": "8.53.1",
147
- "@typescript-eslint/parser": "8.53.1",
146
+ "@typescript-eslint/eslint-plugin": "8.54.0",
147
+ "@typescript-eslint/parser": "8.54.0",
148
148
  "@vitest/coverage-v8": "4.0.18",
149
149
  "@vitest/ui": "4.0.18",
150
150
  "ansi-colors": "4.1.3",
@@ -162,7 +162,7 @@
162
162
  "npm-watch": "0.13.0",
163
163
  "otpauth": "9.4.1",
164
164
  "pm2": "6.0.14",
165
- "prettier": "3.7.4",
165
+ "prettier": "3.8.1",
166
166
  "pretty-quick": "4.2.2",
167
167
  "rimraf": "6.1.2",
168
168
  "supertest": "7.2.2",
@@ -190,8 +190,8 @@ export class CoreAuthController {
190
190
  * Process cookies
191
191
  */
192
192
  protected processCookies(res: ResponseType, result: any) {
193
- // Check if cookie handling is activated
194
- if (this.configService.getFastButReadOnly('cookies')) {
193
+ // Check if cookie handling is activated (enabled by default, unless explicitly set to false)
194
+ if (this.configService.getFastButReadOnly('cookies') !== false) {
195
195
  // Set cookies
196
196
  if (!result || typeof result !== 'object') {
197
197
  res.cookie('token', '', { httpOnly: true });
@@ -172,8 +172,8 @@ export class CoreAuthResolver {
172
172
  * Process cookies
173
173
  */
174
174
  protected processCookies(ctx: { res: ResponseType }, result: any) {
175
- // Check if cookie handling is activated
176
- if (this.configService.getFastButReadOnly('cookies')) {
175
+ // Check if cookie handling is activated (enabled by default, unless explicitly set to false)
176
+ if (this.configService.getFastButReadOnly('cookies') !== false) {
177
177
  // Set cookies
178
178
  if (!result || typeof result !== 'object') {
179
179
  ctx.res.cookie('token', '', { httpOnly: true });
@@ -49,9 +49,10 @@ export class BetterAuthTokenService {
49
49
  /**
50
50
  * Extracts a token from the request's Authorization header or cookies.
51
51
  *
52
- * Checks in order:
52
+ * Cookie priority (v11.12+):
53
53
  * 1. Authorization header (Bearer token)
54
- * 2. Session cookies (iam.session_token, better-auth.session_token, token)
54
+ * 2. `{basePath}.session_token` (e.g., `iam.session_token`) - Better-Auth native
55
+ * 3. `token` - Legacy nest-server cookie
55
56
  *
56
57
  * @param request - HTTP request object with headers and cookies
57
58
  * @returns Token extraction result with token and source
@@ -70,14 +71,10 @@ export class BetterAuthTokenService {
70
71
  }
71
72
  }
72
73
 
73
- // Try cookies
74
+ // Try cookies - Better-Auth native cookie first, then legacy
74
75
  if (request.cookies && this.betterAuthService) {
75
76
  const cookieName = this.betterAuthService.getSessionCookieName();
76
- const token =
77
- request.cookies[cookieName] ||
78
- request.cookies['better-auth.session_token'] ||
79
- request.cookies['token'] ||
80
- undefined;
77
+ const token = request.cookies[cookieName] || request.cookies['token'] || undefined;
81
78
 
82
79
  if (token) {
83
80
  return { source: 'cookie', token };
@@ -4,6 +4,8 @@ import { betterAuth, BetterAuthPlugin } from 'better-auth';
4
4
  import { mongodbAdapter } from 'better-auth/adapters/mongodb';
5
5
  import { jwt, twoFactor } from 'better-auth/plugins';
6
6
  import * as crypto from 'crypto';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
7
9
 
8
10
  import { IBetterAuth } from '../../common/interfaces/server-options.interface';
9
11
 
@@ -27,10 +29,16 @@ function generateSecureSecret(): string {
27
29
 
28
30
  /**
29
31
  * Cached auto-generated secret for the current server instance.
30
- * Generated once at module load to ensure consistency within a single run.
32
+ * Generated once at a module load to ensure consistency within a single run.
31
33
  */
32
34
  let cachedAutoGeneratedSecret: null | string = null;
33
35
 
36
+ /**
37
+ * Cached project app name for the current server instance.
38
+ * Read once from package.json to avoid repeated file reads.
39
+ */
40
+ let cachedProjectAppName: null | string = null;
41
+
34
42
  /**
35
43
  * Options for creating a better-auth instance
36
44
  */
@@ -165,7 +173,7 @@ interface ValidationResult {
165
173
  */
166
174
  export function createBetterAuthInstance(options: CreateBetterAuthOptions): BetterAuthInstance | null {
167
175
  const logger = new Logger('BetterAuthConfig');
168
- const { config, db, fallbackSecrets } = options;
176
+ const { config, db, fallbackSecrets, serverEnv } = options;
169
177
 
170
178
  // Return null only if better-auth is explicitly disabled
171
179
  // BetterAuth is enabled by default (zero-config)
@@ -184,7 +192,7 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
184
192
  // Normalize Passkey configuration with auto-detection from resolved URLs
185
193
  // This must happen BEFORE validation to allow graceful degradation
186
194
  // Passkey is now AUTO-ACTIVATED by default (not opt-in)
187
- const passkeyNormalization = normalizePasskeyConfig(config, resolvedUrls);
195
+ const passkeyNormalization = normalizePasskeyConfig(config, { resolvedUrls, serverEnv });
188
196
 
189
197
  // Log passkey normalization warnings (info about auto-detection or disabled status)
190
198
  for (const warning of passkeyNormalization.warnings) {
@@ -205,15 +213,23 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
205
213
  }
206
214
 
207
215
  // Build configuration components (pass normalized passkey config and resolved URLs)
208
- const plugins = buildPlugins(config, passkeyNormalization);
216
+ const plugins = buildPlugins(config, { passkeyNormalization, serverEnv });
209
217
  const socialProviders = buildSocialProviders(config);
210
218
  const trustedOrigins = buildTrustedOrigins(config, passkeyNormalization, resolvedUrls);
211
219
  const additionalFields = buildUserFields(config);
212
220
 
213
221
  // Build the base Better-Auth configuration
214
222
  // Use resolved baseUrl (with local defaults) or fallback
223
+ const basePath = config.basePath || '/iam';
224
+ // Cookie prefix derived from basePath (e.g., '/iam' → 'iam')
225
+ // This ensures Better-Auth looks for cookies like 'iam.session_token' instead of 'better-auth.session_token'
226
+ const cookiePrefix = basePath.replace(/^\//, '').replace(/\//g, '.');
227
+
215
228
  const betterAuthConfig: Record<string, unknown> = {
216
- basePath: config.basePath || '/iam',
229
+ advanced: {
230
+ cookiePrefix,
231
+ },
232
+ basePath,
217
233
  baseURL: resolvedUrls.baseUrl || config.baseUrl || 'http://localhost:3000',
218
234
  database: mongodbAdapter(db),
219
235
  // Enable email/password authentication by default (required by Better-Auth 1.x)
@@ -257,9 +273,15 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
257
273
  * - `undefined`: Disabled (default)
258
274
  *
259
275
  * @param config - Better-auth configuration
260
- * @param passkeyNormalization - Normalized passkey configuration from normalizePasskeyConfig()
276
+ * @param options - Additional options
277
+ * @param options.passkeyNormalization - Normalized passkey configuration from normalizePasskeyConfig()
278
+ * @param options.serverEnv - Server environment for auto-detected app name suffix
261
279
  */
262
- function buildPlugins(config: IBetterAuth, passkeyNormalization: PasskeyNormalizationResult): BetterAuthPlugin[] {
280
+ function buildPlugins(
281
+ config: IBetterAuth,
282
+ options: { passkeyNormalization: PasskeyNormalizationResult; serverEnv?: string },
283
+ ): BetterAuthPlugin[] {
284
+ const { passkeyNormalization, serverEnv } = options;
263
285
  const plugins: BetterAuthPlugin[] = [];
264
286
 
265
287
  // JWT Plugin for API client compatibility
@@ -285,7 +307,8 @@ function buildPlugins(config: IBetterAuth, passkeyNormalization: PasskeyNormaliz
285
307
  const twoFactorConfig = typeof config.twoFactor === 'object' ? config.twoFactor : {};
286
308
  plugins.push(
287
309
  twoFactor({
288
- issuer: twoFactorConfig.appName || 'Nest Server',
310
+ // issuer: Use explicit config, or auto-detect from package.json with environment suffix
311
+ issuer: twoFactorConfig.appName || getProjectAppName(serverEnv),
289
312
  }),
290
313
  );
291
314
  }
@@ -447,6 +470,50 @@ function buildUserFields(config: IBetterAuth): Record<string, UserFieldConfig> {
447
470
  return coreFields;
448
471
  }
449
472
 
473
+ /**
474
+ * Formats the environment name as a suffix.
475
+ * Only adds suffix for development/test environments.
476
+ *
477
+ * @example
478
+ * formatEnvSuffix('local') // → '(Local)'
479
+ * formatEnvSuffix('development') // → '(Development)'
480
+ * formatEnvSuffix('test') // → '(Test)'
481
+ * formatEnvSuffix('production') // → '' (no suffix for production)
482
+ * formatEnvSuffix(undefined) // → ''
483
+ */
484
+ function formatEnvSuffix(env?: string): string {
485
+ if (!env) return '';
486
+
487
+ // Don't add suffix for production
488
+ if (env === 'production' || env === 'prod') return '';
489
+
490
+ // Capitalize first letter
491
+ const formatted = env.charAt(0).toUpperCase() + env.slice(1).toLowerCase();
492
+ return `(${formatted})`;
493
+ }
494
+
495
+ /**
496
+ * Formats a package name to a human-readable display name.
497
+ * Converts kebab-case and snake_case to Title Case.
498
+ *
499
+ * @example
500
+ * formatProjectName('my-awesome-app') // → 'My Awesome App'
501
+ * formatProjectName('@org/my-app') // → 'My App'
502
+ * formatProjectName('nest_server_starter') // → 'Nest Server Starter'
503
+ */
504
+ function formatProjectName(name: string): string {
505
+ // Remove scope (e.g., '@org/my-app' → 'my-app')
506
+ let formatted = name.replace(/^@[^/]+\//, '');
507
+
508
+ // Split by hyphens and underscores, capitalize each word
509
+ formatted = formatted
510
+ .split(/[-_]/)
511
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
512
+ .join(' ');
513
+
514
+ return formatted;
515
+ }
516
+
450
517
  /**
451
518
  * Gets or generates the fallback secret for development.
452
519
  * The secret is cached to ensure consistency during the server's lifetime.
@@ -458,6 +525,37 @@ function getAutoGeneratedSecret(): string {
458
525
  return cachedAutoGeneratedSecret;
459
526
  }
460
527
 
528
+ /**
529
+ * Gets the project's app name from package.json.
530
+ *
531
+ * This function reads the `name` field from the project's package.json
532
+ * and formats it for display (converts a kebab-case to Title Case).
533
+ *
534
+ * Used as default for:
535
+ * - `betterAuth.passkey.rpName` (displayed in browser Passkey prompts)
536
+ * - `betterAuth.twoFactor.appName` (displayed in authenticator apps)
537
+ *
538
+ * @param serverEnv - Optional server environment to append as suffix (e.g., 'local' → '(Local)')
539
+ * @returns The formatted project name, or 'Nest Server' as fallback
540
+ *
541
+ * @example
542
+ * // package.json: { "name": "my-awesome-app" }
543
+ * getProjectAppName() // → 'My Awesome App'
544
+ * getProjectAppName('local') // → 'My Awesome App (Local)'
545
+ * getProjectAppName('test') // → 'My Awesome App (Test)'
546
+ */
547
+ function getProjectAppName(serverEnv?: string): string {
548
+ // Return cached value if available (without env suffix, will be added after)
549
+ if (cachedProjectAppName === null) {
550
+ cachedProjectAppName = readProjectNameFromPackageJson();
551
+ }
552
+
553
+ // Add environment suffix for non-production environments
554
+ // This helps developers distinguish Passkeys when running multiple projects locally
555
+ const envSuffix = formatEnvSuffix(serverEnv);
556
+ return envSuffix ? `${cachedProjectAppName} ${envSuffix}` : cachedProjectAppName;
557
+ }
558
+
461
559
  /**
462
560
  * Checks if a secret has valid minimum length (32 characters)
463
561
  */
@@ -477,6 +575,28 @@ function isValidUrl(url: string): boolean {
477
575
  }
478
576
  }
479
577
 
578
+ /**
579
+ * Reads the project name from package.json in the current working directory.
580
+ * Falls back to 'Nest Server' if package.json cannot be read or has no name.
581
+ */
582
+ function readProjectNameFromPackageJson(): string {
583
+ const fallback = 'Nest Server';
584
+
585
+ try {
586
+ const packageJsonPath = path.join(process.cwd(), 'package.json');
587
+ const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8');
588
+ const packageJson = JSON.parse(packageJsonContent);
589
+
590
+ if (packageJson.name && typeof packageJson.name === 'string') {
591
+ return formatProjectName(packageJson.name);
592
+ }
593
+ } catch {
594
+ // Ignore errors - use fallback
595
+ }
596
+
597
+ return fallback;
598
+ }
599
+
480
600
  /**
481
601
  * Default URLs for local/test environments (local, ci, e2e)
482
602
  * These environments typically run on localhost and don't have a deployed domain.
@@ -598,10 +718,16 @@ function extractRpIdFromUrl(url: string): string {
598
718
  * - `trustedOrigins`: from config.trustedOrigins > [resolvedUrls.appUrl]
599
719
  *
600
720
  * @param config - Better-auth configuration
601
- * @param resolvedUrls - Resolved URLs from resolveUrls()
721
+ * @param options - Additional options
722
+ * @param options.resolvedUrls - Resolved URLs from resolveUrls()
723
+ * @param options.serverEnv - Server environment for auto-detected app name suffix
602
724
  * @returns Normalization result with enabled status, config, and warnings
603
725
  */
604
- function normalizePasskeyConfig(config: IBetterAuth, resolvedUrls: ResolvedUrls): PasskeyNormalizationResult {
726
+ function normalizePasskeyConfig(
727
+ config: IBetterAuth,
728
+ options: { resolvedUrls: ResolvedUrls; serverEnv?: string },
729
+ ): PasskeyNormalizationResult {
730
+ const { resolvedUrls, serverEnv } = options;
605
731
  const warnings: string[] = [];
606
732
 
607
733
  // Check if Passkey is explicitly DISABLED
@@ -657,10 +783,11 @@ function normalizePasskeyConfig(config: IBetterAuth, resolvedUrls: ResolvedUrls)
657
783
  }
658
784
 
659
785
  // Build normalized config
786
+ // rpName: Use explicit config, or auto-detect from package.json with environment suffix
660
787
  const normalizedConfig: NormalizedPasskeyConfig = {
661
788
  origin: finalOrigin,
662
789
  rpId: finalRpId,
663
- rpName: rawConfig.rpName || 'Nest Server',
790
+ rpName: rawConfig.rpName || getProjectAppName(serverEnv),
664
791
  };
665
792
 
666
793
  // Copy optional fields from explicit config
@@ -853,7 +980,7 @@ function validateConfig(
853
980
  errors.push(`Social provider '${name}' is missing clientSecret`);
854
981
  }
855
982
  } else {
856
- // No credentials provided but provider is configured and not disabled
983
+ // No credentials provided, but the provider is configured and not disabled
857
984
  // This is likely a configuration mistake - warn the user
858
985
  warnings.push(
859
986
  `Social provider '${name}' is configured but missing both clientId and clientSecret. ` +
@@ -3,6 +3,7 @@ import { NextFunction, Request, Response } from 'express';
3
3
 
4
4
  import { isProduction } from '../../common/helpers/logging.helper';
5
5
  import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
6
+ import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
6
7
  import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
7
8
  import { CoreBetterAuthService } from './core-better-auth.service';
8
9
 
@@ -19,28 +20,17 @@ import { CoreBetterAuthService } from './core-better-auth.service';
19
20
  * All other paths (Passkey, 2FA, etc.) go directly to Better Auth's
20
21
  * native handler via this middleware for maximum compatibility.
21
22
  */
22
- const CONTROLLER_HANDLED_PATHS = [
23
- '/sign-in/email',
24
- '/sign-up/email',
25
- '/sign-out',
26
- '/session',
27
- ];
23
+ const CONTROLLER_HANDLED_PATHS = ['/sign-in/email', '/sign-up/email', '/sign-out', '/session'];
28
24
 
29
25
  /**
30
26
  * Passkey paths that generate challenges
31
27
  */
32
- const PASSKEY_GENERATE_PATHS = [
33
- '/passkey/generate-register-options',
34
- '/passkey/generate-authenticate-options',
35
- ];
28
+ const PASSKEY_GENERATE_PATHS = ['/passkey/generate-register-options', '/passkey/generate-authenticate-options'];
36
29
 
37
30
  /**
38
31
  * Passkey paths that verify challenges
39
32
  */
40
- const PASSKEY_VERIFY_PATHS = [
41
- '/passkey/verify-registration',
42
- '/passkey/verify-authentication',
43
- ];
33
+ const PASSKEY_VERIFY_PATHS = ['/passkey/verify-registration', '/passkey/verify-authentication'];
44
34
 
45
35
  /**
46
36
  * Middleware that forwards Better Auth API requests to the native Better Auth handler.
@@ -63,12 +53,36 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
63
53
  private readonly logger = new Logger(CoreBetterAuthApiMiddleware.name);
64
54
  private readonly isProd = isProduction();
65
55
  private loggedChallengeStorageMode = false;
56
+ private cookieHelper?: BetterAuthCookieHelper;
66
57
 
67
58
  constructor(
68
59
  private readonly betterAuthService: CoreBetterAuthService,
69
60
  @Optional() private readonly challengeService?: CoreBetterAuthChallengeService,
70
61
  ) {}
71
62
 
63
+ /**
64
+ * Gets or creates the cookie helper instance.
65
+ * Lazy initialization because betterAuthService may not be fully initialized in constructor.
66
+ *
67
+ * Note: Legacy cookie is not enabled in middleware since we can't access ConfigService.
68
+ * The middleware only needs to set the native Better-Auth cookie.
69
+ * Secret is required for cookie signing (Passkey/2FA).
70
+ */
71
+ private getCookieHelper(): BetterAuthCookieHelper {
72
+ if (!this.cookieHelper) {
73
+ const config = this.betterAuthService.getConfig();
74
+ this.cookieHelper = createCookieHelper(
75
+ this.betterAuthService.getBasePath(),
76
+ {
77
+ legacyCookieEnabled: false, // Middleware doesn't need legacy cookie
78
+ secret: config?.secret, // Required for cookie signing
79
+ },
80
+ this.logger,
81
+ );
82
+ }
83
+ return this.cookieHelper;
84
+ }
85
+
72
86
  /**
73
87
  * Check if database challenge storage should be used.
74
88
  * This is checked dynamically because the ChallengeService initializes in onModuleInit.
@@ -134,7 +148,9 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
134
148
  let challengeIdToDelete: string | undefined;
135
149
  if (isPasskeyVerify && this.challengeService) {
136
150
  const challengeId = req.body?.challengeId;
137
- this.logger.debug(`Passkey verify: challengeId=${challengeId ? `${challengeId.substring(0, 8)}...` : 'MISSING'}, body keys=${Object.keys(req.body || {}).join(', ')}`);
151
+ this.logger.debug(
152
+ `Passkey verify: challengeId=${challengeId ? `${challengeId.substring(0, 8)}...` : 'MISSING'}, body keys=${Object.keys(req.body || {}).join(', ')}`,
153
+ );
138
154
  if (challengeId) {
139
155
  const verificationToken = await this.challengeService.getVerificationToken(challengeId);
140
156
  if (verificationToken) {
@@ -244,9 +260,19 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
244
260
  }
245
261
  }
246
262
 
247
- // Clean up the used challenge mapping after verification (success or failure)
248
- if (challengeIdToDelete && this.challengeService) {
263
+ // Clean up the used challenge mapping only after SUCCESSFUL verification
264
+ // On failure, keep the challenge so the user can retry with a different passkey
265
+ if (challengeIdToDelete && this.challengeService && response.ok) {
249
266
  await this.challengeService.deleteChallengeMapping(challengeIdToDelete);
267
+ } else if (challengeIdToDelete && !response.ok) {
268
+ this.logger.debug(`Keeping challenge mapping after failed verification (status=${response.status}) for retry`);
269
+ }
270
+
271
+ // For successful passkey verify-authentication, set session cookie
272
+ // Better-Auth's native handler sets its own cookies, but we extract and
273
+ // re-set them using our cookie helper for consistent cookie handling.
274
+ if (relativePath === '/passkey/verify-authentication' && response.ok) {
275
+ this.getCookieHelper().setSessionCookiesFromWebResponse(res, response);
250
276
  }
251
277
 
252
278
  // Convert Web Standard Response to Express response using shared helper
@@ -261,9 +287,7 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
261
287
 
262
288
  // Send error response if headers not sent
263
289
  if (!res.headersSent) {
264
- const message = this.isProd
265
- ? 'Authentication error'
266
- : (error instanceof Error ? error.message : 'Unknown error');
290
+ const message = this.isProd ? 'Authentication error' : error instanceof Error ? error.message : 'Unknown error';
267
291
  res.status(500).json({
268
292
  error: 'Authentication handler error',
269
293
  message,