@lenne.tech/nest-server 11.27.4 → 11.27.6

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.
@@ -7,6 +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 { resolveServerUrls, strippedApiHostname } from '../../common/helpers/cookies.helper';
10
11
  import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
11
12
  import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
12
13
 
@@ -379,15 +380,47 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
379
380
  );
380
381
  }
381
382
 
383
+ // Better-Auth base URL (resolved localhost defaults → explicit config → fallback).
384
+ const betterAuthBaseUrl = resolvedUrls.baseUrl || config.baseUrl || 'http://localhost:3000';
385
+ // The `Secure` attribute Better-Auth WOULD derive for its cookies if `useSecureCookies`
386
+ // were not pinned below: an `https://` baseURL is Better-Auth's own signal
387
+ // (createCookieGetter → `baseURLString.startsWith('https://')`). We keep exactly this value so
388
+ // the pinned `useSecureCookies: false` (needed for name alignment, see below) does not silently
389
+ // strip `Secure` from the cookies Better-Auth's native handlers forward.
390
+ const secureCookies = betterAuthBaseUrl.startsWith('https://');
391
+
382
392
  const betterAuthConfig: Record<string, unknown> = {
383
393
  advanced: {
384
394
  cookiePrefix,
385
395
  ...(crossSubDomain.enabled && {
386
396
  crossSubDomainCookies: { domain: crossSubDomain.domain, enabled: true },
387
397
  }),
398
+ // Keep Better-Auth's NATIVE handlers on the SAME cookie name the
399
+ // nest-server cookie helpers actually write. Those helpers always emit
400
+ // the UNPREFIXED `<cookiePrefix>.session_token`. Better-Auth, left to its
401
+ // own devices on an `https://` baseURL, auto-enables secure cookies AND
402
+ // prefixes the name with `__Secure-`, so its native handlers (2FA
403
+ // enable/disable, passkey register/list, backup codes, `/token`) look for
404
+ // `__Secure-<cookiePrefix>.session_token` — a cookie that is never written
405
+ // — and return `401 UNAUTHORIZED`, while the project's own session
406
+ // resolver (which reads the unprefixed cookie) still returns a session: a
407
+ // split-brain where `GET /iam/get-session` is 200 but every sensitive
408
+ // Better-Auth endpoint is 401. Pinning `useSecureCookies: false` keeps the
409
+ // native read path aligned with the cookie helper.
410
+ useSecureCookies: false,
411
+ // …BUT `useSecureCookies: false` also forces `secure: false` on EVERY cookie
412
+ // Better-Auth sets (createCookieGetter → `secure: !!secureCookiePrefix`), and the
413
+ // native handlers forward their `Set-Cookie` VERBATIM via `sendWebResponse`
414
+ // (2FA verify, social callback, magic link, passkey) WITHOUT passing through the
415
+ // cookie helper — so those session cookies would ship without `Secure` in
416
+ // production. Restore the exact Secure flag Better-Auth itself would have derived
417
+ // (an `https://` baseURL), keeping the unprefixed NAME while preserving the
418
+ // `Secure` TRANSPORT flag. Only injected on https so http/local and a consumer's
419
+ // own `options.advanced.useSecureCookies` override are left untouched.
420
+ ...(secureCookies && { defaultCookieAttributes: { secure: true } }),
388
421
  },
389
422
  basePath,
390
- baseURL: resolvedUrls.baseUrl || config.baseUrl || 'http://localhost:3000',
423
+ baseURL: betterAuthBaseUrl,
391
424
  database: mongodbAdapter(db),
392
425
  // Enable email/password authentication by default (required by Better-Auth 1.x)
393
426
  // Can be disabled by setting config.emailAndPassword.enabled = false
@@ -680,12 +713,20 @@ function buildSocialProviders(config: IBetterAuth): Record<string, SocialProvide
680
713
  *
681
714
  * Behavior (in priority order):
682
715
  * 1. Explicit betterAuth.trustedOrigins → use those (always takes precedence)
683
- * 2. Server CORS disabled → empty array (BetterAuth CORS also disabled)
684
- * 3. Server CORS allowAll undefined (BetterAuth allows all origins)
716
+ * 2. Server CORS disabled → empty array (adds no extra trusted origins beyond BetterAuth's own
717
+ * baseURL see NOTE; it does NOT switch BetterAuth's origin check off)
718
+ * 3. Server CORS allowAll → fall through to rules 4-7 (see below)
685
719
  * 4. Server CORS allowedOrigins → merge with appUrl/baseUrl
686
720
  * 5. Passkey trustedOrigins → use from normalizePasskeyConfig()
687
721
  * 6. Fallback to resolved appUrl
688
- * 7. Otherwise → undefined (allows all origins via Better-Auth's default)
722
+ * 7. Otherwise → undefined (Better-Auth then trusts only its own baseURL)
723
+ *
724
+ * NOTE on rules 2 and 7: neither an empty array (rule 2) nor `undefined` (rule 7) means "any
725
+ * origin is allowed", and neither disables BetterAuth's origin check. In both cases BetterAuth
726
+ * still derives and trusts its own `baseURL` origin (see `getTrustedOrigins()` in
727
+ * `better-auth/context`, which unconditionally pushes `new URL(baseURL).origin` before appending
728
+ * `options.trustedOrigins`), so `[]` and `undefined` are behaviorally identical here: a
729
+ * separately hosted frontend is still rejected by every origin-checked endpoint.
689
730
  *
690
731
  * @param config - Better-auth configuration
691
732
  * @param options - Passkey normalization, resolved URLs, and server CORS context
@@ -705,15 +746,25 @@ export function buildTrustedOrigins(
705
746
  return config.trustedOrigins;
706
747
  }
707
748
 
708
- // 2. Server CORS disabled → BetterAuth CORS also disabled
749
+ // 2. Server CORS disabled → add no extra trusted origins. BetterAuth still trusts its own
750
+ // baseURL (see the NOTE above), so this does NOT turn its origin check off — it only means
751
+ // "no separately hosted frontend is trusted". Behaviorally identical to rule 7's undefined.
709
752
  if (serverCorsConfig === false || (typeof serverCorsConfig === 'object' && serverCorsConfig?.enabled === false)) {
710
753
  return [];
711
754
  }
712
755
 
713
- // 3. Server CORS allowAll → BetterAuth allows all origins
714
- if (typeof serverCorsConfig === 'object' && serverCorsConfig?.allowAll) {
715
- return undefined;
716
- }
756
+ // 3. Server CORS allowAll → fall through to the appUrl / passkey rules below.
757
+ //
758
+ // This used to `return undefined` in the belief that BetterAuth then "allows all
759
+ // origins". It does not: without trustedOrigins BetterAuth trusts only its own
760
+ // baseURL, so the *app* origin is rejected and every origin-checked endpoint
761
+ // (two-factor/enable, passkey) answers 403 INVALID_ORIGIN. That silently broke
762
+ // 2FA and passkeys in exactly the setups that use allowAll — local dev and CI.
763
+ //
764
+ // An origin check has no meaningful "allow everything" mode — that is the very
765
+ // thing it defends against — so allowAll now yields the known-good origins
766
+ // (appUrl, passkey) instead of none. Projects that really do want to accept
767
+ // arbitrary origins can still set `betterAuth.trustedOrigins` explicitly (rule 1).
717
768
 
718
769
  // 4. Server allowedOrigins → merge with appUrl/baseUrl.
719
770
  // Order (appUrl, baseUrl, allowedOrigins) mirrors buildCorsConfig() in cookies.helper.ts
@@ -914,21 +965,6 @@ function readProjectNameFromPackageJson(): string {
914
965
  return fallback;
915
966
  }
916
967
 
917
- /**
918
- * Default URLs for local/test environments (local, ci, e2e)
919
- * These environments typically run on localhost and don't have a deployed domain.
920
- */
921
- const LOCALHOST_DEFAULTS = {
922
- API_URL: 'http://localhost:3000',
923
- APP_URL: 'http://localhost:3001',
924
- };
925
-
926
- /**
927
- * Environments that use localhost defaults for URLs.
928
- * These are typically development/test environments without deployed domains.
929
- */
930
- const LOCALHOST_ENVS = ['local', 'ci', 'e2e'];
931
-
932
968
  /**
933
969
  * Resolves the effective URLs for BetterAuth configuration.
934
970
  *
@@ -948,37 +984,6 @@ interface ResolvedUrls {
948
984
  warnings: string[];
949
985
  }
950
986
 
951
- /**
952
- * Derives appUrl from baseUrl by removing 'api.' prefix from subdomain.
953
- *
954
- * Examples:
955
- * - 'https://api.example.com' → 'https://example.com'
956
- * - 'https://api.dev.example.com' → 'https://dev.example.com'
957
- * - 'https://example.com' → 'https://example.com' (unchanged)
958
- * - 'http://localhost:3000' → 'http://localhost:3000' (unchanged)
959
- *
960
- * @param baseUrl - The API base URL
961
- * @returns Derived app URL or the original URL if no 'api.' prefix
962
- */
963
- function deriveAppUrlFromBaseUrl(baseUrl: string): string {
964
- try {
965
- const url = new URL(baseUrl);
966
- const hostname = url.hostname;
967
-
968
- // Check if hostname starts with 'api.'
969
- if (hostname.startsWith('api.')) {
970
- // Remove 'api.' prefix
971
- url.hostname = hostname.substring(4);
972
- return url.origin;
973
- }
974
-
975
- // Return original URL if no 'api.' prefix
976
- return url.origin;
977
- } catch {
978
- return baseUrl;
979
- }
980
- }
981
-
982
987
  /**
983
988
  * Extracts the root domain from a URL for use as Passkey rpId.
984
989
  *
@@ -1228,6 +1233,17 @@ export function resolveCrossSubDomainCookies(
1228
1233
  * 3. Use baseUrl hostname as-is
1229
1234
  *
1230
1235
  * Returns undefined for localhost (cross-subdomain not meaningful there).
1236
+ *
1237
+ * The `api.` strip reuses {@link strippedApiHostname} so it shares that helper's guard against
1238
+ * bogus results: `api.dev` → `dev` would set a public-suffix cookie domain that browsers reject
1239
+ * outright (dropping every session cookie), so `api.dev` is kept as-is instead.
1240
+ *
1241
+ * KNOWN LIMITATION (fails closed, no leak): the single-label guard cannot recognise a MULTI-label
1242
+ * public suffix — `api.co.uk` still strips to `co.uk`, a Public-Suffix-List entry browsers also
1243
+ * reject, so cross-subdomain auth would break (cookie dropped) rather than over-scope. A full PSL
1244
+ * check would need a dependency, which this security-critical module deliberately avoids; for such
1245
+ * apex domains set `betterAuth.crossSubDomainCookies.domain` explicitly instead of relying on
1246
+ * derivation. See migration guide 11.27.5 → 11.27.6.
1231
1247
  */
1232
1248
  function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string | undefined {
1233
1249
  // Priority 1: appUrl hostname (this IS the parent domain in typical setups)
@@ -1249,11 +1265,9 @@ function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string |
1249
1265
  if (hostname === 'localhost' || hostname === '127.0.0.1') {
1250
1266
  return undefined;
1251
1267
  }
1252
- // Strip api. prefix to get parent domain
1253
- if (hostname.startsWith('api.')) {
1254
- return hostname.substring(4);
1255
- }
1256
- return hostname;
1268
+ // Strip api. prefix to get parent domain — but keep the host unchanged when stripping
1269
+ // would leave a bare TLD (`api.dev`) or empty host (`api.`).
1270
+ return strippedApiHostname(hostname) ?? hostname;
1257
1271
  } catch {
1258
1272
  return undefined;
1259
1273
  }
@@ -1263,36 +1277,31 @@ function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string |
1263
1277
  }
1264
1278
 
1265
1279
  function resolveUrls(options: CreateBetterAuthOptions): ResolvedUrls {
1266
- const { config, serverAppUrl, serverBaseUrl, serverEnv } = options;
1280
+ const { config, serverAppUrl, serverBaseUrl, serverCorsConfig, serverEnv } = options;
1267
1281
  const warnings: string[] = [];
1268
- const usesLocalhostDefaults = LOCALHOST_ENVS.includes(serverEnv || '');
1269
1282
 
1270
- // Step 1: Resolve baseUrl
1271
- // Priority: betterAuth.baseUrl > serverBaseUrl > localhost default (for local/ci/e2e)
1272
- let baseUrl = config.baseUrl || serverBaseUrl;
1273
- if (!baseUrl && usesLocalhostDefaults) {
1274
- baseUrl = LOCALHOST_DEFAULTS.API_URL;
1283
+ // Steps 1+2 (baseUrl, appUrl) are delegated to the shared resolver in cookies.helper so that
1284
+ // BetterAuth's trustedOrigins and the REST/GraphQL CORS allowlist can never disagree about
1285
+ // which app origin this server is reachable under. `cors.deriveAppUrl: false` therefore
1286
+ // suppresses the `api.`-strip derivation on this layer too.
1287
+ const corsObj = typeof serverCorsConfig === 'object' && serverCorsConfig !== null ? serverCorsConfig : undefined;
1288
+ const resolved = resolveServerUrls({
1289
+ appUrl: serverAppUrl,
1290
+ // Priority: betterAuth.baseUrl > serverBaseUrl (> localhost default, applied by the resolver)
1291
+ baseUrl: config.baseUrl || serverBaseUrl,
1292
+ deriveAppUrl: corsObj?.deriveAppUrl,
1293
+ env: serverEnv,
1294
+ });
1295
+ const { appUrl, baseUrl } = resolved;
1296
+
1297
+ if (resolved.baseUrlSource === 'localhost-default') {
1275
1298
  warnings.push(`URL: Using localhost default baseUrl="${baseUrl}" (env: '${serverEnv}')`);
1276
1299
  }
1277
-
1278
- // Step 2: Resolve appUrl
1279
- // Priority: serverAppUrl > localhost default (when baseUrl is localhost) > derived from baseUrl
1280
- let appUrl = serverAppUrl;
1281
-
1282
- // For localhost environments with localhost baseUrl, use localhost app default
1283
- // This handles the common case where API runs on :3000 and App on :3001
1284
- const isBaseUrlLocalhost = baseUrl && (baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1'));
1285
- if (!appUrl && usesLocalhostDefaults && isBaseUrlLocalhost) {
1286
- appUrl = LOCALHOST_DEFAULTS.APP_URL;
1300
+ if (resolved.appUrlSource === 'localhost-default') {
1287
1301
  warnings.push(`URL: Using localhost default appUrl="${appUrl}" (env: '${serverEnv}')`);
1288
1302
  }
1289
-
1290
- // For non-localhost environments, try to derive appUrl from baseUrl
1291
- if (!appUrl && baseUrl) {
1292
- appUrl = deriveAppUrlFromBaseUrl(baseUrl);
1293
- if (appUrl !== baseUrl) {
1294
- warnings.push(`URL: Auto-derived appUrl="${appUrl}" from baseUrl="${baseUrl}"`);
1295
- }
1303
+ if (resolved.appUrlSource === 'derived' && appUrl !== baseUrl) {
1304
+ warnings.push(`URL: Auto-derived appUrl="${appUrl}" from baseUrl="${baseUrl}"`);
1296
1305
  }
1297
1306
 
1298
1307
  // Step 3: Resolve rpId from appUrl (not baseUrl!)
@@ -19,13 +19,27 @@ export interface MigrationFile {
19
19
  up: () => Promise<void>;
20
20
  }
21
21
 
22
+ /**
23
+ * Default file pattern for migration files: `*.ts` and `*.js`, but never `*.d.ts`.
24
+ *
25
+ * A compiled migration ships `foo.js` next to `foo.d.ts` (whenever the project
26
+ * builds with `declaration: true`). A plain `/\.(ts|js)$/` matches the
27
+ * declaration file too, so the runner would load it as a *second* migration and
28
+ * throw — `export declare …` is not valid CommonJS.
29
+ *
30
+ * The lookbehind guards the `.ts` branch only. Writing it as `(?<!\.d)\.(ts|js)$`
31
+ * would also reject a perfectly valid `foo.d.js`, since `.d` precedes `.js` there
32
+ * as well.
33
+ */
34
+ export const DEFAULT_MIGRATION_FILE_PATTERN = /(?:(?<!\.d)\.ts|\.js)$/;
35
+
22
36
  /**
23
37
  * Migration runner configuration
24
38
  */
25
39
  export interface MigrationRunnerOptions {
26
40
  /** Directory containing migration files */
27
41
  migrationsDirectory: string;
28
- /** Pattern to match migration files (default: *.ts, *.js) */
42
+ /** Pattern to match migration files (default: {@link DEFAULT_MIGRATION_FILE_PATTERN}) */
29
43
  pattern?: RegExp;
30
44
  /** State store for tracking migrations */
31
45
  stateStore: MongoStateStore;
@@ -59,7 +73,7 @@ export class MigrationRunner {
59
73
 
60
74
  constructor(options: MigrationRunnerOptions) {
61
75
  this.options = options;
62
- this.pattern = options.pattern || /\.(ts|js)$/;
76
+ this.pattern = options.pattern || DEFAULT_MIGRATION_FILE_PATTERN;
63
77
  }
64
78
 
65
79
  /**