@lenne.tech/nest-server 11.24.4 → 11.25.1

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 (64) hide show
  1. package/.claude/rules/configurable-features.md +2 -0
  2. package/CLAUDE.md +13 -0
  3. package/FRAMEWORK-API.md +14 -2
  4. package/README.md +15 -0
  5. package/dist/config.env.js +100 -81
  6. package/dist/config.env.js.map +1 -1
  7. package/dist/core/common/helpers/cookies.helper.d.ts +19 -0
  8. package/dist/core/common/helpers/cookies.helper.js +109 -0
  9. package/dist/core/common/helpers/cookies.helper.js.map +1 -0
  10. package/dist/core/common/interfaces/server-options.interface.d.ts +11 -1
  11. package/dist/core/modules/auth/core-auth.controller.js +4 -16
  12. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  13. package/dist/core/modules/auth/core-auth.resolver.js +4 -16
  14. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  15. package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
  16. package/dist/core/modules/better-auth/better-auth.config.d.ts +24 -1
  17. package/dist/core/modules/better-auth/better-auth.config.js +22 -2
  18. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -0
  20. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  21. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.d.ts +3 -1
  22. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +7 -3
  23. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -1
  24. package/dist/core/modules/better-auth/core-better-auth.controller.js +7 -3
  25. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  26. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +2 -1
  27. package/dist/core/modules/better-auth/core-better-auth.module.js +4 -1
  28. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  29. package/dist/core/modules/better-auth/core-better-auth.service.js +5 -4
  30. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  31. package/dist/core/modules/migrate/templates/migration-project.template.ts +16 -2
  32. package/dist/core.module.d.ts +3 -1
  33. package/dist/core.module.js +10 -7
  34. package/dist/core.module.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/main.js +17 -3
  39. package/dist/main.js.map +1 -1
  40. package/dist/test/test.helper.d.ts +1 -0
  41. package/dist/test/test.helper.js +2 -1
  42. package/dist/test/test.helper.js.map +1 -1
  43. package/dist/tsconfig.build.tsbuildinfo +1 -1
  44. package/docs/REQUEST-LIFECYCLE.md +78 -3
  45. package/migration-guides/11.24.x-to-11.25.0.md +438 -0
  46. package/package.json +23 -21
  47. package/src/config.env.ts +116 -111
  48. package/src/core/common/helpers/cookies.helper.ts +298 -0
  49. package/src/core/common/interfaces/server-options.interface.ts +141 -2
  50. package/src/core/modules/auth/core-auth.controller.ts +11 -23
  51. package/src/core/modules/auth/core-auth.resolver.ts +11 -23
  52. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +18 -0
  53. package/src/core/modules/better-auth/README.md +7 -0
  54. package/src/core/modules/better-auth/better-auth.config.ts +53 -15
  55. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +6 -3
  56. package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +33 -7
  57. package/src/core/modules/better-auth/core-better-auth.controller.ts +12 -3
  58. package/src/core/modules/better-auth/core-better-auth.module.ts +16 -1
  59. package/src/core/modules/better-auth/core-better-auth.service.ts +26 -10
  60. package/src/core/modules/migrate/templates/migration-project.template.ts +16 -2
  61. package/src/core.module.ts +40 -12
  62. package/src/index.ts +1 -0
  63. package/src/main.ts +32 -5
  64. package/src/test/test.helper.ts +15 -1
@@ -7,7 +7,7 @@ import * as crypto from 'crypto';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
 
10
- import { IBetterAuth } from '../../common/interfaces/server-options.interface';
10
+ import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
11
11
 
12
12
  /**
13
13
  * Type for better-auth instance with plugins
@@ -139,6 +139,14 @@ export interface CreateBetterAuthOptions {
139
139
  */
140
140
  serverBaseUrl?: string;
141
141
 
