@lenne.tech/nest-server 11.27.5 → 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.
- package/.claude/rules/configurable-features.md +2 -2
- package/.claude/rules/testing.md +27 -9
- package/CLAUDE.md +4 -2
- package/FRAMEWORK-API.md +1 -1
- package/bin/migrate.js +84 -25
- package/dist/core/common/helpers/cookies.helper.d.ts +1 -0
- package/dist/core/common/helpers/cookies.helper.js +33 -16
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +6 -8
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +3 -2
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +17 -4
- package/migration-guides/11.27.5-to-11.27.6.md +359 -0
- package/package.json +11 -6
- package/src/core/common/helpers/cookies.helper.ts +101 -26
- package/src/core/common/interfaces/server-options.interface.ts +35 -6
- package/src/core/modules/better-auth/README.md +10 -4
- package/src/core/modules/better-auth/better-auth.config.ts +74 -15
- package/src/core/modules/migrate/migration-runner.ts +16 -2
|
@@ -273,8 +273,7 @@ export const LOCALHOST_URL_ENVS: readonly string[] = ['ci', 'e2e', 'local'];
|
|
|
273
273
|
const API_HOST_LABEL = 'api.';
|
|
274
274
|
|
|
275
275
|
/**
|
|
276
|
-
*
|
|
277
|
-
* http(s) URL.
|
|
276
|
+
* Parses a URL string into a `URL`, or returns `undefined` when it is not a usable http(s) URL.
|
|
278
277
|
*
|
|
279
278
|
* The `protocol` guard is security-relevant, not cosmetic: `URL.origin` serializes to the
|
|
280
279
|
* literal string `'null'` for opaque origins (any non-special scheme, e.g. `custom://host`).
|
|
@@ -282,7 +281,7 @@ const API_HOST_LABEL = 'api.';
|
|
|
282
281
|
* `credentials: true` allowlist would grant credentialed access to any site able to frame a
|
|
283
282
|
* sandboxed document.
|
|
284
283
|
*/
|
|
285
|
-
function
|
|
284
|
+
function toHttpUrl(value: string): undefined | URL {
|
|
286
285
|
let url: URL;
|
|
287
286
|
try {
|
|
288
287
|
url = new URL(value);
|
|
@@ -294,7 +293,15 @@ function toHttpOrigin(value: string): string | undefined {
|
|
|
294
293
|
return undefined;
|
|
295
294
|
}
|
|
296
295
|
|
|
297
|
-
return url
|
|
296
|
+
return url;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Normalizes a URL string to its http(s) origin, or `undefined` when it is not a usable
|
|
301
|
+
* http(s) URL. See {@link toHttpUrl} for why the protocol guard matters.
|
|
302
|
+
*/
|
|
303
|
+
function toHttpOrigin(value: string): string | undefined {
|
|
304
|
+
return toHttpUrl(value)?.origin;
|
|
298
305
|
}
|
|
299
306
|
|
|
300
307
|
/**
|
|
@@ -316,7 +323,8 @@ function isLocalhostUrl(value: string | undefined): boolean {
|
|
|
316
323
|
}
|
|
317
324
|
|
|
318
325
|
/**
|
|
319
|
-
*
|
|
326
|
+
* The hostname with its leading `api.` label removed, or `undefined` when the label is absent
|
|
327
|
+
* or stripping it would not leave a deployable host.
|
|
320
328
|
*
|
|
321
329
|
* Guards two cases where a naive strip produces a bogus origin:
|
|
322
330
|
* - `api.dev` → `dev` — a bare TLD. `api.dev`/`api.io`/`api.co` are registrable domains, so
|
|
@@ -326,14 +334,65 @@ function isLocalhostUrl(value: string | undefined): boolean {
|
|
|
326
334
|
* `URL` setter for special schemes, so the strip would appear to succeed but do nothing.
|
|
327
335
|
*
|
|
328
336
|
* `localhost` is the one legitimate single-label host (`api.localhost` → `localhost`).
|
|
337
|
+
*
|
|
338
|
+
* Exported for reuse by BetterAuth's `deriveCookieDomainFromUrls()`, so the cookie-domain
|
|
339
|
+
* derivation shares this bare-TLD/empty guard instead of re-implementing a naive `api.`-strip.
|
|
329
340
|
*/
|
|
330
|
-
function
|
|
341
|
+
export function strippedApiHostname(hostname: string): string | undefined {
|
|
331
342
|
if (!hostname.startsWith(API_HOST_LABEL)) {
|
|
332
|
-
return
|
|
343
|
+
return undefined;
|
|
333
344
|
}
|
|
334
345
|
|
|
335
346
|
const remainder = hostname.slice(API_HOST_LABEL.length);
|
|
336
|
-
|
|
347
|
+
if (remainder !== 'localhost' && !remainder.includes('.')) {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return remainder;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Derives the app origin from an already-parsed API base `URL` by stripping a leading `api.`
|
|
356
|
+
* label from its hostname. Mutates and re-serializes the passed `URL`, so callers must not
|
|
357
|
+
* reuse it afterwards. Shared by {@link deriveAppUrlFromBaseUrl} (string entry point) and
|
|
358
|
+
* {@link resolveServerUrls} (which parses `baseUrl` once and threads the result through).
|
|
359
|
+
*/
|
|
360
|
+
function deriveAppOriginFromUrl(url: URL): string {
|
|
361
|
+
const stripped = strippedApiHostname(url.hostname);
|
|
362
|
+
if (stripped !== undefined) {
|
|
363
|
+
url.hostname = stripped;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return url.origin;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Whether stripping the `api.` label from `baseUrl` names a host the API itself is NOT served
|
|
371
|
+
* from — i.e. API and app are separated by HOST rather than by PORT.
|
|
372
|
+
*
|
|
373
|
+
* {@link LOCALHOST_URL_DEFAULTS} encode a port split: one host, API on `:3000`, app on `:3001`.
|
|
374
|
+
* `http://api.localhost:3000` and `https://api.localhost` are both that shape — the label
|
|
375
|
+
* strips to the bare `localhost` the API already answers on, so only the port tells app and API
|
|
376
|
+
* apart, and the flat `http://localhost:3001` default is the right answer.
|
|
377
|
+
*
|
|
378
|
+
* `https://api.crm.localhost` (as served by `lt dev up` behind Caddy) strips to the sibling host
|
|
379
|
+
* `crm.localhost`, which the API never answers on. There the derivation names the real app
|
|
380
|
+
* origin and must win over the flat default.
|
|
381
|
+
*
|
|
382
|
+
* Keyed on the stripped LABEL, never on the presence of a port: a host split stays a host split
|
|
383
|
+
* behind a non-default port (`https://api.crm.localhost:8443` → `https://crm.localhost:8443`),
|
|
384
|
+
* and a port split stays a port split on the default port (`https://api.localhost`).
|
|
385
|
+
*
|
|
386
|
+
* Takes an already-parsed `URL` (or `undefined` for an unparseable/absent `baseUrl`) so the
|
|
387
|
+
* caller parses `baseUrl` only once.
|
|
388
|
+
*/
|
|
389
|
+
function separatesApiAndAppByHost(url: undefined | URL): boolean {
|
|
390
|
+
if (!url) {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const stripped = strippedApiHostname(url.hostname);
|
|
395
|
+
return stripped !== undefined && stripped !== 'localhost';
|
|
337
396
|
}
|
|
338
397
|
|
|
339
398
|
/**
|
|
@@ -341,6 +400,8 @@ function canStripApiLabel(hostname: string): boolean {
|
|
|
341
400
|
* label from the hostname (e.g. `https://api.example.com` → `https://example.com`,
|
|
342
401
|
* `https://api.dev.example.com` → `https://dev.example.com`).
|
|
343
402
|
*
|
|
403
|
+
* The port is preserved (`https://api.example.com:8443` → `https://example.com:8443`).
|
|
404
|
+
*
|
|
344
405
|
* Returns the origin unchanged when there is no strippable `api.` prefix, and returns the
|
|
345
406
|
* input unchanged when it is not an http(s) URL — callers decide what to do with a value
|
|
346
407
|
* they cannot normalize.
|
|
@@ -348,17 +409,8 @@ function canStripApiLabel(hostname: string): boolean {
|
|
|
348
409
|
* @since 11.27.5
|
|
349
410
|
*/
|
|
350
411
|
export function deriveAppUrlFromBaseUrl(baseUrl: string): string {
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
return baseUrl;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const url = new URL(origin);
|
|
357
|
-
if (canStripApiLabel(url.hostname)) {
|
|
358
|
-
url.hostname = url.hostname.slice(API_HOST_LABEL.length);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
return url.origin;
|
|
412
|
+
const url = toHttpUrl(baseUrl);
|
|
413
|
+
return url ? deriveAppOriginFromUrl(url) : baseUrl;
|
|
362
414
|
}
|
|
363
415
|
|
|
364
416
|
/**
|
|
@@ -378,15 +430,22 @@ export interface IResolvedServerUrls {
|
|
|
378
430
|
* Resolves the effective app/API URLs for a server configuration.
|
|
379
431
|
*
|
|
380
432
|
* `baseUrl`: explicit → localhost default (local/ci/e2e only) → none.
|
|
381
|
-
* `appUrl`: explicit → localhost
|
|
382
|
-
* derived from `baseUrl` → none.
|
|
433
|
+
* `appUrl`: explicit → derived from a host-split localhost `baseUrl` (local/ci/e2e) →
|
|
434
|
+
* localhost default (local/ci/e2e with a localhost `baseUrl`) → derived from `baseUrl` → none.
|
|
435
|
+
*
|
|
436
|
+
* The localhost defaults assume a PORT split (API `:3000`, app `:3001`, one host). A localhost
|
|
437
|
+
* `baseUrl` whose `api.` label strips to a SIBLING host — `https://api.crm.localhost` from
|
|
438
|
+
* `lt dev up` — derives that host on whatever port it carries, because the flat
|
|
439
|
+
* `http://localhost:3001` default would name a host the app never serves from. See
|
|
440
|
+
* {@link separatesApiAndAppByHost}.
|
|
383
441
|
*
|
|
384
442
|
* `baseUrl` is returned verbatim (not origin-normalized) because BetterAuth passes it
|
|
385
443
|
* straight through as its `baseURL`; normalization for origin matching is the caller's job.
|
|
386
444
|
*
|
|
387
445
|
* @param input.deriveAppUrl - Set to `false` to disable the `api.`-strip derivation. The
|
|
388
446
|
* localhost defaults are unaffected — they are an explicit,
|
|
389
|
-
* documented behavior of the `local`/`ci`/`e2e` environments
|
|
447
|
+
* documented behavior of the `local`/`ci`/`e2e` environments, and
|
|
448
|
+
* a host-split localhost `baseUrl` falls back to them.
|
|
390
449
|
*
|
|
391
450
|
* @since 11.27.5
|
|
392
451
|
*/
|
|
@@ -409,13 +468,29 @@ export function resolveServerUrls(input: {
|
|
|
409
468
|
return { appUrl: input.appUrl, appUrlSource: 'explicit', baseUrl, baseUrlSource };
|
|
410
469
|
}
|
|
411
470
|
|
|
412
|
-
|
|
413
|
-
|
|
471
|
+
const mayDerive = input.deriveAppUrl !== false;
|
|
472
|
+
|
|
473
|
+
// Parse `baseUrl` once and reuse the result for both the host-split check and the derivation
|
|
474
|
+
// below (the localhost check keeps its own string parse — its loopback-IP matching differs
|
|
475
|
+
// from `toHttpUrl`'s http(s)-only guard). Gated on `mayDerive`: both consumers of
|
|
476
|
+
// `parsedBaseUrl` sit behind it (the host-split check via short-circuit, the derivation via its
|
|
477
|
+
// own guard), so with `deriveAppUrl: false` the parse is never needed and is skipped entirely.
|
|
478
|
+
const parsedBaseUrl = baseUrl && mayDerive ? toHttpUrl(baseUrl) : undefined;
|
|
479
|
+
|
|
480
|
+
// API on :3000 and app on :3001 — deriving from a port-split baseUrl would yield the API's
|
|
481
|
+
// own origin. A baseUrl that splits API and app by host instead (`https://api.crm.localhost`,
|
|
482
|
+
// from `lt dev up`) derives the real app origin, so prefer it over the flat default.
|
|
483
|
+
// `separatesApiAndAppByHost` is evaluated last, so every deployed (non-localhost) environment
|
|
484
|
+
// short-circuits before paying for the parse.
|
|
485
|
+
if (usesLocalhostDefaults && isLocalhostUrl(baseUrl) && !(mayDerive && separatesApiAndAppByHost(parsedBaseUrl))) {
|
|
414
486
|
return { appUrl: LOCALHOST_URL_DEFAULTS.appUrl, appUrlSource: 'localhost-default', baseUrl, baseUrlSource };
|
|
415
487
|
}
|
|
416
488
|
|
|
417
|
-
if (baseUrl &&
|
|
418
|
-
|
|
489
|
+
if (baseUrl && mayDerive) {
|
|
490
|
+
// `parsedBaseUrl` is mutated by `deriveAppOriginFromUrl`; a non-http(s) `baseUrl` (parse
|
|
491
|
+
// failed) is returned verbatim, matching `deriveAppUrlFromBaseUrl`.
|
|
492
|
+
const appUrl = parsedBaseUrl ? deriveAppOriginFromUrl(parsedBaseUrl) : baseUrl;
|
|
493
|
+
return { appUrl, appUrlSource: 'derived', baseUrl, baseUrlSource };
|
|
419
494
|
}
|
|
420
495
|
|
|
421
496
|
return { appUrl: undefined, appUrlSource: 'none', baseUrl, baseUrlSource };
|
|
@@ -1012,8 +1012,17 @@ export interface ICorsConfig {
|
|
|
1012
1012
|
* Convenient for development but NOT recommended for production as it enables
|
|
1013
1013
|
* CSRF-like attacks from any domain.
|
|
1014
1014
|
*
|
|
1015
|
-
* When true, overrides `allowedOrigins`.
|
|
1016
|
-
*
|
|
1015
|
+
* When true, overrides `allowedOrigins`.
|
|
1016
|
+
*
|
|
1017
|
+
* **This does NOT disable BetterAuth's origin check.** BetterAuth keeps
|
|
1018
|
+
* verifying the `Origin` header against its `trustedOrigins`, which are derived
|
|
1019
|
+
* from `appUrl` (and the passkey config). Leaving `trustedOrigins` empty would
|
|
1020
|
+
* not "allow everything" — BetterAuth then trusts only its own `baseURL`, and
|
|
1021
|
+
* origin-checked endpoints such as `two-factor/enable` or passkey registration
|
|
1022
|
+
* answer `403 INVALID_ORIGIN` for a separately hosted frontend.
|
|
1023
|
+
*
|
|
1024
|
+
* To accept arbitrary origins for auth as well, set `betterAuth.trustedOrigins`
|
|
1025
|
+
* explicitly — an origin check has no meaningful "allow everything" mode.
|
|
1017
1026
|
*
|
|
1018
1027
|
* @default false
|
|
1019
1028
|
*/
|
|
@@ -1337,13 +1346,19 @@ export interface IServerOptions {
|
|
|
1337
1346
|
* - Frontend redirect URLs
|
|
1338
1347
|
*
|
|
1339
1348
|
* **Auto-Detection from `baseUrl`:**
|
|
1340
|
-
* If not set, `appUrl` is derived from `baseUrl
|
|
1349
|
+
* If not set, `appUrl` is derived from `baseUrl` (the port is preserved):
|
|
1341
1350
|
* - `https://api.example.com` → `https://example.com` (removes 'api.' prefix)
|
|
1342
1351
|
* - `https://example.com` → `https://example.com` (unchanged)
|
|
1343
1352
|
*
|
|
1344
1353
|
* **Localhost Environment Defaults:**
|
|
1345
|
-
* When `env` is 'local', 'ci', or 'e2e'
|
|
1346
|
-
*
|
|
1354
|
+
* When `env` is 'local', 'ci', or 'e2e', `appUrl` is not set, and `baseUrl` is unset or points at
|
|
1355
|
+
* localhost, `appUrl` defaults to `http://localhost:3001`. These defaults encode a PORT split:
|
|
1356
|
+
* one host, API on `:3000`, app on `:3001`.
|
|
1357
|
+
*
|
|
1358
|
+
* A localhost `baseUrl` whose `api.` label strips to a SIBLING host is a HOST split and is
|
|
1359
|
+
* derived instead — `https://api.crm.localhost` → `https://crm.localhost`, as served by
|
|
1360
|
+
* `lt dev up` behind Caddy. `https://api.localhost` strips to the bare `localhost` the API
|
|
1361
|
+
* already answers on, so it keeps the `http://localhost:3001` default.
|
|
1347
1362
|
*
|
|
1348
1363
|
* **Environment Variable:** `APP_URL` (only needed if not auto-derivable from `BASE_URL`)
|
|
1349
1364
|
*
|
|
@@ -1362,6 +1377,11 @@ export interface IServerOptions {
|
|
|
1362
1377
|
* env: 'local', // or 'ci' or 'e2e'
|
|
1363
1378
|
* // baseUrl defaults to 'http://localhost:3000'
|
|
1364
1379
|
* // appUrl defaults to 'http://localhost:3001'
|
|
1380
|
+
*
|
|
1381
|
+
* // Local/CI/E2E behind `lt dev up` (host split — appUrl auto-derived)
|
|
1382
|
+
* env: 'local',
|
|
1383
|
+
* baseUrl: 'https://api.crm.localhost',
|
|
1384
|
+
* // → appUrl auto-derived: 'https://crm.localhost'
|
|
1365
1385
|
* ```
|
|
1366
1386
|
*/
|
|
1367
1387
|
appUrl?: string;
|
|
@@ -2784,7 +2804,8 @@ interface IBetterAuthBase {
|
|
|
2784
2804
|
* },
|
|
2785
2805
|
* advanced: {
|
|
2786
2806
|
* cookiePrefix: 'my-app',
|
|
2787
|
-
* useSecureCookies: true,
|
|
2807
|
+
* useSecureCookies: true, // re-enables the __Secure- prefix; safe ONLY when Better-Auth
|
|
2808
|
+
* // fully manages your cookies — see the note below.
|
|
2788
2809
|
* crossSubDomainCookies: {
|
|
2789
2810
|
* domain: 'example.com', // Cookies shared across *.example.com
|
|
2790
2811
|
* },
|
|
@@ -2795,6 +2816,14 @@ interface IBetterAuthBase {
|
|
|
2795
2816
|
* **Note on `advanced` options:** The `advanced` object is deep-merged with internal defaults
|
|
2796
2817
|
* (e.g., `cookiePrefix` derived from `basePath`). You do not need to re-specify `cookiePrefix`
|
|
2797
2818
|
* when adding other `advanced` options like `crossSubDomainCookies`.
|
|
2819
|
+
*
|
|
2820
|
+
* **`useSecureCookies` is pinned to `false` by default (since v11.27.6).** The framework keeps
|
|
2821
|
+
* Better-Auth's native handlers on the same UNPREFIXED cookie name the nest-server cookie helper
|
|
2822
|
+
* writes; the `Secure` attribute is still applied on an `https://` baseURL via
|
|
2823
|
+
* `advanced.defaultCookieAttributes`, so transport security is unchanged. Only set
|
|
2824
|
+
* `useSecureCookies: true` if Better-Auth manages your session cookies entirely (not the
|
|
2825
|
+
* nest-server helper) — otherwise its native handlers look for a `__Secure-`-prefixed cookie the
|
|
2826
|
+
* helper never writes and answer `401` on 2FA / passkey / `/token`.
|
|
2798
2827
|
*/
|
|
2799
2828
|
options?: Record<string, unknown>;
|
|
2800
2829
|
|
|
@@ -262,10 +262,10 @@ Read the security section below for production deployments.
|
|
|
262
262
|
|
|
263
263
|
**Global server-level settings that affect BetterAuth behavior (since v11.25.0):**
|
|
264
264
|
|
|
265
|
-
| Setting (top-level `IServerOptions`) | Technical Purpose | Impact of Wrong Value
|
|
266
|
-
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
267
|
-
| `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns the token in the response body (test-only; **forbidden in production**) | Tokens missing from response body surprises test clients; `exposeTokenInBody` in prod = XSS-risk, framework throws at startup
|
|
268
|
-
| `cors` (`boolean \| ICorsConfig`, default: enabled with auto-derived origins) | Unified CORS config — propagates to GraphQL (Apollo), REST (Express), and BetterAuth `trustedOrigins` from a single source | `cors.enabled: false` disables
|
|
265
|
+
| Setting (top-level `IServerOptions`) | Technical Purpose | Impact of Wrong Value |
|
|
266
|
+
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
267
|
+
| `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns the token in the response body (test-only; **forbidden in production**) | Tokens missing from response body surprises test clients; `exposeTokenInBody` in prod = XSS-risk, framework throws at startup |
|
|
268
|
+
| `cors` (`boolean \| ICorsConfig`, default: enabled with auto-derived origins) | Unified CORS config — propagates to GraphQL (Apollo), REST (Express), and BetterAuth `trustedOrigins` from a single source | `cors.enabled: false` disables the REST/GraphQL layers; `cors.allowAll` mirrors any request origin for REST/GraphQL (dev only) but BetterAuth keeps restricting to `appUrl` (its origin check has no "allow all" mode) |
|
|
269
269
|
|
|
270
270
|
**For Development:** The defaults (`http://localhost:3000`, `/iam`) are correct.
|
|
271
271
|
|
|
@@ -725,6 +725,12 @@ const config = {
|
|
|
725
725
|
},
|
|
726
726
|
advanced: {
|
|
727
727
|
cookiePrefix: 'my-app',
|
|
728
|
+
// Since v11.27.6 the framework pins `useSecureCookies: false` so Better-Auth's native
|
|
729
|
+
// handlers read the same UNPREFIXED cookie the nest-server helper writes (the `Secure`
|
|
730
|
+
// attribute is still applied on https via `advanced.defaultCookieAttributes`). Only set
|
|
731
|
+
// `true` if Better-Auth manages your cookies entirely — otherwise its native handlers
|
|
732
|
+
// look for a `__Secure-`-prefixed cookie that is never written and answer 401 on
|
|
733
|
+
// 2FA / passkey / `/token`.
|
|
728
734
|
useSecureCookies: true,
|
|
729
735
|
},
|
|
730
736
|
},
|
|
@@ -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 { resolveServerUrls } from '../../common/helpers/cookies.helper';
|
|
10
|
+
import { resolveServerUrls, strippedApiHostname } from '../../common/helpers/cookies.helper';
|
|
11
11
|
import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
|
|
12
12
|
import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
|
|
13
13
|
|
|
@@ -380,15 +380,47 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
380
380
|
);
|
|
381
381
|
}
|
|
382
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
|
+
|
|
383
392
|
const betterAuthConfig: Record<string, unknown> = {
|
|
384
393
|
advanced: {
|
|
385
394
|
cookiePrefix,
|
|
386
395
|
...(crossSubDomain.enabled && {
|
|
387
396
|
crossSubDomainCookies: { domain: crossSubDomain.domain, enabled: true },
|
|
388
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 } }),
|
|
389
421
|
},
|
|
390
422
|
basePath,
|
|
391
|
-
baseURL:
|
|
423
|
+
baseURL: betterAuthBaseUrl,
|
|
392
424
|
database: mongodbAdapter(db),
|
|
393
425
|
// Enable email/password authentication by default (required by Better-Auth 1.x)
|
|
394
426
|
// Can be disabled by setting config.emailAndPassword.enabled = false
|
|
@@ -681,12 +713,20 @@ function buildSocialProviders(config: IBetterAuth): Record<string, SocialProvide
|
|
|
681
713
|
*
|
|
682
714
|
* Behavior (in priority order):
|
|
683
715
|
* 1. Explicit betterAuth.trustedOrigins → use those (always takes precedence)
|
|
684
|
-
* 2. Server CORS disabled → empty array (
|
|
685
|
-
*
|
|
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)
|
|
686
719
|
* 4. Server CORS allowedOrigins → merge with appUrl/baseUrl
|
|
687
720
|
* 5. Passkey trustedOrigins → use from normalizePasskeyConfig()
|
|
688
721
|
* 6. Fallback to resolved appUrl
|
|
689
|
-
* 7. Otherwise → undefined (
|
|
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.
|
|
690
730
|
*
|
|
691
731
|
* @param config - Better-auth configuration
|
|
692
732
|
* @param options - Passkey normalization, resolved URLs, and server CORS context
|
|
@@ -706,15 +746,25 @@ export function buildTrustedOrigins(
|
|
|
706
746
|
return config.trustedOrigins;
|
|
707
747
|
}
|
|
708
748
|
|
|
709
|
-
// 2. Server CORS disabled → BetterAuth
|
|
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.
|
|
710
752
|
if (serverCorsConfig === false || (typeof serverCorsConfig === 'object' && serverCorsConfig?.enabled === false)) {
|
|
711
753
|
return [];
|
|
712
754
|
}
|
|
713
755
|
|
|
714
|
-
// 3. Server CORS allowAll →
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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).
|
|
718
768
|
|
|
719
769
|
// 4. Server allowedOrigins → merge with appUrl/baseUrl.
|
|
720
770
|
// Order (appUrl, baseUrl, allowedOrigins) mirrors buildCorsConfig() in cookies.helper.ts
|
|
@@ -1183,6 +1233,17 @@ export function resolveCrossSubDomainCookies(
|
|
|
1183
1233
|
* 3. Use baseUrl hostname as-is
|
|
1184
1234
|
*
|
|
1185
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.
|
|
1186
1247
|
*/
|
|
1187
1248
|
function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string | undefined {
|
|
1188
1249
|
// Priority 1: appUrl hostname (this IS the parent domain in typical setups)
|
|
@@ -1204,11 +1265,9 @@ function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string |
|
|
|
1204
1265
|
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
1205
1266
|
return undefined;
|
|
1206
1267
|
}
|
|
1207
|
-
// Strip api. prefix to get parent domain
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
}
|
|
1211
|
-
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;
|
|
1212
1271
|
} catch {
|
|
1213
1272
|
return undefined;
|
|
1214
1273
|
}
|
|
@@ -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:
|
|
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 ||
|
|
76
|
+
this.pattern = options.pattern || DEFAULT_MIGRATION_FILE_PATTERN;
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/**
|