@lenne.tech/nest-server 11.10.2 → 11.10.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.
Files changed (56) hide show
  1. package/dist/config.env.js +16 -133
  2. package/dist/config.env.js.map +1 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
  4. package/dist/core/modules/better-auth/better-auth.config.d.ts +3 -0
  5. package/dist/core/modules/better-auth/better-auth.config.js +176 -47
  6. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  7. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +5 -1
  8. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +101 -8
  9. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  10. package/dist/core/modules/better-auth/core-better-auth-challenge.service.d.ts +20 -0
  11. package/dist/core/modules/better-auth/core-better-auth-challenge.service.js +142 -0
  12. package/dist/core/modules/better-auth/core-better-auth-challenge.service.js.map +1 -0
  13. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  15. package/dist/core/modules/better-auth/core-better-auth-web.helper.d.ts +2 -0
  16. package/dist/core/modules/better-auth/core-better-auth-web.helper.js +29 -1
  17. package/dist/core/modules/better-auth/core-better-auth-web.helper.js.map +1 -1
  18. package/dist/core/modules/better-auth/core-better-auth.controller.js +5 -13
  19. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  20. package/dist/core/modules/better-auth/core-better-auth.middleware.d.ts +0 -1
  21. package/dist/core/modules/better-auth/core-better-auth.middleware.js +6 -19
  22. package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
  23. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +4 -1
  24. package/dist/core/modules/better-auth/core-better-auth.module.js +53 -19
  25. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  26. package/dist/core/modules/better-auth/core-better-auth.resolver.js +7 -6
  27. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  28. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +0 -2
  29. package/dist/core/modules/better-auth/core-better-auth.service.js +23 -37
  30. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  31. package/dist/core.module.js +3 -0
  32. package/dist/core.module.js.map +1 -1
  33. package/dist/server/modules/better-auth/better-auth.module.d.ts +4 -1
  34. package/dist/server/modules/better-auth/better-auth.module.js +4 -1
  35. package/dist/server/modules/better-auth/better-auth.module.js.map +1 -1
  36. package/dist/server/server.module.js +1 -4
  37. package/dist/server/server.module.js.map +1 -1
  38. package/dist/tsconfig.build.tsbuildinfo +1 -1
  39. package/package.json +1 -1
  40. package/src/config.env.ts +24 -174
  41. package/src/core/common/interfaces/server-options.interface.ts +288 -35
  42. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +82 -56
  43. package/src/core/modules/better-auth/README.md +132 -35
  44. package/src/core/modules/better-auth/better-auth.config.ts +402 -70
  45. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +158 -18
  46. package/src/core/modules/better-auth/core-better-auth-challenge.service.ts +254 -0
  47. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +1 -1
  48. package/src/core/modules/better-auth/core-better-auth-web.helper.ts +64 -1
  49. package/src/core/modules/better-auth/core-better-auth.controller.ts +6 -14
  50. package/src/core/modules/better-auth/core-better-auth.middleware.ts +7 -20
  51. package/src/core/modules/better-auth/core-better-auth.module.ts +135 -25
  52. package/src/core/modules/better-auth/core-better-auth.resolver.ts +7 -6
  53. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -48
  54. package/src/core.module.ts +5 -0
  55. package/src/server/modules/better-auth/better-auth.module.ts +40 -10
  56. package/src/server/server.module.ts +2 -4