142
+ /**
143
+ * Server-level CORS configuration (from IServerOptions.cors).
144
+ * Used to align BetterAuth's trustedOrigins with the server-level CORS config.
145
+ *
146
+ * @since 11.25.0
147
+ */
148
+ serverCorsConfig?: boolean | ICorsConfig;
149
+
142
150
  /**
143
151
  * Server environment (from IServerOptions.env).
144
152
  * Used for local environment defaults.
@@ -298,7 +306,11 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
298
306
  // Build configuration components (pass normalized passkey config and resolved URLs)
299
307
  const plugins = buildPlugins(config, { passkeyNormalization, serverEnv });
300
308
  const socialProviders = buildSocialProviders(config);
301
- const trustedOrigins = buildTrustedOrigins(config, { passkeyNormalization, resolvedUrls });
309
+ const trustedOrigins = buildTrustedOrigins(config, {
310
+ passkeyNormalization,
311
+ resolvedUrls,
312
+ serverCorsConfig: options.serverCorsConfig,
313
+ });
302
314
  const additionalFields = buildUserFields(config);
303
315
 
304
316
  // Resolve cross-subdomain cookies (Boolean Shorthand)
@@ -598,40 +610,66 @@ function buildSocialProviders(config: IBetterAuth): Record<string, SocialProvide
598
610
  /**
599
611
  * Builds the trusted origins array for CORS configuration.
600
612
  *
601
- * Behavior:
602
- * - If trustedOrigins is explicitly configured → use those origins
603
- * - If Passkey is enabled use trustedOrigins from normalizePasskeyConfig()
604
- * - Fallback to resolved appUrl
605
- * - Otherwise return undefined (allows all origins via Better-Auth's default)
613
+ * Behavior (in priority order):
614
+ * 1. Explicit betterAuth.trustedOrigins → use those (always takes precedence)
615
+ * 2. Server CORS disabledempty array (BetterAuth CORS also disabled)
616
+ * 3. Server CORS allowAll → undefined (BetterAuth allows all origins)
617
+ * 4. Server CORS allowedOrigins merge with appUrl/baseUrl
618
+ * 5. Passkey trustedOrigins → use from normalizePasskeyConfig()
619
+ * 6. Fallback to resolved appUrl
620
+ * 7. Otherwise → undefined (allows all origins via Better-Auth's default)
606
621
  *
607
622
  * @param config - Better-auth configuration
608
- * @param options - Passkey normalization and resolved URLs context
623
+ * @param options - Passkey normalization, resolved URLs, and server CORS context
609
624
  */
610
- function buildTrustedOrigins(
625
+ export function buildTrustedOrigins(
611
626
  config: IBetterAuth,
612
627
  options: {
613
628
  passkeyNormalization: PasskeyNormalizationResult;
614
629
  resolvedUrls: ResolvedUrls;
630
+ serverCorsConfig?: boolean | ICorsConfig;
615
631
  },
616
632
  ): string[] | undefined {
617
- const { passkeyNormalization, resolvedUrls } = options;
618
- // If trustedOrigins is explicitly configured, use it
633
+ const { passkeyNormalization, resolvedUrls, serverCorsConfig } = options;
634
+
635
+ // 1. Explicit betterAuth.trustedOrigins always takes precedence (backward compatible)
619
636
  if (config.trustedOrigins?.length) {
620
637
  return config.trustedOrigins;
621
638
  }
622
639
 
623
- // If Passkey is enabled, use the trustedOrigins from normalization
640
+ // 2. Server CORS disabled BetterAuth CORS also disabled
641
+ if (serverCorsConfig === false || (typeof serverCorsConfig === 'object' && serverCorsConfig?.enabled === false)) {
642
+ return [];
643
+ }
644
+
645
+ // 3. Server CORS allowAll → BetterAuth allows all origins
646
+ if (typeof serverCorsConfig === 'object' && serverCorsConfig?.allowAll) {
647
+ return undefined;
648
+ }
649
+
650
+ // 4. Server allowedOrigins → merge with appUrl/baseUrl.
651
+ // Order (appUrl, baseUrl, allowedOrigins) mirrors buildCorsConfig() in cookies.helper.ts
652
+ // for consistency — the final Set is order-equivalent, but the array order helps readers
653
+ // trace which value came from which source.
654
+ if (typeof serverCorsConfig === 'object' && serverCorsConfig?.allowedOrigins?.length) {
655
+ const origins: string[] = [];
656
+ if (resolvedUrls.appUrl) origins.push(resolvedUrls.appUrl);
657
+ if (resolvedUrls.baseUrl) origins.push(resolvedUrls.baseUrl);
658
+ origins.push(...serverCorsConfig.allowedOrigins);
659
+ return [...new Set(origins)];
660
+ }
661
+
662
+ // 5. If Passkey is enabled, use the trustedOrigins from normalization
624
663
  if (passkeyNormalization.enabled && passkeyNormalization.trustedOrigins?.length) {
625
664
  return passkeyNormalization.trustedOrigins;
626
665
  }
627
666
 
628
- // Fallback to resolved appUrl (for CORS even without Passkey)
667
+ // 6. Fallback to resolved appUrl (for CORS even without Passkey)
629
668
  if (resolvedUrls.appUrl) {
630
669
  return [resolvedUrls.appUrl];
631
670
  }
632
671
 
633
- // Otherwise, let Better-Auth handle CORS with its default behavior
634
- // This allows all origins for requests without credentials
672
+ // 7. Otherwise, let Better-Auth handle CORS with its default behavior
635
673
  return undefined;
636
674
  }
637
675
 
@@ -2,6 +2,7 @@ import { Injectable, Logger, NestMiddleware, Optional } from '@nestjs/common';
2
2
  import { Response as ExpressResponse, NextFunction, Request } from 'express';
3
3
 
4
4
  import { isProduction } from '../../common/helpers/logging.helper';
5
+ import { ConfigService } from '../../common/services/config.service';
5
6
  import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
6
7
  import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
7
8
  import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
@@ -64,17 +65,19 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
64
65
  * Gets or creates the cookie helper instance.
65
66
  * Lazy initialization because betterAuthService may not be fully initialized in constructor.
66
67
  *
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).
68
+ * Note: Legacy cookie is not enabled in middleware. The middleware only needs to set
69
+ * the native Better-Auth cookie. Secret is required for cookie signing (Passkey/2FA).
70
+ * `env` is read via the frozen ConfigService snapshot so the `secure` flag covers staging.
70
71
  */