@@ -62,6 +62,24 @@ export interface CreateBetterAuthOptions {
62
62
  * ```
63
63
  */
64
64
  fallbackSecrets?: (string | undefined)[];
65
+
66
+ /**
67
+ * Server-level app/frontend URL (from IServerOptions.appUrl).
68
+ * Used for Passkey origin and CORS trustedOrigins.
69
+ */
70
+ serverAppUrl?: string;
71
+
72
+ /**
73
+ * Server-level base URL (from IServerOptions.baseUrl).
74
+ * Used as fallback for betterAuth.baseUrl.
75
+ */
76
+ serverBaseUrl?: string;
77
+
78
+ /**
79
+ * Server environment (from IServerOptions.env).
80
+ * Used for local environment defaults.
81
+ */
82
+ serverEnv?: string;
65
83
  }
66
84
 
67
85
  /**
@@ -70,6 +88,46 @@ export interface CreateBetterAuthOptions {
70
88
  */
71
89
  type BetterAuthFieldType = 'boolean' | 'date' | 'json' | 'number' | 'number[]' | 'string' | 'string[]';
72
90
 
91
+ /**
92
+ * Normalized Passkey configuration with required fields
93
+ */
94
+ interface NormalizedPasskeyConfig {
95
+ authenticatorAttachment?: 'cross-platform' | 'platform';
96
+ challengeStorage?: 'cookie' | 'database';
97
+ challengeTtlSeconds?: number;
98
+ origin: string;
99
+ residentKey?: 'discouraged' | 'preferred' | 'required';
100
+ rpId: string;
101
+ rpName: string;
102
+ userVerification?: 'discouraged' | 'preferred' | 'required';
103
+ webAuthnChallengeCookie?: string;
104
+ }
105
+
106
+ /**
107
+ * Result of Passkey configuration normalization
108
+ */
109
+ interface PasskeyNormalizationResult {
110
+ /**
111
+ * Whether Passkey should be enabled after normalization
112
+ */
113
+ enabled: boolean;
114
+
115
+ /**
116
+ * Normalized Passkey configuration (with auto-detected values)
117
+ */
118
+ normalizedConfig: NormalizedPasskeyConfig | null;
119
+
120
+ /**
121
+ * Auto-detected trustedOrigins (to be merged with config)
122
+ */
123
+ trustedOrigins: null | string[];
124
+
125
+ /**
126
+ * Warnings generated during normalization
127
+ */
128
+ warnings: string[];
129
+ }
130
+
73
131
  /**
74
132
  * Social provider configuration for better-auth
75
133
  */
@@ -115,8 +173,26 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
115
173
  return null;
116
174
  }
117
175
 
176
+ // Resolve URLs with auto-detection and local defaults
177
+ const resolvedUrls = resolveUrls(options);
178
+
179
+ // Log URL resolution warnings
180
+ for (const warning of resolvedUrls.warnings) {
181
+ logger.log(warning);
182
+ }
183
+
184
+ // Normalize Passkey configuration with auto-detection from resolved URLs
185
+ // This must happen BEFORE validation to allow graceful degradation
186
+ // Passkey is now AUTO-ACTIVATED by default (not opt-in)
187
+ const passkeyNormalization = normalizePasskeyConfig(config, resolvedUrls);
188
+
189
+ // Log passkey normalization warnings (info about auto-detection or disabled status)
190
+ for (const warning of passkeyNormalization.warnings) {
191
+ logger.warn(warning);
192
+ }
193
+
118
194
  // Validate configuration (with fallback secrets for backwards compatibility)
119
- const validation = validateConfig(config, fallbackSecrets);
195
+ const validation = validateConfig(config, fallbackSecrets, passkeyNormalization);
120
196
 
121
197
  // Log warnings
122
198
  for (const warning of validation.warnings) {
@@ -128,16 +204,17 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
128
204
  throw new Error(`BetterAuth configuration invalid: ${validation.errors.join('; ')}`);
129
205
  }
130
206
 
131
- // Build configuration components
132
- const plugins = buildPlugins(config);
207
+ // Build configuration components (pass normalized passkey config and resolved URLs)
208
+ const plugins = buildPlugins(config, passkeyNormalization);
133
209
  const socialProviders = buildSocialProviders(config);
134
- const trustedOrigins = buildTrustedOrigins(config);
210
+ const trustedOrigins = buildTrustedOrigins(config, passkeyNormalization, resolvedUrls);
135
211
  const additionalFields = buildUserFields(config);
136
212
 
137
213
  // Build the base Better-Auth configuration
214
+ // Use resolved baseUrl (with local defaults) or fallback
138
215
  const betterAuthConfig: Record<string, unknown> = {
139
216
  basePath: config.basePath || '/iam',
140
- baseURL: config.baseUrl || 'http://localhost:3000',
217
+ baseURL: resolvedUrls.baseUrl || config.baseUrl || 'http://localhost:3000',
141
218
  database: mongodbAdapter(db),
142
219
  // Enable email/password authentication by default (required by Better-Auth 1.x)
143
220
  // Can be disabled by setting config.emailAndPassword.enabled = false
@@ -178,8 +255,11 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
178
255
  * - `{ option: value }`: Enable with custom settings
179
256
  * - `false` or `{ enabled: false }`: Disable
180
257
  * - `undefined`: Disabled (default)
258
+ *
259
+ * @param config - Better-auth configuration
260
+ * @param passkeyNormalization - Normalized passkey configuration from normalizePasskeyConfig()
181
261
  */
182
- function buildPlugins(config: IBetterAuth): BetterAuthPlugin[] {
262
+ function buildPlugins(config: IBetterAuth, passkeyNormalization: PasskeyNormalizationResult): BetterAuthPlugin[] {
183
263
  const plugins: BetterAuthPlugin[] = [];
184
264
 
185
265
  // JWT Plugin for API client compatibility
@@ -198,8 +278,11 @@ function buildPlugins(config: IBetterAuth): BetterAuthPlugin[] {
198
278
  }
199
279
 
200
280
  // Two-Factor Authentication Plugin
201
- const twoFactorConfig = getPluginConfig(config.twoFactor);
202
- if (twoFactorConfig) {
281
+ // 2FA is enabled by default unless explicitly disabled (twoFactor: false or twoFactor: { enabled: false })
282
+ const twoFactorExplicitlyDisabled =
283
+ config.twoFactor === false || (typeof config.twoFactor === 'object' && config.twoFactor?.enabled === false);
284
+ if (!twoFactorExplicitlyDisabled) {
285
+ const twoFactorConfig = typeof config.twoFactor === 'object' ? config.twoFactor : {};
203
286
  plugins.push(
204
287
  twoFactor({
205
288
  issuer: twoFactorConfig.appName || 'Nest Server',
@@ -208,27 +291,29 @@ function buildPlugins(config: IBetterAuth): BetterAuthPlugin[] {
208
291
  }
209
292
 
210
293
  // Passkey/WebAuthn Plugin
211
- const passkeyConfig = getPluginConfig(config.passkey);
212
- if (passkeyConfig) {
213
- // Build passkey options, only including defined values
294
+ // Uses normalized config from normalizePasskeyConfig() which handles auto-detection
295
+ if (passkeyNormalization.enabled && passkeyNormalization.normalizedConfig) {
296
+ const normalizedPasskey = passkeyNormalization.normalizedConfig;
297
+
298
+ // Build passkey options from normalized config
214
299
  const passkeyOptions: Record<string, unknown> = {
215
- origin: passkeyConfig.origin || 'http://localhost:3000',
216
- rpID: passkeyConfig.rpId || 'localhost',
217
- rpName: passkeyConfig.rpName || 'Nest Server',
300
+ origin: normalizedPasskey.origin,
301
+ rpID: normalizedPasskey.rpId,
302
+ rpName: normalizedPasskey.rpName,
218
303
  };
219
304
 
220
305
  // Add optional WebAuthn configuration if specified
221
- if (passkeyConfig.authenticatorAttachment) {
222
- passkeyOptions.authenticatorAttachment = passkeyConfig.authenticatorAttachment;
306
+ if (normalizedPasskey.authenticatorAttachment) {
307
+ passkeyOptions.authenticatorAttachment = normalizedPasskey.authenticatorAttachment;
223
308
  }
224
- if (passkeyConfig.residentKey) {
225
- passkeyOptions.residentKey = passkeyConfig.residentKey;
309
+ if (normalizedPasskey.residentKey) {
310
+ passkeyOptions.residentKey = normalizedPasskey.residentKey;
226
311
  }
227
- if (passkeyConfig.userVerification) {
228
- passkeyOptions.userVerification = passkeyConfig.userVerification;
312
+ if (normalizedPasskey.userVerification) {
313
+ passkeyOptions.userVerification = normalizedPasskey.userVerification;
229
314
  }
230
- if (passkeyConfig.webAuthnChallengeCookie) {
231
- passkeyOptions.webAuthnChallengeCookie = passkeyConfig.webAuthnChallengeCookie;
315
+ if (normalizedPasskey.webAuthnChallengeCookie) {
316
+ passkeyOptions.webAuthnChallengeCookie = normalizedPasskey.webAuthnChallengeCookie;
232
317
  }
233
318
 
234
319
  plugins.push(passkey(passkeyOptions));
@@ -273,24 +358,32 @@ function buildSocialProviders(config: IBetterAuth): Record<string, SocialProvide
273
358
  *
274
359
  * Behavior:
275
360
  * - If trustedOrigins is explicitly configured → use those origins
276
- * - If Passkey disabled and baseUrl set fallback to [baseUrl] (backwards compatible)
361
+ * - If Passkey is enableduse trustedOrigins from normalizePasskeyConfig()
362
+ * - Fallback to resolved appUrl
277
363
  * - Otherwise → return undefined (allows all origins via Better-Auth's default)
278
364
  *
279
- * NOTE: When Passkey is enabled, trustedOrigins MUST be configured because
280
- * Passkey uses credentials: 'include' which doesn't work with CORS '*' wildcard.
281
- * This is validated in validateConfig() which throws an error if missing.
365
+ * @param config - Better-auth configuration
366
+ * @param passkeyNormalization - Normalized passkey configuration from normalizePasskeyConfig()
367
+ * @param resolvedUrls - Resolved URLs from resolveUrls()
282
368
  */
283
- function buildTrustedOrigins(config: IBetterAuth): string[] | undefined {
369
+ function buildTrustedOrigins(
370
+ config: IBetterAuth,
371
+ passkeyNormalization: PasskeyNormalizationResult,
372
+ resolvedUrls: ResolvedUrls,
373
+ ): string[] | undefined {
284
374
  // If trustedOrigins is explicitly configured, use it
285
375
  if (config.trustedOrigins?.length) {
286
376
  return config.trustedOrigins;
287
377
  }
288
378
 
289
- // Backwards-compatible fallback for non-Passkey configs
290
- // When Passkey is enabled, validateConfig() will throw an error anyway
291
- const isPasskeyEnabled = isPluginEnabled(config.passkey);
292
- if (!isPasskeyEnabled && config.baseUrl) {
293
- return [config.baseUrl];
379
+ // If Passkey is enabled, use the trustedOrigins from normalization
380
+ if (passkeyNormalization.enabled && passkeyNormalization.trustedOrigins?.length) {
381
+ return passkeyNormalization.trustedOrigins;
382
+ }
383
+
384
+ // Fallback to resolved appUrl (for CORS even without Passkey)
385
+ if (resolvedUrls.appUrl) {
386
+ return [resolvedUrls.appUrl];
294
387
  }
295
388
 
296
389
  // Otherwise, let Better-Auth handle CORS with its default behavior
@@ -365,29 +458,6 @@ function getAutoGeneratedSecret(): string {
365
458
  return cachedAutoGeneratedSecret;
366
459
  }
367
460
 
368
- /**
369
- * Gets plugin configuration as object, handling boolean shorthand.
370
- * Returns undefined if disabled, or the config object if enabled.
371
- */
372
- function getPluginConfig<T extends { enabled?: boolean }>(config: boolean | T | undefined): T | undefined {
373
- if (!isPluginEnabled(config)) return undefined;
374
- if (typeof config === 'boolean') return {} as T;
375
- return config;
376
- }
377
-
378
- /**
379
- * Checks if a plugin configuration is enabled.
380
- * Supports both boolean and object configuration:
381
- * - `true` or `{}` or `{ enabled: true }` → enabled
382
- * - `false` or `{ enabled: false }` → disabled
383
- * - `undefined` → disabled
384
- */
385
- function isPluginEnabled<T extends { enabled?: boolean }>(config: boolean | T | undefined): boolean {
386
- if (config === undefined) return false;
387
- if (typeof config === 'boolean') return config;
388
- return config.enabled !== false;
389
- }
390
-
391
461
  /**
392
462
  * Checks if a secret has valid minimum length (32 characters)
393
463
  */
@@ -407,6 +477,263 @@ function isValidUrl(url: string): boolean {
407
477
  }
408
478
  }
409
479
 
480
+ /**
481
+ * Default URLs for local/test environments (local, ci, e2e)
482
+ * These environments typically run on localhost and don't have a deployed domain.
483
+ */
484
+ const LOCALHOST_DEFAULTS = {
485
+ API_URL: 'http://localhost:3000',
486
+ APP_URL: 'http://localhost:3001',
487
+ };
488
+
489
+ /**
490
+ * Environments that use localhost defaults for URLs.
491
+ * These are typically development/test environments without deployed domains.
492
+ */
493
+ const LOCALHOST_ENVS = ['local', 'ci', 'e2e'];
494
+
495
+ /**
496
+ * Resolves the effective URLs for BetterAuth configuration.
497
+ *
498
+ * Resolution priority:
499
+ * 1. Explicit betterAuth config values
500
+ * 2. Server-level URLs (IServerOptions.baseUrl, IServerOptions.appUrl)
501
+ * 3. Auto-derived values (appUrl from baseUrl)
502
+ * 4. Local environment defaults (when env: 'local')
503
+ *
504
+ * @param options - CreateBetterAuthOptions containing config and server URLs
505
+ * @returns Resolved URLs with warnings for logging
506
+ */
507
+ interface ResolvedUrls {
508
+ appUrl: string | undefined;
509
+ baseUrl: string | undefined;
510
+ rpId: string | undefined;
511
+ warnings: string[];
512
+ }
513
+
514
+ /**
515
+ * Derives appUrl from baseUrl by removing 'api.' prefix from subdomain.
516
+ *
517
+ * Examples:
518
+ * - 'https://api.example.com' → 'https://example.com'
519
+ * - 'https://api.dev.example.com' → 'https://dev.example.com'
520
+ * - 'https://example.com' → 'https://example.com' (unchanged)
521
+ * - 'http://localhost:3000' → 'http://localhost:3000' (unchanged)
522
+ *
523
+ * @param baseUrl - The API base URL
524
+ * @returns Derived app URL or the original URL if no 'api.' prefix
525
+ */
526
+ function deriveAppUrlFromBaseUrl(baseUrl: string): string {
527
+ try {
528
+ const url = new URL(baseUrl);
529
+ const hostname = url.hostname;
530
+
531
+ // Check if hostname starts with 'api.'
532
+ if (hostname.startsWith('api.')) {
533
+ // Remove 'api.' prefix
534
+ url.hostname = hostname.substring(4);
535
+ return url.origin;
536
+ }
537
+
538
+ // Return original URL if no 'api.' prefix
539
+ return url.origin;
540
+ } catch {
541
+ return baseUrl;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Extracts the root domain from a URL for use as Passkey rpId.
547
+ *
548
+ * For localhost: Returns 'localhost' (not 'localhost:3000')
549
+ * For domains: Returns the registrable domain (e.g., 'example.com')
550
+ *
551
+ * Examples:
552
+ * - 'https://api.example.com' → 'example.com'
553
+ * - 'https://app.dev.example.com' → 'example.com'
554
+ * - 'http://localhost:3000' → 'localhost'
555
+ * - 'https://example.com' → 'example.com'
556
+ *
557
+ * @param url - The URL to extract rpId from
558
+ * @returns The root domain suitable for Passkey rpId
559
+ */
560
+ function extractRpIdFromUrl(url: string): string {
561
+ try {
562
+ const parsedUrl = new URL(url);
563
+ const hostname = parsedUrl.hostname;
564
+
565
+ // localhost is a special case
566
+ if (hostname === 'localhost' || hostname === '127.0.0.1') {
567
+ return 'localhost';
568
+ }
569
+
570
+ // For regular domains, extract the registrable domain
571
+ // This is a simplified approach - for complex TLDs like .co.uk, a library would be better
572
+ const parts = hostname.split('.');
573
+
574
+ // If it's just a simple domain (example.com), return it
575
+ if (parts.length <= 2) {
576
+ return hostname;
577
+ }
578
+
579
+ // For subdomains, return the last two parts (example.com from api.example.com)
580
+ // Note: This doesn't handle .co.uk style TLDs correctly, but works for common cases
581
+ return parts.slice(-2).join('.');
582
+ } catch {
583
+ return 'localhost';
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Normalizes Passkey configuration with auto-detection from resolved URLs.
589
+ *
590
+ * **AUTO-ACTIVATION:** Passkey is enabled by default when BetterAuth is active.
591
+ * It is only disabled when:
592
+ * 1. Explicitly disabled: `passkey: false` or `passkey: { enabled: false }`
593
+ * 2. Required URLs cannot be resolved (Graceful Degradation)
594
+ *
595
+ * Auto-Detection Logic (using resolvedUrls from resolveUrls()):
596
+ * - `rpId`: from passkey.rpId > resolvedUrls.rpId (derived from appUrl)
597
+ * - `origin`: from passkey.origin > resolvedUrls.appUrl
598
+ * - `trustedOrigins`: from config.trustedOrigins > [resolvedUrls.appUrl]
599
+ *
600
+ * @param config - Better-auth configuration
601
+ * @param resolvedUrls - Resolved URLs from resolveUrls()
602
+ * @returns Normalization result with enabled status, config, and warnings
603
+ */
604
+ function normalizePasskeyConfig(config: IBetterAuth, resolvedUrls: ResolvedUrls): PasskeyNormalizationResult {
605
+ const warnings: string[] = [];
606
+
607
+ // Check if Passkey is explicitly DISABLED
608
+ // passkey: false OR passkey: { enabled: false }
609
+ const isExplicitlyDisabled =
610
+ config.passkey === false || (typeof config.passkey === 'object' && config.passkey?.enabled === false);
611
+
612
+ if (isExplicitlyDisabled) {
613
+ return { enabled: false, normalizedConfig: null, trustedOrigins: null, warnings };
614
+ }
615
+
616
+ // Get the raw passkey config (if object)
617
+ const rawConfig = typeof config.passkey === 'object' ? config.passkey : {};
618
+
619
+ // Resolve values: explicit config > resolved URLs
620
+ const finalRpId = rawConfig.rpId || resolvedUrls.rpId;
621
+ const finalOrigin = rawConfig.origin || resolvedUrls.appUrl;
622
+ const finalTrustedOrigins = config.trustedOrigins?.length ? config.trustedOrigins : resolvedUrls.appUrl ? [resolvedUrls.appUrl] : undefined;
623
+
624
+ // Check if we have all required values for Passkey
625
+ const hasRequiredConfig = finalRpId && finalOrigin && finalTrustedOrigins?.length;
626
+
627
+ if (!hasRequiredConfig) {
628
+ // Graceful Degradation: Disable Passkey with warning
629
+ const missingFields: string[] = [];
630
+ if (!finalRpId) missingFields.push('rpId');
631
+ if (!finalOrigin) missingFields.push('origin');
632
+ if (!finalTrustedOrigins?.length) missingFields.push('trustedOrigins');
633
+
634
+ warnings.push(
635
+ `⚠️ PASSKEY DISABLED: Cannot auto-detect required values (${missingFields.join(', ')}). ` +
636
+ 'To enable Passkey, set baseUrl in your config or configure passkey values explicitly.',
637
+ );
638
+ warnings.push(
639
+ 'Fix options:\n' +
640
+ ' 1. Set baseUrl: "https://api.example.com" → auto-detects appUrl, rpId, origin\n' +
641
+ ' 2. Set env: "local" → uses localhost:3000 (API) and localhost:3001 (App) defaults\n' +
642
+ ' 3. Disable Passkey explicitly: passkey: false (no warning)',
643
+ );
644
+
645
+ return { enabled: false, normalizedConfig: null, trustedOrigins: null, warnings };
646
+ }
647
+
648
+ // Log auto-detection info
649
+ if (!rawConfig.rpId && resolvedUrls.rpId) {
650
+ warnings.push(`PASSKEY: Using rpId="${finalRpId}" (auto-detected from appUrl)`);
651
+ }
652
+ if (!rawConfig.origin && resolvedUrls.appUrl) {
653
+ warnings.push(`PASSKEY: Using origin="${finalOrigin}" (= appUrl)`);
654
+ }
655
+ if (!config.trustedOrigins?.length && resolvedUrls.appUrl) {
656
+ warnings.push(`PASSKEY: Using trustedOrigins=${JSON.stringify(finalTrustedOrigins)} (= [appUrl])`);
657
+ }
658
+
659
+ // Build normalized config
660
+ const normalizedConfig: NormalizedPasskeyConfig = {
661
+ origin: finalOrigin,
662
+ rpId: finalRpId,
663
+ rpName: rawConfig.rpName || 'Nest Server',
664
+ };
665
+
666
+ // Copy optional fields from explicit config
667
+ if (rawConfig.authenticatorAttachment) {
668
+ normalizedConfig.authenticatorAttachment = rawConfig.authenticatorAttachment;
669
+ }
670
+ if (rawConfig.challengeStorage) {
671
+ normalizedConfig.challengeStorage = rawConfig.challengeStorage;
672
+ }
673
+ if (rawConfig.challengeTtlSeconds) {
674
+ normalizedConfig.challengeTtlSeconds = rawConfig.challengeTtlSeconds;
675
+ }
676
+ if (rawConfig.residentKey) {
677
+ normalizedConfig.residentKey = rawConfig.residentKey;
678
+ }
679
+ if (rawConfig.userVerification) {
680
+ normalizedConfig.userVerification = rawConfig.userVerification;
681
+ }
682
+ if (rawConfig.webAuthnChallengeCookie) {
683
+ normalizedConfig.webAuthnChallengeCookie = rawConfig.webAuthnChallengeCookie;
684
+ }
685
+
686
+ return {
687
+ enabled: true,
688
+ normalizedConfig,
689
+ trustedOrigins: finalTrustedOrigins,
690
+ warnings,
691
+ };
692
+ }
693
+
694
+ function resolveUrls(options: CreateBetterAuthOptions): ResolvedUrls {
695
+ const { config, serverAppUrl, serverBaseUrl, serverEnv } = options;
696
+ const warnings: string[] = [];
697
+ const usesLocalhostDefaults = LOCALHOST_ENVS.includes(serverEnv || '');
698
+
699
+ // Step 1: Resolve baseUrl
700
+ // Priority: betterAuth.baseUrl > serverBaseUrl > localhost default (for local/ci/e2e)
701
+ let baseUrl = config.baseUrl || serverBaseUrl;
702
+ if (!baseUrl && usesLocalhostDefaults) {
703
+ baseUrl = LOCALHOST_DEFAULTS.API_URL;
704
+ warnings.push(`URL: Using localhost default baseUrl="${baseUrl}" (env: '${serverEnv}')`);
705
+ }
706
+
707
+ // Step 2: Resolve appUrl
708
+ // Priority: serverAppUrl > localhost default (when baseUrl is localhost) > derived from baseUrl
709
+ let appUrl = serverAppUrl;
710
+
711
+ // For localhost environments with localhost baseUrl, use localhost app default
712
+ // This handles the common case where API runs on :3000 and App on :3001
713
+ const isBaseUrlLocalhost = baseUrl && (baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1'));
714
+ if (!appUrl && usesLocalhostDefaults && isBaseUrlLocalhost) {
715
+ appUrl = LOCALHOST_DEFAULTS.APP_URL;
716
+ warnings.push(`URL: Using localhost default appUrl="${appUrl}" (env: '${serverEnv}')`);
717
+ }
718
+
719
+ // For non-localhost environments, try to derive appUrl from baseUrl
720
+ if (!appUrl && baseUrl) {
721
+ appUrl = deriveAppUrlFromBaseUrl(baseUrl);
722
+ if (appUrl !== baseUrl) {
723
+ warnings.push(`URL: Auto-derived appUrl="${appUrl}" from baseUrl="${baseUrl}"`);
724
+ }
725
+ }
726
+
727
+ // Step 3: Resolve rpId from appUrl (not baseUrl!)
728
+ // rpId should be the domain where the user authenticates (app domain)
729
+ let rpId: string | undefined;
730
+ if (appUrl) {
731
+ rpId = extractRpIdFromUrl(appUrl);
732
+ }
733
+
734
+ return { appUrl, baseUrl, rpId, warnings };
735
+ }
736
+
410
737
  /**
411
738
  * Validates the better-auth configuration and applies fallback secret if needed.
412
739
  * Mutates config.secret if fallback is applied.
@@ -418,8 +745,13 @@ function isValidUrl(url: string): boolean {
418
745
  *
419
746
  * @param config - Better-auth configuration
420
747
  * @param fallbackSecrets - Optional array of fallback secrets to try
748
+ * @param passkeyNormalization - Result of passkey normalization (handles graceful degradation)
421
749
  */
422
- function validateConfig(config: IBetterAuth, fallbackSecrets?: (string | undefined)[]): ValidationResult {
750
+ function validateConfig(
751
+ config: IBetterAuth,
752
+ fallbackSecrets?: (string | undefined)[],
753
+ passkeyNormalization?: PasskeyNormalizationResult,
754
+ ): ValidationResult {
423
755
  const errors: string[] = [];
424
756
  const warnings: string[] = [];
425
757
 
@@ -472,7 +804,7 @@ function validateConfig(config: IBetterAuth, fallbackSecrets?: (string | undefin
472
804
  errors.push(`Invalid baseUrl format: ${config.baseUrl}`);
473
805
  }
474
806
 
475
- // Validate trustedOrigins
807
+ // Validate trustedOrigins (if explicitly configured)
476
808
  if (config.trustedOrigins) {
477
809
  for (const origin of config.trustedOrigins) {
478
810
  if (!isValidUrl(origin)) {
@@ -481,21 +813,21 @@ function validateConfig(config: IBetterAuth, fallbackSecrets?: (string | undefin
481
813
  }
482
814
  }
483
815
 
484
- // ERROR if Passkey is enabled but trustedOrigins is not configured
485
- // Passkey uses credentials: 'include', which doesn't work with CORS '*' wildcard
486
- const isPasskeyEnabled = isPluginEnabled(config.passkey);
487
- if (isPasskeyEnabled && !config.trustedOrigins?.length) {
488
- errors.push(
489
- 'PASSKEY CORS: trustedOrigins is REQUIRED when Passkey is enabled. ' +
490
- 'Passkey uses credentials which requires explicit CORS origins (wildcard "*" is not allowed). ' +
491
- 'Configure betterAuth.trustedOrigins with your frontend URLs, e.g.: ["http://localhost:3001", "https://app.example.com"]',
492
- );
816
+ // Validate auto-detected trustedOrigins from passkey normalization
817
+ if (passkeyNormalization?.trustedOrigins) {
818
+ for (const origin of passkeyNormalization.trustedOrigins) {
819
+ if (!isValidUrl(origin)) {
820
+ errors.push(`Invalid auto-detected trustedOrigin format: ${origin}`);
821
+ }
822
+ }
493
823
  }
494
824
 
495
- // Validate passkey origin
496
- const passkeyConfig = typeof config.passkey === 'object' ? config.passkey : null;
497
- if (passkeyConfig?.enabled && passkeyConfig.origin && !isValidUrl(passkeyConfig.origin)) {
498
- errors.push(`Invalid passkey origin format: ${passkeyConfig.origin}`);
825
+ // Validate passkey origin (only if passkey is enabled after normalization)
826
+ if (passkeyNormalization?.enabled && passkeyNormalization.normalizedConfig) {
827
+ const normalizedOrigin = passkeyNormalization.normalizedConfig.origin;
828
+ if (!isValidUrl(normalizedOrigin)) {
829
+ errors.push(`Invalid passkey origin format: ${normalizedOrigin}`);
830
+ }
499
831
  }
500
832
 
501
833
  // Validate social providers dynamically