71
72
  private getCookieHelper(): BetterAuthCookieHelper {
72
73
  if (!this.cookieHelper) {
73
74
  const config = this.betterAuthService.getConfig();
75
+ const env = ConfigService.configFastButReadOnly?.env as string | undefined;
74
76
  this.cookieHelper = createCookieHelper(
75
77
  this.betterAuthService.getBasePath(),
76
78
  {
77
79
  domain: this.betterAuthService.getCookieDomain(),
80
+ env,
78
81
  legacyCookieEnabled: false, // Middleware doesn't need legacy cookie
79
82
  secret: config?.secret, // Required for cookie signing
80
83
  },
@@ -1,6 +1,7 @@
1
1
  import { Logger } from '@nestjs/common';
2
2
  import { Response } from 'express';
3
3
 
4
+ import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
4
5
  import { signCookieValue } from './core-better-auth-web.helper';
5
6
 
6
7
  /**
@@ -45,6 +46,14 @@ export interface BetterAuthCookieHelperConfig {
45
46
  * @example 'example.com' → cookies shared across api.example.com, ws.example.com, etc.
46
47
  */
47
48
  domain?: string;
49
+ /**
50
+ * App-level environment from `IServerOptions.env` (e.g. `'production'`, `'staging'`).
51
+ * Used to derive the `secure` flag for cookies in conjunction with `process.env.NODE_ENV`.
52
+ *
53
+ * @since 11.25.0 Covers staging deployments where `config.env === 'staging'` but
54
+ * `NODE_ENV !== 'production'`.
55
+ */
56
+ env?: string;
48
57
  /**
49
58
  * Enable legacy 'token' cookie for backwards compatibility with < 11.7.0.
50
59
  * Only needed when Legacy Auth (Passport) is also active.
@@ -122,13 +131,17 @@ export class BetterAuthCookieHelper {
122
131
  /**
123
132
  * Gets the default cookie options for authentication cookies.
124
133
  *
134
+ * The `secure` flag is `true` when either `config.env` is production-like
135
+ * (`production` / `staging`) or `NODE_ENV === 'production'`. This covers
136
+ * staging deployments where only one of the two signals is set.
137
+ *
125
138
  * @returns Cookie options with httpOnly, sameSite, and secure settings
126
139
  */
127
140
  getDefaultCookieOptions(): AuthCookieOptions {
128
141
  const options: AuthCookieOptions = {
129
142
  httpOnly: true,
130
143
  sameSite: 'lax',
131
- secure: process.env.NODE_ENV === 'production',
144
+ secure: isProductionLikeEnv(this.config.env),
132
145
  };
133
146
 
134
147
  if (this.config.domain) {
@@ -286,18 +299,28 @@ export class BetterAuthCookieHelper {
286
299
  }
287
300
 
288
301
  /**
289
- * Processes a result object by setting appropriate cookies and removing token from body.
302
+ * Processes a result object by setting appropriate cookies and optionally keeping token in body.
290
303
  *
291
304
  * This method handles the common pattern of:
292
305
  * 1. Setting cookies if cookie handling is enabled
293
- * 2. Removing the token from the response body (it's now in cookies)
306
+ * 2. Removing the token from the response body (unless exposeTokenInBody is true)
307
+ *
308
+ * When exposeTokenInBody is false (default), the token is removed from the response
309
+ * body because the httpOnly cookie provides XSS protection — exposing the token in
310
+ * the body would negate this security benefit.
294
311
  *
295
312
  * @param res - Express Response object
296
313
  * @param result - The result object to process (modified in place)
297
314
  * @param cookiesEnabled - Whether cookie handling is enabled (from config)
315
+ * @param exposeTokenInBody - Whether to keep the token in the response body (default: false)
298
316
  * @returns The modified result (same reference as input)
299
317
  */
300
- processAuthResult<T extends CookieProcessingResult>(res: Response, result: T, cookiesEnabled: boolean): T {
318
+ processAuthResult<T extends CookieProcessingResult>(
319
+ res: Response,
320
+ result: T,
321
+ cookiesEnabled: boolean,
322
+ exposeTokenInBody: boolean = false,
323
+ ): T {
301
324
  if (!cookiesEnabled) {
302
325
  return result;
303
326
  }
@@ -306,8 +329,10 @@ export class BetterAuthCookieHelper {
306
329
  // Set cookies
307
330
  this.setSessionCookies(res, result.token);
308
331
 
309
- // Remove token from response body (it's now in cookies)
310
- delete result.token;
332
+ // Remove token from response body unless exposeTokenInBody is enabled
333
+ if (!exposeTokenInBody) {
334
+ delete result.token;
335
+ }
311
336
  }
312
337
 
313
338
  return result;
@@ -326,12 +351,13 @@ export class BetterAuthCookieHelper {
326
351
  */
327
352
  export function createCookieHelper(
328
353
  basePath: string,
329
- options?: { domain?: string; legacyCookieEnabled?: boolean; secret?: string },
354
+ options?: { domain?: string; env?: string; legacyCookieEnabled?: boolean; secret?: string },
330
355
  logger?: Logger,
331
356
  ): BetterAuthCookieHelper {
332
357
  return new BetterAuthCookieHelper({
333
358
  basePath,
334
359
  domain: options?.domain,
360
+ env: options?.env,
335
361
  legacyCookieEnabled: options?.legacyCookieEnabled ?? false,
336
362
  logger,
337
363
  secret: options?.secret,
@@ -27,6 +27,8 @@ import { Request, Response } from 'express';
27
27
 
28
28
  import { Roles } from '../../common/decorators/roles.decorator';
29
29
  import { RoleEnum } from '../../common/enums/role.enum';
30
+ import { isCookiesEnabled, isExposeTokenInBodyEnabled } from '../../common/helpers/cookies.helper';
31
+ import type { ICookiesConfig } from '../../common/interfaces/server-options.interface';
30
32
  import { maskEmail, maskToken } from '../../common/helpers/logging.helper';
31
33
  import { ConfigService } from '../../common/services/config.service';
32
34
  import { ErrorCode } from '../error-code/error-codes';
@@ -224,6 +226,7 @@ export class CoreBetterAuthController {
224
226
  this.betterAuthService.getBasePath(),
225
227
  {
226
228
  domain: this.betterAuthService.getCookieDomain(),
229
+ env: this.configService.getFastButReadOnly<string>('env'),
227
230
  legacyCookieEnabled: legacyAuthEnabled,
228
231
  secret: betterAuthConfig?.secret,
229
232
  },
@@ -828,19 +831,25 @@ export class CoreBetterAuthController {
828
831
  result: CoreBetterAuthResponse,
829
832
  sessionToken?: string,
830
833
  ): CoreBetterAuthResponse {
831
- const cookiesEnabled = this.configService.getFastButReadOnly('cookies') !== false;
834
+ // Cookies config is read per-request (not cached in the constructor) because tests
835
+ // and runtime config changes (e.g. `ConfigService.setProperty('cookies', ...)`) must
836
+ // take effect immediately. The `getFastButReadOnly` call is a single property access
837
+ // on a frozen snapshot — overhead is negligible.
838
+ const cookiesConfig = this.configService.getFastButReadOnly<boolean | ICookiesConfig>('cookies');
839
+ const cookiesEnabled = isCookiesEnabled(cookiesConfig);
840
+ const exposeTokenInBody = isExposeTokenInBodyEnabled(cookiesConfig);
832
841
 
833
842
  // If a specific session token is provided, use it directly
834
843
  if (sessionToken && cookiesEnabled) {
835
844
  this.cookieHelper.setSessionCookies(res, sessionToken, result.session?.id);
836
- if (result.token) {
845
+ if (!exposeTokenInBody && result.token) {
837
846
  delete result.token;
838
847
  }
839
848
  return result;
840
849
  }
841
850
 
842
851
  // Otherwise, use the cookie helper's standard processing
843
- return this.cookieHelper.processAuthResult(res, result, cookiesEnabled);
852
+ return this.cookieHelper.processAuthResult(res, result, cookiesEnabled, exposeTokenInBody);
844
853
  }
845
854
 
846
855
  /**
@@ -13,7 +13,7 @@ import { APP_GUARD } from '@nestjs/core';
13
13
  import { getConnectionToken } from '@nestjs/mongoose';
14
14
  import mongoose, { Connection } from 'mongoose';
15
15
 
16
- import { IBetterAuth } from '../../common/interfaces/server-options.interface';
16
+ import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
17
17
  import { BrevoService } from '../../common/services/brevo.service';
18
18
  import { ConfigService } from '../../common/services/config.service';
19
19
  import { RolesGuardRegistry } from '../auth/guards/roles-guard-registry';
@@ -161,6 +161,14 @@ export interface CoreBetterAuthModuleOptions {
161
161
  */
162
162
  serverBaseUrl?: string;
163
163
 
164
+ /**
165
+ * Server-level CORS configuration (from IServerOptions.cors).
166
+ * Used to align BetterAuth's trustedOrigins with the server-level CORS config.
167
+ *
168
+ * @since 11.25.0
169
+ */
170
+ serverCorsConfig?: boolean | ICorsConfig;
171
+
164
172
  /**
165
173
  * Server environment (from IServerOptions.env).
166
174
  * Used for local environment defaults:
@@ -452,6 +460,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
452
460
  resolver,
453
461
  serverAppUrl,
454
462
  serverBaseUrl,
463
+ serverCorsConfig,
455
464
  serverEnv,
456
465
  } = options;
457
466
 
@@ -535,10 +544,14 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
535
544
  // Always use deferred initialization to ensure MongoDB is ready
536
545
  // This prevents timing issues during application startup
537
546
  // Pass server-level URLs for Passkey auto-detection (using effective values from ConfigService fallback)
547
+ // Auto-detect server CORS config from ConfigService if not explicitly provided
548
+ const effectiveServerCorsConfig = serverCorsConfig ?? globalConfig?.cors;
549
+
538
550
  this.cachedDynamicModule = this.createDeferredModule(config, {
539
551
  fallbackSecrets: effectiveFallbackSecrets,
540
552
  serverAppUrl: effectiveServerAppUrl,
541
553
  serverBaseUrl: effectiveServerBaseUrl,
554
+ serverCorsConfig: effectiveServerCorsConfig,
542
555
  serverEnv: effectiveServerEnv,
543
556
  });
544
557
  return this.cachedDynamicModule;
@@ -816,6 +829,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
816
829
  fallbackSecrets?: (string | undefined)[];
817
830
  serverAppUrl?: string;
818
831
  serverBaseUrl?: string;
832
+ serverCorsConfig?: boolean | ICorsConfig;
819
833
  serverEnv?: string;
820
834
  },
821
835
  ): DynamicModule {
@@ -872,6 +886,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
872
886
  sendVerificationEmail,
873
887
  serverAppUrl: options?.serverAppUrl,
874
888
  serverBaseUrl: options?.serverBaseUrl,
889
+ serverCorsConfig: options?.serverCorsConfig,
875
890
  serverEnv: options?.serverEnv,
876
891
  };
877
892
 
@@ -4,8 +4,9 @@ import { Request } from 'express';
4
4
  import { importJWK, jwtVerify } from 'jose';
5
5
  import { Connection } from 'mongoose';
6
6
 
7
+ import { shouldConvertSessionTokenToJwt } from '../../common/helpers/cookies.helper';
7
8
  import { maskEmail, maskToken } from '../../common/helpers/logging.helper';
8
- import { IBetterAuth } from '../../common/interfaces/server-options.interface';
9
+ import { IBetterAuth, ICookiesConfig } from '../../common/interfaces/server-options.interface';
9
10
  import { ConfigService } from '../../common/services/config.service';
10
11
  import { ErrorCode } from '../error-code/error-codes';
11
12
  import { BetterAuthInstance } from './better-auth.config';
@@ -288,20 +289,32 @@ export class CoreBetterAuthService implements OnModuleInit {
288
289
  // ===================================================================================================================
289
290
 
290
291
  /**
291
- * Resolves a session token to a JWT when cookies are disabled and JWT is enabled.
292
+ * Resolves a session token to a JWT when JWT conversion is needed.
292
293
  *
293
- * When cookies are disabled, BetterAuth's internal API may return a session token
294
- * (opaque string like "TVRRtiL19h9q...") instead of a JWT. This method detects
295
- * non-JWT tokens and converts them to proper JWTs via the BetterAuth JWT plugin.
294
+ * JWT conversion is performed when:
295
+ * - Cookies are disabled (JWT-only mode) clients need a JWT in the response body
296
+ * - exposeTokenInBody is true clients need a JWT alongside cookies for parallel auth
297
+ *
298
+ * When cookies are enabled WITHOUT exposeTokenInBody, the token stays as a session token
299
+ * (it's delivered via cookie, not the response body).
300
+ *
301
+ * On conversion failure (BetterAuth JWT plugin not warmed up, transient error, etc.),
302
+ * this method returns `undefined` rather than the raw session token. Returning the raw
303
+ * session token in a body meant for a JWT would expose a database-backed opaque identifier
304
+ * outside the httpOnly cookie boundary (see SEC-005, v11.25.0). Callers must handle
305
+ * `undefined` by forcing client retry / re-authentication.
296
306
  *
297
307
  * @param token - The token from BetterAuth response (may be session token or JWT)
298
- * @returns A proper JWT token, or the original token if conversion is not needed/possible
308
+ * @returns A proper JWT, the original token if conversion is not needed, or `undefined`
309
+ * when conversion was needed but failed.
299
310
  */
300
311
  async resolveJwtToken(token: string | undefined): Promise<string | undefined> {
301
312
  if (!token) return undefined;
302
313
 
303
- const cookiesEnabled = ConfigService.configFastButReadOnly?.cookies !== false;
304
- if (cookiesEnabled || !this.isJwtEnabled()) {
314
+ const cookiesConfig = ConfigService.configFastButReadOnly?.cookies as boolean | ICookiesConfig | undefined;
315
+
316
+ // Skip conversion when cookies-only mode or JWT plugin disabled
317
+ if (!shouldConvertSessionTokenToJwt(cookiesConfig, this.isJwtEnabled())) {
305
318
  return token;
306
319
  }
307
320
 
@@ -325,8 +338,11 @@ export class CoreBetterAuthService implements OnModuleInit {
325
338
  return jwt;
326
339
  }
327
340
 
328
- this.logger.warn('Failed to resolve session token to JWT - returning session token as fallback');
329
- return token;
341
+ // Do NOT return the raw session token as fallback that would leak an opaque
342
+ // DB identifier into the response body where clients expect a JWT. Force the
343
+ // caller to either retry or surface an auth error to the client.
344
+ this.logger.warn('Failed to resolve session token to JWT - returning undefined to avoid session token exposure');
345
+ return undefined;
330
346
  }
331
347
 
332
348
  /**
@@ -1,6 +1,5 @@
1
1
  import { getDb, uploadFileToGridFS } from '@lenne.tech/nest-server';
2
2
  import { Db, ObjectId } from 'mongodb';
3
- import config from '../src/config.env';
4
3
 
5
4
  /**
6
5
  * Migration template for nest-server projects
@@ -11,9 +10,24 @@ import config from '../src/config.env';
11
10
  * Available helpers:
12
11
  * - getDb(uri): Get MongoDB connection
13
12
  * - uploadFileToGridFS(uri, filePath, options): Upload file to GridFS
13
+ *
14
+ * MongoDB URI resolution (in order):
15
+ * 1. config.env.ts (local development via ts-node)
16
+ * 2. NSC__MONGOOSE__URI environment variable (Docker production)
14
17
  */
15
18
 
16
- const MONGO_URL = config.mongoose.uri;
19
+ let MONGO_URL: string;
20
+ try {
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const config = require('../src/config.env');
23
+ MONGO_URL = (config.default || config).mongoose.uri;
24
+ } catch {
25
+ // Fallback for Docker production where config.env.ts is not available as TypeScript source
26
+ if (!process.env.NSC__MONGOOSE__URI) {
27
+ throw new Error('MongoDB URI not available. Set NSC__MONGOOSE__URI or ensure config.env.ts is loadable.');
28
+ }
29
+ MONGO_URL = process.env.NSC__MONGOOSE__URI;
30
+ }
17
31
 
18
32
  /**
19
33
  * Run migration
@@ -12,7 +12,18 @@ import { CheckResponseInterceptor } from './core/common/interceptors/check-respo
12
12
  import { CheckSecurityInterceptor } from './core/common/interceptors/check-security.interceptor';
13
13
  import { ResponseModelInterceptor } from './core/common/interceptors/response-model.interceptor';
14
14
  import { TranslateResponseInterceptor } from './core/common/interceptors/translate-response.interceptor';
15
- import { ICoreModuleOverrides, IServerOptions } from './core/common/interfaces/server-options.interface';
15
+ import {
16
+ assertCookiesProductionSafe,
17
+ buildCorsConfig,
18
+ isCookiesEnabled,
19
+ isCorsDisabled,
20
+ isExposeTokenInBodyEnabled,
21
+ } from './core/common/helpers/cookies.helper';
22
+ import {
23
+ ICookiesConfig,
24
+ ICoreModuleOverrides,
25
+ IServerOptions,
26
+ } from './core/common/interfaces/server-options.interface';
16
27
  import { RequestContextMiddleware } from './core/common/middleware/request-context.middleware';
17
28
  import { MapAndValidatePipe } from './core/common/pipes/map-and-validate.pipe';
18
29
  import { ComplexityPlugin } from './core/common/plugins/complexity.plugin';
@@ -169,14 +180,15 @@ export class CoreModule implements NestModule {
169
180
  ? (authModuleOrUndefined as ICoreModuleOverrides | undefined)
170
181
  : overridesOrUndefined;
171
182
 
172
- // Process config
173
- let cors = {};
174
- if (options?.cookies) {
175
- cors = {
176
- credentials: true,
177
- origin: true,
178
- };
179
- }
183
+ // Guard against unsafe cookie configuration in production/staging.
184
+ // Throws if `cookies.exposeTokenInBody: true` is set in a production-like environment.
185
+ assertCookiesProductionSafe(options?.cookies, options?.env);
186
+
187
+ // Process CORS config (unified across GraphQL, REST, and BetterAuth).
188
+ // When CORS is explicitly disabled, pass `false` to Apollo — not `{}`.
189
+ // Apollo treats `cors: {}` as "open CORS with no credentials" (via Express cors() defaults),
190
+ // so we must distinguish the "disabled" case from the "no origins configured" case.
191
+ const cors: false | object = isCorsDisabled(options?.cors) ? false : buildCorsConfig(options);
180
192
 
181
193
  // Determine if GraphQL is enabled (false means explicitly disabled)
182
194
  const isGraphQlEnabled = options.graphQl !== false;
@@ -421,6 +433,8 @@ export class CoreModule implements NestModule {
421
433
  // When env: 'local', defaults are: baseUrl=localhost:3000, appUrl=localhost:3001
422
434
  serverAppUrl: config.appUrl,
423
435
  serverBaseUrl: config.baseUrl,
436
+ // Pass server-level CORS config so BetterAuth trustedOrigins aligns
437
+ serverCorsConfig: config.cors,
424
438
  serverEnv: config.env,
425
439
  }),
426
440
  );
@@ -472,7 +486,7 @@ export class CoreModule implements NestModule {
472
486
  * Uses CoreBetterAuthService for subscription authentication via JWT tokens.
473
487
  * This is the recommended mode for new projects.
474
488
  */
475
- private static buildIamOnlyGraphQlDriver(cors: object, options: Partial<IServerOptions>) {
489
+ private static buildIamOnlyGraphQlDriver(cors: false | object, options: Partial<IServerOptions>) {
476
490
  // This method is only called when graphQl !== false, extract config with type narrowing
477
491
  const graphQlOpts = typeof options?.graphQl === 'object' ? options.graphQl : undefined;
478
492
  return {
@@ -569,7 +583,7 @@ export class CoreModule implements NestModule {
569
583
  * This is safe because `onConnect` is only called when a WebSocket connection is made,
570
584
  * which happens after all modules are initialized.
571
585
  */
572
- private static buildLazyIamGraphQlDriver(cors: object, options: Partial<IServerOptions>) {
586
+ private static buildLazyIamGraphQlDriver(cors: false | object, options: Partial<IServerOptions>) {
573
587
  // This method is only called when graphQl !== false, extract config with type narrowing
574
588
  const graphQlOpts = typeof options?.graphQl === 'object' ? options.graphQl : undefined;
575
589
  return {
@@ -675,7 +689,7 @@ export class CoreModule implements NestModule {
675
689
  private static buildLegacyGraphQlDriver(
676
690
  AuthService: any,
677
691
  AuthModule: any,
678
- cors: object,
692
+ cors: false | object,
679
693
  options: Partial<IServerOptions>,
680
694
  ) {
681
695
  // This method is only called when graphQl !== false, extract config with type narrowing
@@ -746,4 +760,18 @@ export class CoreModule implements NestModule {
746
760
  ),
747
761
  };
748
762
  }
763
+
764
+ /**
765
+ * @deprecated Use `isCookiesEnabled` from `core/common/helpers/cookies.helper` instead.
766
+ */
767
+ static isCookiesEnabled(cookies: boolean | ICookiesConfig | undefined): boolean {
768
+ return isCookiesEnabled(cookies);
769
+ }
770
+
771
+ /**
772
+ * @deprecated Use `isExposeTokenInBodyEnabled` from `core/common/helpers/cookies.helper` instead.
773
+ */
774
+ static isExposeTokenInBodyEnabled(cookies: boolean | ICookiesConfig | undefined): boolean {
775
+ return isExposeTokenInBodyEnabled(cookies);
776
+ }
749
777
  }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export * from './core/common/filters/http-exception-log.filter';
30
30
  export * from './core/common/helpers/common.helper';
31
31
  export * from './core/common/helpers/config.helper';
32
32
  export * from './core/common/helpers/context.helper';
33
+ export * from './core/common/helpers/cookies.helper';
33
34
  export * from './core/common/helpers/db.helper';
34
35
  export * from './core/common/helpers/decorator.helper';
35
36
  export * from './core/common/helpers/file.helper';
package/src/main.ts CHANGED
@@ -7,6 +7,7 @@ import cookieParser = require('cookie-parser');
7
7
 
8
8
  import envConfig from './config.env';
9
9
  import { FilterArgs } from './core/common/args/filter.args';
10
+ import { buildCorsConfig, isCookiesEnabled, isCorsDisabled } from './core/common/helpers/cookies.helper';
10
11
  import { HttpExceptionLogFilter } from './core/common/filters/http-exception-log.filter';
11
12
  import { CorePersistenceModel } from './core/common/models/core-persistence.model';
12
13
  import { CoreAuthModel } from './core/modules/auth/core-auth.model';
@@ -47,9 +48,18 @@ async function bootstrap() {
47
48
  server.use(compression(compressionOptions));
48
49
  }
49
50
 
50
- // Cookie handling
51
- if (envConfig.cookies) {
52
- server.use(cookieParser());
51
+ // Cookie handling (enabled by default, disable with cookies: false)
52
+ // Pass a signing secret (if configured) to cookieParser so signed cookies can be
53
+ // verified via req.signedCookies. BetterAuth signs its own session cookies
54
+ // independently via HMAC, but the Express layer still benefits from a secret —
55
+ // Legacy Auth cookies and any custom signed cookies rely on this.
56
+ //
57
+ // Fallback chain: jwt.secret → betterAuth.secret (IAM-only mode) → unsigned
58
+ const cookiesEnabled = isCookiesEnabled(envConfig.cookies);
59
+ if (cookiesEnabled) {
60
+ const betterAuthSecret = typeof envConfig.betterAuth === 'object' ? envConfig.betterAuth?.secret : undefined;
61
+ const cookieSecret = envConfig.jwt?.secret || betterAuthSecret;
62
+ server.use(cookieSecret ? cookieParser(cookieSecret) : cookieParser());
53
63
  }
54
64
 
55
65
  // Asset directory
@@ -59,8 +69,25 @@ async function bootstrap() {
59
69
  server.setBaseViewsDir(envConfig.templates.path);
60
70
  server.setViewEngine(envConfig.templates.engine);
61
71
 
62
- // Enable cors to allow requests from other domains
63
- server.enableCors();
72
+ // Enable CORS (unified with GraphQL and BetterAuth via shared buildCorsConfig helper).
73
+ //
74
+ // Three cases:
75
+ // 1. CORS explicitly disabled (`cors: false` or `cors.enabled: false`) → skip `enableCors()`
76
+ // entirely. No CORS headers emitted. Same-origin requests still work.
77
+ // 2. Origins resolvable (appUrl/baseUrl/allowedOrigins or allowAll) → use the computed options.
78
+ // 3. Cookies disabled → permissive `enableCors()` without credentials (backward-compatible).
79
+ if (isCorsDisabled(envConfig.cors)) {
80
+ // Case 1 — CORS explicitly disabled. Do nothing.
81
+ } else {
82
+ const corsOptions = buildCorsConfig(envConfig);
83
+ if (Object.keys(corsOptions).length > 0) {
84
+ server.enableCors(corsOptions); // Case 2
85
+ } else if (!cookiesEnabled) {
86
+ server.enableCors(); // Case 3
87
+ }
88
+ // else: cookies enabled but no origins resolvable → secure default (no `enableCors()` call
89
+ // to avoid open CORS with credentials). Callers should configure appUrl/baseUrl/allowedOrigins.
90
+ }
64
91
 
65
92
  // Swagger documentation
66
93
  const config = new DocumentBuilder()
@@ -78,6 +78,19 @@ export interface TestGraphQLOptions {
78
78
  */
79
79
  convertEnums?: boolean | string[];
80
80
 
81
+ /**
82
+ * Cookies for the request. Three usage modes (same as `TestRestOptions.cookies`):
83
+ * - string (plain token without = or ;): Auto-detected as session token
84
+ * and converted via `buildBetterAuthCookies()` to all relevant cookie names
85
+ * (iam.session_token, token)
86
+ * - string (with = or ;): Used as-is as a cookie string
87
+ * - Record<string, string>: Key-value pairs joined into a cookie string
88
+ *
89
+ * Use this for cookie-based authentication (default since 11.25.0). Can be
90
+ * combined with `token` — both headers will be sent.
91
+ */
92
+ cookies?: Record<string, string> | string;
93
+
81
94
  /**
82
95
  * Count of subscription messages, specifies how many messages are to be received on subscription
83
96
  */
@@ -305,7 +318,7 @@ export class TestHelper {
305
318
  };
306
319
 
307
320
  // Init vars
308
- const { language, log, logError, statusCode, token, variables } = config;
321
+ const { cookies, language, log, logError, statusCode, token, variables } = config;
309
322
 
310
323
  // Init
311
324
  let query = '';
@@ -402,6 +415,7 @@ export class TestHelper {
402
415
 
403
416
  // Request configuration
404
417
  const requestConfig: any = {
418
+ cookies,
405
419
  method: 'POST',
406
420
  payload: { query },
407
421
  url: '/graphql',