diff --git a/FRAMEWORK-API.md b/FRAMEWORK-API.md index 106b217..d4eecd4 100644 --- a/FRAMEWORK-API.md +++ b/FRAMEWORK-API.md @@ -1,6 +1,6 @@ # @lenne.tech/nest-server — Framework API Reference -> Auto-generated from source code on 2026-05-31 (v11.26.1) +> Auto-generated from source code on 2026-06-03 (v11.26.2) > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code ## CoreModule.forRoot() diff --git a/migration-guides/11.26.1-to-11.26.2.md b/migration-guides/11.26.1-to-11.26.2.md new file mode 100644 index 0000000..bfc06af --- /dev/null +++ b/migration-guides/11.26.1-to-11.26.2.md @@ -0,0 +1,234 @@ +# Migration Guide: 11.26.1 → 11.26.2 + +## Overview + +| Category | Details | +|----------|---------| +| **Breaking Changes** | None | +| **Bugfixes** | Latent BetterAuth session-cookie drift fixed — six call sites that independently derived the cookie name from `basePath` now share a single resolver, so a `cookiePrefix` override no longer breaks sign-in / authenticated requests / sign-out | +| **New Features** | New `COOKIE_PREFIX` env variable — isolates the BetterAuth session cookie name on shared hosts (e.g. several lenne.tech apps on `*.localhost`). New helper module `better-auth-cookie-prefix.helper.ts` exporting `resolveBetterAuthCookiePrefix()`, `resolveBetterAuthSessionCookieName()`, `detectCookiePrefixDrift()`. New `CoreBetterAuthService.getCookiePrefix()`. Drift warning when `options.advanced.cookiePrefix` is used programmatically | +| **Migration Effort** | 0 minutes (automatic) — opt-in `COOKIE_PREFIX` for multi-app shared-host deployments only | + +--- + +## Quick Migration + +No code changes required. The fix and the new resolver activate automatically; `COOKIE_PREFIX` is opt-in. + +```bash +# Update package +pnpm add @lenne.tech/nest-server@11.26.2 + +# Verify build +pnpm run build + +# Run tests +pnpm test +``` + +--- + +## What's New in 11.26.2 + +### 1. `COOKIE_PREFIX` env variable — isolate auth cookies per app + +**Use case:** Several lenne.tech apps deployed on the same host (e.g. via `lt dev` — `nest-server.localhost`, `offers.localhost`, `showroom.localhost` all served by Caddy on the same machine). Browser cookies are scoped by host, not port — without isolation a session cookie from one app collides with another and authentication breaks. The BetterAuth REST surface in 11.26.1 derives the cookie name from `basePath` (default: `iam.session_token`), which is identical across apps. + +**Fix:** Set `COOKIE_PREFIX` per app — the BetterAuth session cookie becomes `.session_token`: + +```bash +# App A +COOKIE_PREFIX=offers # → cookie name: offers.session_token + +# App B +COOKIE_PREFIX=showroom # → cookie name: showroom.session_token +``` + +**Precedence (resolved by `resolveBetterAuthCookiePrefix`):** + +1. **`COOKIE_PREFIX` env** (dedicated, always wins) — sanitised to the safe cookie-name subset `[A-Za-z0-9._-]`; if it sanitises to empty it is ignored +2. otherwise the **basePath-derived** prefix (`/iam` → `iam`, `/api/iam` → `api.iam`) — the previous default, fully backward compatible when `COOKIE_PREFIX` is unset + +**Frontend pairing (required when set):** The frontend `@lenne.tech/nuxt-extensions` exposes `NUXT_PUBLIC_COOKIE_PREFIX` (same precedence, same sanitisation). The two sides MUST agree, otherwise the browser sends one name and the server reads another. The backend logs a `COOKIE_PREFIX override active → …` line on bootstrap to make the override visible in operator logs. + +### 2. Single source of truth helper module + +New file: `src/core/modules/better-auth/better-auth-cookie-prefix.helper.ts`. Dependency-free on purpose so it can be imported from the config builder, the service, controllers and helpers without any import cycle. + +```typescript +// All public API: +import { + resolveBetterAuthCookiePrefix, // → 'iam' (default) or 'acme' (under COOKIE_PREFIX=acme) + resolveBetterAuthSessionCookieName, // → 'iam.session_token' or 'acme.session_token' + detectCookiePrefixDrift, // → null | warning string +} from '@lenne.tech/nest-server'; +``` + +A `@deprecated` re-export from `./better-auth.config` keeps old imports working — new code should import from the helper directly. + +### 3. `CoreBetterAuthService.getCookiePrefix()` (new public method) + +```typescript +class CoreBetterAuthService { + /** Cached on first read so it cannot drift away from the Better-Auth instance, + * which captures the prefix once at bootstrap. */ + getCookiePrefix(): string; + getSessionCookieName(): string; // unchanged signature, now backed by getCookiePrefix() +} +``` + +### 4. Drift warning when `options.advanced.cookiePrefix` is set programmatically + +```typescript +betterAuth: { + options: { + advanced: { cookiePrefix: 'custom' }, // ← BetterAuth would honour this, NestJS layer would not + }, +} +``` + +This pattern silently broke sign-in / read / sign-out in 11.26.1 and earlier. 11.26.2 detects the drift via `detectCookiePrefixDrift()` and emits a loud `logger.warn` at bootstrap: + +``` +options.advanced.cookiePrefix="custom" overrides Better-Auth but the NestJS layer +still uses "iam". This will break sign-in / read / sign-out — use the COOKIE_PREFIX +env variable instead (honoured by every call site). +``` + +--- + +## What's Fixed in 11.26.2 + +### Cross-layer cookie-prefix drift in the BetterAuth pipeline + +**Affects:** Any project that set a non-default `cookiePrefix` via `options.advanced.cookiePrefix` or via the proposed `COOKIE_PREFIX` env (introduced here). Default deployments (no override) are not affected. + +**Symptom (before 11.26.2):** +With a non-default cookie prefix, Better-Auth set `Set-Cookie: .session_token=…` but the NestJS layer kept reading `iam.session_token`: + +| Stage | Cookie name in 11.26.1 | Result | +|---|---|---| +| Sign-up / Sign-in | `.session_token` (Better-Auth) | Cookie correctly set by Better-Auth | +| Authenticated request | `iam.session_token` ([core-better-auth.controller.ts:778-781](../src/core/modules/better-auth/core-better-auth.controller.ts)) | User reads as anonymous (`success: false`) | +| `getSession()` cookie-signing | `iam.session_token` ([core-better-auth.service.ts:437-438](../src/core/modules/better-auth/core-better-auth.service.ts)) | Session never resolved | +| `extractSessionToken` (middleware) | `iam.session_token` ([core-better-auth-web.helper.ts:98-103](../src/core/modules/better-auth/core-better-auth-web.helper.ts)) | 401 on protected routes | +| `BetterAuthCookieHelper` (set + clear) | `iam.session_token` ([core-better-auth-cookie.helper.ts:127-128](../src/core/modules/better-auth/core-better-auth-cookie.helper.ts)) | Sign-out leaves real cookie behind | + +**Fix (in 11.26.2):** +All six call sites now resolve the cookie name through `resolveBetterAuthSessionCookieName(basePath)` — a single function that honours `COOKIE_PREFIX` and falls back to the basePath default. Cached on the service so a late `process.env` mutation (typical in tests) cannot drift the value away from the Better-Auth instance, which captures the prefix at bootstrap. + +--- + +## Compatibility Notes + +- **Default deployments (no `COOKIE_PREFIX`, no programmatic override):** Cookie name remains `iam.session_token`, behaviour is bit-for-bit identical to 11.26.1. No change is observable. +- **Projects that read `iam.session_token` directly in tests (hardcoded strings):** Continue to work as long as `COOKIE_PREFIX` is unset. Under override the hardcoded name is wrong — prefer `resolveBetterAuthSessionCookieName('/iam')` or the `TestHelper.extractSessionToken()` default (which now resolves through the same helper). +- **`TestHelper.extractSessionToken(response, cookieName?)`:** The second parameter is now optional; when omitted it derives the cookie name through the shared resolver (so tests under `COOKIE_PREFIX=acme` automatically look at `acme.session_token`). Existing call sites that pass an explicit name are unaffected. +- **`TestHelper.buildBetterAuthCookies(token, basePath = 'iam')`:** Already honoured `COOKIE_PREFIX` since the prior release via the same resolver — no caller change required. +- **Programmatic `options.advanced.cookiePrefix`:** Still applied to the Better-Auth instance (no behaviour change in Better-Auth), but now emits a `logger.warn` drift notice on bootstrap. Switch to `COOKIE_PREFIX` (env) to remove the warning and the latent breakage. +- **`BetterAuthCookieHelper.getNormalizedBasePath()`:** Unchanged — still returns the basePath-derived string (e.g. `'iam'`). It is **NOT** a cookie prefix; the JSDoc now carries a loud warning so future code never reconstructs the cookie name as `` `${getNormalizedBasePath()}.session_token` `` (which would re-introduce the drift). +- **Vendor-mode consumers:** Same as npm consumers; the new file ships under `src/core/modules/better-auth/`. + +--- + +## Adopting `COOKIE_PREFIX` (Optional) + +### Step 1: Decide the prefix per app + +Pick a short, unique slug per app — typically the project slug (`offers`, `showroom`) or a kebab/dot subset that matches your environment hygiene. + +| Pick | Valid? | Result | +|---|---|---| +| `offers` | ✓ | `offers.session_token` | +| `kit-test_01` | ✓ | `kit-test_01.session_token` | +| `a;c=me x\r\n` | sanitised | `acmex.session_token` (semicolons, equals, whitespace, CR/LF stripped) | +| `;;==` | sanitises to empty → falls back to basePath | `iam.session_token` (the env is ignored) | + +### Step 2: Set both sides in lockstep + +```bash +# Backend (this package) +COOKIE_PREFIX=offers + +# Frontend (@lenne.tech/nuxt-extensions) +NUXT_PUBLIC_COOKIE_PREFIX=offers +``` + +### Step 3: Verify + +```bash +# Bootstrap should log: +# COOKIE_PREFIX override active → auth cookies use prefix "offers" (e.g. "offers.session_token"). +# The frontend NUXT_PUBLIC_COOKIE_PREFIX MUST match. + +# Sign in and inspect the Set-Cookie header — it must use the overridden name: +curl -i -X POST http://localhost:3000/iam/sign-in/email \ + -H 'Content-Type: application/json' \ + -d '{"email":"…","password":"…"}' \ + | grep -i set-cookie +# Set-Cookie: offers.session_token=…; Path=/; HttpOnly; … +``` + +--- + +## Troubleshooting + +### After setting `COOKIE_PREFIX`, sign-in succeeds but every follow-up request is unauthenticated + +Verify the frontend `NUXT_PUBLIC_COOKIE_PREFIX` matches the backend value. The most common cause is one side set in `.env` and the other still inheriting the default. The backend log line `COOKIE_PREFIX override active → …` confirms the backend value; check the browser's DevTools → Application → Cookies for the actual cookie name the browser holds. + +### Bootstrap log says `options.advanced.cookiePrefix="…" overrides Better-Auth but the NestJS layer still uses "…"` + +You are setting the cookie prefix programmatically via `betterAuth.options.advanced.cookiePrefix`. Move the override to the `COOKIE_PREFIX` env variable — that is the only path that is honoured by every call site (sign-up, sign-in, get-session, sign-out, middleware). The programmatic override is left untouched but the NestJS layer cannot follow it, so sign-in writes one cookie and authenticated requests look for another. + +### Existing browser cookies under the old name keep the user "logged in" after switching `COOKIE_PREFIX` + +Expected. Browser cookies are persistent — changing the prefix does not retroactively rename them. Users will be silently signed out (the server no longer reads the old name) and sign in again, after which the new cookie is set. Old cookies fall out of the browser at their max-age. To force a clean state, instruct users to clear cookies for the affected host, or set a one-off `Clear-Site-Data: "cookies"` header during the rollout. + +### Tests under `COOKIE_PREFIX=acme` return `{ success: false }` from `/iam/session` although sign-in succeeded + +Most likely you are passing the JWT-converted token (sign-in response body's `token` field) instead of the raw session token. With `betterAuth.jwt` enabled the response token is a JWT (`eyJ…`), which goes in the `Authorization: Bearer` header — not in the session cookie. For cookie-based authenticated tests, read the raw token from the `session` collection: + +```typescript +const sessionRow = await db.collection('session').findOne( + { $or: [{ userId: iamUser._id }, { userId: iamUser._id.toString() }] }, + { sort: { createdAt: -1 } }, +); +const rawSessionToken = sessionRow.token; +``` + +See `tests/stories/better-auth-cookie-prefix.story.test.ts` for a complete e2e reference. + +### `TestHelper.extractSessionToken(response)` returns `null` although the cookie is in the Set-Cookie header + +You are likely on a `COOKIE_PREFIX=acme` setup but pass an explicit `cookieName: 'iam.session_token'` as the second argument. Either omit the argument (the new default goes through the resolver) or pass the resolved name explicitly. + +--- + +## Module Documentation + +### BetterAuth Module + +- **README:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md) +- **Integration Checklist:** [src/core/modules/better-auth/INTEGRATION-CHECKLIST.md](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) +- **Customization Guide:** [src/core/modules/better-auth/CUSTOMIZATION.md](../src/core/modules/better-auth/CUSTOMIZATION.md) +- **Reference Implementation:** `src/server/modules/better-auth/` +- **Key Files:** + - `src/core/modules/better-auth/better-auth-cookie-prefix.helper.ts` — single source of truth for the prefix (`resolveBetterAuthCookiePrefix`, `resolveBetterAuthSessionCookieName`, `detectCookiePrefixDrift`) + - `src/core/modules/better-auth/better-auth.config.ts` — bootstrap-time resolution + drift warning + `@deprecated` re-export + - `src/core/modules/better-auth/core-better-auth.service.ts` — `getCookiePrefix()` (new, cached), `getSessionCookieName()` (now via cache) + - `src/core/modules/better-auth/core-better-auth.controller.ts`, `core-better-auth-cookie.helper.ts`, `core-better-auth-web.helper.ts` — all converted to the shared resolver +- **Reference tests:** + - `tests/unit/better-auth-cookie-prefix.spec.ts` — pure resolver + drift detector + - `tests/unit/better-auth-cookie-helper.spec.ts` — cross-layer set/read lockstep + service cache + `TestHelper` default + - `tests/stories/better-auth-cookie-prefix.story.test.ts` — e2e sign-up / authenticated request / sign-out against a real Mongo + Better-Auth boot under `COOKIE_PREFIX=acme` + +--- + +## References + +- [Migration Guide 11.26.0 → 11.26.1](./11.26.0-to-11.26.1.md) — Previous release (roles in REST auth response) +- [Configurable Features](../.claude/rules/configurable-features.md) — Pattern notes for env-driven overrides +- [BetterAuth module rules](../.claude/rules/better-auth.md) — Standard-compliance & security baselines +- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation) +- [@lenne.tech/nuxt-extensions `resolveLtCookiePrefix`](https://github.com/lenneTech/nuxt-extensions) — frontend pendant (`NUXT_PUBLIC_COOKIE_PREFIX`) diff --git a/package.json b/package.json index d76f913..912f698 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lenne.tech/nest-server", - "version": "11.26.1", + "version": "11.26.2", "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).", "keywords": [ "node", diff --git a/spectaql.yml b/spectaql.yml index 50b81f7..2c945ea 100644 --- a/spectaql.yml +++ b/spectaql.yml @@ -19,7 +19,7 @@ servers: info: title: lT Nest Server description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases). - version: 11.26.1 + version: 11.26.2 contact: name: lenne.Tech GmbH url: https://lenne.tech diff --git a/src/core/modules/better-auth/better-auth-cookie-prefix.helper.ts b/src/core/modules/better-auth/better-auth-cookie-prefix.helper.ts new file mode 100644 index 0000000..72bb41d --- /dev/null +++ b/src/core/modules/better-auth/better-auth-cookie-prefix.helper.ts @@ -0,0 +1,84 @@ +/** + * Single source of truth for the Better-Auth session cookie prefix. + * + * The session cookie is named `.session_token` (e.g. `iam.session_token`). + * The prefix is resolved here — and ONLY here — so every place that sets, reads, + * signs, extracts or clears the session cookie agrees on the exact name. (A + * previous bug derived the name from `basePath` independently in 6 places, so a + * `COOKIE_PREFIX` override broke the auth pipeline: Better-Auth set + * `acme.session_token` while the NestJS layer still looked for `iam.session_token`.) + * + * Dependency-free on purpose so it can be imported from the config builder, the + * service, controllers and helpers without any import cycle. + */ + +/** + * Characters allowed in a cookie-name token. A safe subset of RFC 6265 that is + * IDENTICAL to the frontend resolver (`@lenne.tech/nuxt-extensions` + * `resolveLtCookiePrefix`), so a shared `COOKIE_PREFIX` produces the SAME prefix + * on both sides. Everything else (spaces, `;`, `=`, `\r`, `\n`, …) is stripped so + * a typo can never corrupt the `Set-Cookie` header. + */ +function sanitizeCookiePrefix(raw: string): string { + return raw.trim().replace(/[^A-Za-z0-9._-]/g, ''); +} + +/** + * Resolve the Better-Auth cookie prefix (the `iam` in `iam.session_token`). + * + * Precedence: + * 1. **`COOKIE_PREFIX` env** (dedicated, always wins) — for fully autonomous + * cookie isolation on a shared host (several lenne.tech apps on the same + * host, where cookies collide by host, not port). Sanitised to valid + * cookie-name characters; if it sanitises to empty it is ignored. + * 2. otherwise the **basePath-derived** prefix (`/iam` → `iam`, + * `/api/iam` → `api.iam`) — the previous behaviour, fully backward + * compatible when `COOKIE_PREFIX` is unset. + * + * IMPORTANT: when `COOKIE_PREFIX` is set it MUST match the frontend + * `NUXT_PUBLIC_COOKIE_PREFIX` (see `@lenne.tech/nuxt-extensions` + * `resolveLtCookiePrefix`) — otherwise the two sides use different cookie names + * and authentication breaks. + * + * @param basePath - Better-Auth base path (e.g. `/iam`) + * @param env - environment to read `COOKIE_PREFIX` from (defaults to `process.env`) + */ +export function resolveBetterAuthCookiePrefix(basePath: string, env: NodeJS.ProcessEnv = process.env): string { + const basePathPrefix = (basePath || '/iam').replace(/^\//, '').replace(/\//g, '.'); + const explicit = sanitizeCookiePrefix(typeof env.COOKIE_PREFIX === 'string' ? env.COOKIE_PREFIX : ''); + return explicit || basePathPrefix; +} + +/** + * Convenience: the full session-cookie name (`.session_token`) for the + * given basePath, honouring `COOKIE_PREFIX`. Use this wherever the session + * cookie name is needed so all call sites stay in lockstep. + */ +export function resolveBetterAuthSessionCookieName(basePath: string, env: NodeJS.ProcessEnv = process.env): string { + return `${resolveBetterAuthCookiePrefix(basePath, env)}.session_token`; +} + +/** + * Detect a Better-Auth cookie-prefix drift between the value the NestJS layer + * resolves (via {@link resolveBetterAuthCookiePrefix}) and a programmatic + * `options.advanced.cookiePrefix` passed straight to Better-Auth. Such a + * mismatch silently breaks the auth pipeline because Better-Auth sets the + * cookie under the programmatic prefix while the NestJS read/clear path still + * uses the resolved prefix. + * + * Returns `null` when there is no drift (no programmatic prefix, or it + * matches), otherwise a human-readable warning sentence ready for `logger.warn`. + * + * Extracted so the drift-detection logic is testable without booting the full + * Better-Auth instance (which requires Mongo). + */ +export function detectCookiePrefixDrift(resolvedPrefix: string, programmaticOptionsAdvanced: unknown): null | string { + if (!programmaticOptionsAdvanced || typeof programmaticOptionsAdvanced !== 'object') return null; + const programmaticPrefix = (programmaticOptionsAdvanced as Record).cookiePrefix; + if (typeof programmaticPrefix !== 'string' || programmaticPrefix === resolvedPrefix) return null; + return ( + `options.advanced.cookiePrefix="${programmaticPrefix}" overrides Better-Auth but the ` + + `NestJS layer still uses "${resolvedPrefix}". This will break sign-in / read / sign-out — ` + + `use the COOKIE_PREFIX env variable instead (honoured by every call site).` + ); +} diff --git a/src/core/modules/better-auth/better-auth.config.ts b/src/core/modules/better-auth/better-auth.config.ts index c376255..a934cb4 100644 --- a/src/core/modules/better-auth/better-auth.config.ts +++ b/src/core/modules/better-auth/better-auth.config.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface'; +import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper'; /** * Type for better-auth instance with plugins @@ -325,9 +326,20 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea // Build the base Better-Auth configuration // Use resolved baseUrl (with local defaults) or fallback const basePath = config.basePath || '/iam'; - // Cookie prefix derived from basePath (e.g., '/iam' → 'iam') - // This ensures Better-Auth looks for cookies like 'iam.session_token' instead of 'better-auth.session_token' - const cookiePrefix = basePath.replace(/^\//, '').replace(/\//g, '.'); + // Cookie prefix for Better-Auth's session cookies (e.g. 'iam.session_token'). + // COOKIE_PREFIX env (always wins) lets a project fully isolate its auth + // cookies on a shared host; otherwise derived from basePath. The SAME pure + // resolver is used by every session-cookie call site (service, controller, + // web/cookie helpers) so the name stays in lockstep. + const cookiePrefix = resolveBetterAuthCookiePrefix(basePath); + // Operator visibility: make a non-default COOKIE_PREFIX override obvious in the + // logs, and remind that the frontend must mirror it or auth will break. + if ((process.env.COOKIE_PREFIX || '').trim()) { + logger.log( + `COOKIE_PREFIX override active → auth cookies use prefix "${cookiePrefix}" ` + + `(e.g. "${cookiePrefix}.session_token"). The frontend NUXT_PUBLIC_COOKIE_PREFIX MUST match.`, + ); + } const betterAuthConfig: Record = { advanced: { @@ -382,6 +394,14 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea const { advanced: optionsAdvanced, ...restOptions } = config.options as Record; finalConfig = { ...betterAuthConfig, ...restOptions }; if (optionsAdvanced && typeof optionsAdvanced === 'object') { + // Drift guard: a programmatic `options.advanced.cookiePrefix` would only + // change what Better-Auth itself sets — the NestJS layer keeps resolving + // through `resolveBetterAuthCookiePrefix(basePath, env)`, so sign-in / + // read / sign-out would silently fall out of lockstep. Loud warning so + // operators see the misconfiguration immediately. Use `COOKIE_PREFIX` + // (env) instead — it is honoured by every call site. + const driftWarning = detectCookiePrefixDrift(cookiePrefix, optionsAdvanced); + if (driftWarning) logger.warn(driftWarning); finalConfig.advanced = { ...(betterAuthConfig.advanced as Record), ...(optionsAdvanced as Record), @@ -1081,6 +1101,17 @@ export interface ResolvedCrossSubDomainCookies { enabled: boolean; } +/** + * Re-exported for backward compatibility. + * + * @deprecated Import from `./better-auth-cookie-prefix.helper` directly. The + * re-export exists only so callers that landed on `./better-auth.config` + * before the helper was extracted keep working; new code MUST import from the + * dedicated helper module to avoid pulling the heavy config-builder into the + * dependency graph (which forces import cycles in some build setups). + */ +export { resolveBetterAuthCookiePrefix, resolveBetterAuthSessionCookieName } from './better-auth-cookie-prefix.helper'; + /** * Resolves the cross-subdomain cookies configuration. * diff --git a/src/core/modules/better-auth/core-better-auth-cookie.helper.ts b/src/core/modules/better-auth/core-better-auth-cookie.helper.ts index 90de251..0744af9 100644 --- a/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +++ b/src/core/modules/better-auth/core-better-auth-cookie.helper.ts @@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common'; import { Response } from 'express'; import { isProductionLikeEnv } from '../../common/helpers/cookies.helper'; +import { resolveBetterAuthSessionCookieName } from './better-auth-cookie-prefix.helper'; import { signCookieValue } from './core-better-auth-web.helper'; /** @@ -124,8 +125,10 @@ export class BetterAuthCookieHelper { constructor(private readonly config: BetterAuthCookieHelperConfig) { // Normalize basePath: remove leading slash, replace slashes with dots this.normalizedBasePath = config.basePath.replace(/^\//, '').replace(/\//g, '.'); - // Default cookie name based on basePath (e.g., 'iam.session_token') - this.cookieName = `${this.normalizedBasePath}.session_token`; + // Session cookie name via the single shared resolver: honours COOKIE_PREFIX + // (e.g. 'iam.session_token', or 'acme.session_token' when COOKIE_PREFIX=acme), + // so set/clear/read here always match the name Better-Auth itself uses. + this.cookieName = resolveBetterAuthSessionCookieName(config.basePath); } /** @@ -153,6 +156,13 @@ export class BetterAuthCookieHelper { /** * Gets the normalized base path (e.g., 'iam' from '/iam'). + * + * WARNING: This is the basePath-derived string, NOT the resolved cookie + * prefix — it intentionally ignores `COOKIE_PREFIX`. Do NOT use it to build + * a session cookie name (use {@link getCookieName} instead). Composing + * `${getNormalizedBasePath()}.session_token` would re-introduce the + * COOKIE_PREFIX drift bug where Better-Auth and the NestJS layer disagree + * on the cookie name (see better-auth-cookie-prefix.helper.ts). */ getNormalizedBasePath(): string { return this.normalizedBasePath; diff --git a/src/core/modules/better-auth/core-better-auth-web.helper.ts b/src/core/modules/better-auth/core-better-auth-web.helper.ts index 020ff0e..b60ec04 100644 --- a/src/core/modules/better-auth/core-better-auth-web.helper.ts +++ b/src/core/modules/better-auth/core-better-auth-web.helper.ts @@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common'; import * as crypto from 'crypto'; import { Request, Response } from 'express'; +import { resolveBetterAuthSessionCookieName } from './better-auth-cookie-prefix.helper'; import { isSessionToken } from './core-better-auth-token.helper'; /** @@ -94,13 +95,12 @@ export function extractSessionToken( } } - // Normalize basePath (remove leading slash, replace slashes with dots) - const normalizedBasePath = basePath.replace(/^\//, '').replace(/\//g, '.'); - // Cookie names to check (in order of priority) - // v11.12+: Only native Better-Auth cookie and legacy token + // v11.12+: Only native Better-Auth cookie and legacy token. The session cookie + // name is resolved through the single shared resolver (honours COOKIE_PREFIX), + // so it always matches what Better-Auth actually sets. const cookieNames = [ - `${normalizedBasePath}.session_token`, // Better-Auth native (PRIMARY) + resolveBetterAuthSessionCookieName(basePath), // Better-Auth native (PRIMARY) BETTER_AUTH_COOKIE_NAMES.TOKEN, // Legacy nest-server cookie ]; @@ -320,8 +320,7 @@ export async function toWebRequest(req: Request, options: ToWebRequestOptions): if (sessionToken) { headers.set('authorization', `Bearer ${sessionToken}`); - const normalizedBasePath = basePath?.replace(/^\//, '').replace(/\//g, '.') || 'iam'; - const primaryCookieName = `${normalizedBasePath}.session_token`; + const primaryCookieName = resolveBetterAuthSessionCookieName(basePath || '/iam'); const existingCookieString = headers.get('cookie') || ''; // Check if the request already has a signed session cookie. diff --git a/src/core/modules/better-auth/core-better-auth.controller.ts b/src/core/modules/better-auth/core-better-auth.controller.ts index d7da40c..c17bdca 100644 --- a/src/core/modules/better-auth/core-better-auth.controller.ts +++ b/src/core/modules/better-auth/core-better-auth.controller.ts @@ -776,8 +776,7 @@ export class CoreBetterAuthController { } // Check cookies - Better-Auth native cookie first, then legacy token - const basePath = this.betterAuthService.getBasePath().replace(/^\//, '').replace(/\//g, '.'); - const cookieName = `${basePath}.session_token`; + const cookieName = this.betterAuthService.getSessionCookieName(); return req.cookies?.[cookieName] || req.cookies?.['token'] || null; } diff --git a/src/core/modules/better-auth/core-better-auth.service.ts b/src/core/modules/better-auth/core-better-auth.service.ts index af478fb..38bf4c5 100644 --- a/src/core/modules/better-auth/core-better-auth.service.ts +++ b/src/core/modules/better-auth/core-better-auth.service.ts @@ -9,6 +9,7 @@ import { maskEmail, maskToken } from '../../common/helpers/logging.helper'; import { IBetterAuth, ICookiesConfig } from '../../common/interfaces/server-options.interface'; import { ConfigService } from '../../common/services/config.service'; import { ErrorCode } from '../error-code/error-codes'; +import { resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper'; import { BetterAuthInstance } from './better-auth.config'; import { BetterAuthSessionUser } from './core-better-auth-user.mapper'; import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper'; @@ -65,6 +66,11 @@ export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN'; export class CoreBetterAuthService implements OnModuleInit { private readonly logger = new Logger(CoreBetterAuthService.name); private readonly config: IBetterAuth; + // Cached cookie prefix — frozen on first read so the value cannot drift away + // from the Better-Auth instance (which captured it at bootstrap). Without + // this cache a test or fork that mutates `process.env.COOKIE_PREFIX` after + // boot would push the service and the Better-Auth instance out of lockstep. + private cachedCookiePrefix: null | string = null; constructor( @Optional() @Inject(BETTER_AUTH_INSTANCE) private readonly authInstance: BetterAuthInstance | null, @@ -280,8 +286,28 @@ export class CoreBetterAuthService implements OnModuleInit { * @returns The session cookie name */ getSessionCookieName(): string { - const basePath = this.getBasePath()?.replace(/^\//, '').replace(/\//g, '.') || 'iam'; - return `${basePath}.session_token`; + return `${this.getCookiePrefix()}.session_token`; + } + + /** + * Gets the cookie prefix (the `iam` in `iam.session_token`). + * + * Single source of truth: honours the `COOKIE_PREFIX` env override and falls + * back to the basePath-derived prefix. Every session-cookie call site must + * resolve the name through this (or {@link getSessionCookieName}) so the name + * stays in lockstep across the whole auth pipeline. + * + * The resolved value is cached on first read and reused for the lifetime of + * the service so it cannot drift away from the Better-Auth instance, which + * captures the prefix once at bootstrap. A late mutation of + * `process.env.COOKIE_PREFIX` (typical in tests / forked workers) would + * otherwise make set, read and clear use different names. + */ + getCookiePrefix(): string { + if (this.cachedCookiePrefix === null) { + this.cachedCookiePrefix = resolveBetterAuthCookiePrefix(this.getBasePath() || '/iam'); + } + return this.cachedCookiePrefix; } // =================================================================================================================== @@ -434,8 +460,7 @@ export class CoreBetterAuthService implements OnModuleInit { // Browser clients send unsigned cookies, but Better-Auth expects signed cookies const cookieHeader = headers.get('cookie'); if (cookieHeader && this.config?.secret) { - const basePath = this.getBasePath()?.replace(/^\//, '').replace(/\//g, '.') || 'iam'; - const sessionCookieName = `${basePath}.session_token`; + const sessionCookieName = this.getSessionCookieName(); const cookies = parseCookieHeader(cookieHeader); let modified = false; diff --git a/src/test/test.helper.ts b/src/test/test.helper.ts index 13913f2..e6a5d33 100644 --- a/src/test/test.helper.ts +++ b/src/test/test.helper.ts @@ -8,6 +8,7 @@ import util = require('util'); import ws = require('ws'); import { getStringIds } from '../core/common/helpers/db.helper'; +import { resolveBetterAuthSessionCookieName } from '../core/modules/better-auth/better-auth-cookie-prefix.helper'; /** * GraphQL request type @@ -830,8 +831,11 @@ export class TestHelper { * Sets the token in all relevant cookie names for compatibility. */ static buildBetterAuthCookies(sessionToken: string, basePath: string = 'iam'): Record { + // Resolve the session cookie name through the shared resolver so tests honour + // a COOKIE_PREFIX override exactly like the runtime (otherwise an authenticated + // request in a COOKIE_PREFIX=acme app would send the wrong cookie name). return { - [`${basePath}.session_token`]: sessionToken, + [resolveBetterAuthSessionCookieName(basePath)]: sessionToken, token: sessionToken, }; } @@ -861,10 +865,16 @@ export class TestHelper { /** * Extract a session token from Set-Cookie headers of a supertest response. * Handles signed cookies (value.signature format) by returning only the value part. + * + * The default cookie name is resolved through the shared resolver so it + * honours a `COOKIE_PREFIX` env override exactly like the runtime (otherwise + * tests against a `COOKIE_PREFIX=acme` app would look for the wrong cookie + * and silently return `null`). Tests can still pass an explicit name. */ - static extractSessionToken(response: any, cookieName: string = 'iam.session_token'): null | string { + static extractSessionToken(response: any, cookieName?: string): null | string { + const resolvedCookieName = cookieName ?? resolveBetterAuthSessionCookieName('/iam'); const cookies = TestHelper.extractCookies(response); - const value = cookies[cookieName]; + const value = cookies[resolvedCookieName]; if (!value) { return null; } diff --git a/tests/stories/better-auth-cookie-prefix.story.test.ts b/tests/stories/better-auth-cookie-prefix.story.test.ts new file mode 100644 index 0000000..c8ee840 --- /dev/null +++ b/tests/stories/better-auth-cookie-prefix.story.test.ts @@ -0,0 +1,254 @@ +/** + * Story: COOKIE_PREFIX end-to-end lockstep + * + * As an operator deploying multiple lenne.tech apps on the same shared host, + * I want `COOKIE_PREFIX` to isolate auth cookies across apps, + * So that sign-in / authenticated requests / sign-out all work with the + * overridden cookie name and never silently fall back to the default. + * + * This is the e2e companion to the cross-layer unit tests in + * `tests/unit/better-auth-cookie-helper.spec.ts`. The unit tests prove SET + * and READ agree under override **without** booting Better-Auth; this story + * boots the real ServerModule (with a real Mongo + a real Better-Auth + * instance) and walks through the SAME pipeline a browser client would, so + * any drift between Better-Auth's internal cookie name and the NestJS layer + * fails the test loudly. + * + * The override is set BEFORE the ServerModule import — vitest runs each + * test file in a forked worker (`pool: 'forks'`), so this env mutation + * stays isolated to this file. + */ + +// MUST come before any other import that may transitively load betterAuth.config: +// `createBetterAuthInstance` captures the prefix at bootstrap and freezes it. +process.env.COOKIE_PREFIX = 'acme'; + +import { Test, TestingModule } from '@nestjs/testing'; +import cookieParser = require('cookie-parser'); +import { PubSub } from 'graphql-subscriptions'; +import { MongoClient, ObjectId } from 'mongodb'; + +import { CoreBetterAuthService, HttpExceptionLogFilter, TestHelper } from '../../src'; +import envConfig from '../../src/config.env'; +import { resolveBetterAuthSessionCookieName } from '../../src/core/modules/better-auth/better-auth-cookie-prefix.helper'; +import { ServerModule } from '../../src/server/server.module'; + +describe('Story: BetterAuth COOKIE_PREFIX end-to-end lockstep', () => { + let app: any; + let testHelper: TestHelper; + let betterAuthService: CoreBetterAuthService; + let isBetterAuthEnabled: boolean; + + let mongoClient: MongoClient; + let db: any; + + const testUserIds: string[] = []; + const testIamUserIds: any[] = []; + + const overriddenCookieName = 'acme.session_token'; + + beforeAll(async () => { + expect(process.env.COOKIE_PREFIX).toBe('acme'); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ServerModule], + providers: [{ provide: 'PUB_SUB', useValue: new PubSub() }], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new HttpExceptionLogFilter()); + app.setBaseViewsDir(envConfig.templates.path); + app.setViewEngine(envConfig.templates.engine); + // src/main.ts registers cookie-parser globally — Test.createTestingModule + // does NOT, so without this the NestJS read path's `req.cookies?.[name]` + // lookup would always miss and the e2e would not actually exercise the + // very layer we are guarding. + const cookieSecret = (envConfig as any).cookieSecret || (envConfig as any).betterAuth?.secret; + app.use(cookieSecret ? cookieParser(cookieSecret) : cookieParser()); + await app.init(); + + testHelper = new TestHelper(app); + betterAuthService = moduleFixture.get(CoreBetterAuthService); + isBetterAuthEnabled = betterAuthService.isEnabled(); + + mongoClient = await MongoClient.connect(envConfig.mongoose.uri); + db = mongoClient.db(); + }); + + afterAll(async () => { + if (db) { + for (const userId of testUserIds) { + try { + await db.collection('users').deleteOne({ _id: new ObjectId(userId) }); + } catch { + // Cleanup-only, ignore. + } + } + for (const iamId of testIamUserIds) { + try { + await db.collection('iam_user').deleteOne({ _id: iamId }); + await db.collection('iam_session').deleteMany({ userId: iamId }); + } catch { + // Cleanup-only, ignore. + } + } + } + if (mongoClient) await mongoClient.close(); + if (app) await app.close(); + // The forked worker exits after this file completes, so the env mutation + // does not leak — but reset anyway for fast-fail debuggability. + delete process.env.COOKIE_PREFIX; + }); + + function generateTestEmail(): string { + return `cookie-prefix-${Date.now()}-${Math.random().toString(36).substring(2, 8)}@test.com`; + } + + it('Resolver agrees with Better-Auth on the overridden prefix', () => { + // Sanity check: every layer that derives the cookie name from the env + // now reports the overridden name — not the basePath-derived fallback. + expect(resolveBetterAuthSessionCookieName('/iam')).toBe(overriddenCookieName); + if (isBetterAuthEnabled) { + expect(betterAuthService.getCookiePrefix()).toBe('acme'); + expect(betterAuthService.getSessionCookieName()).toBe(overriddenCookieName); + } + }); + + it('Sign-up: Better-Auth Set-Cookie uses the overridden prefix (not iam.session_token)', async () => { + if (!isBetterAuthEnabled) return; + + const email = generateTestEmail(); + const password = 'SecurePassword123!'; + + // Use supertest directly because we need raw access to the Set-Cookie + // header; testHelper.rest() unwraps to the JSON body and drops headers. + const supertest = require('supertest'); + const signUpRes = await supertest(app.getHttpServer()) + .post('/iam/sign-up/email') + .send({ email, name: 'Cookie Prefix Test User', password, termsAndPrivacyAccepted: true }); + + const iamUser = await db.collection('iam_user').findOne({ email }); + if (iamUser) testIamUserIds.push(iamUser._id); + const user = await db.collection('users').findOne({ email }); + if (user) testUserIds.push(user._id.toString()); + + const setCookieHeaders: string[] = Array.isArray(signUpRes.headers['set-cookie']) + ? signUpRes.headers['set-cookie'] + : signUpRes.headers['set-cookie'] + ? [signUpRes.headers['set-cookie']] + : []; + + expect(setCookieHeaders.length).toBeGreaterThan(0); + const cookieNames = setCookieHeaders.map((h) => h.split('=')[0].trim()); + + // The overridden cookie name MUST be set … + expect(cookieNames).toContain(overriddenCookieName); + // … and the basePath-default name MUST NOT be set in parallel — the bug + // would have set both, or only the iam.* one. + expect(cookieNames).not.toContain('iam.session_token'); + expect(cookieNames).not.toContain('better-auth.session_token'); + }); + + it('Authenticated request: session cookie with the overridden name is honoured by the NestJS layer', async () => { + if (!isBetterAuthEnabled) return; + + const email = generateTestEmail(); + const password = 'SecurePassword123!'; + + // Sign up — Better-Auth writes the session row, no need to re-sign-in. + await testHelper.rest('/iam/sign-up/email', { + method: 'POST', + payload: { email, name: 'Auth-Cookie Test User', password, termsAndPrivacyAccepted: true }, + statusCode: 201, + }); + + const iamUser = await db.collection('iam_user').findOne({ email }); + if (iamUser) testIamUserIds.push(iamUser._id); + const user = await db.collection('users').findOne({ email }); + if (user) testUserIds.push(user._id.toString()); + + // Sign-in to materialize a fresh `session` row. The token field in the + // response body may have been converted to a JWT by the BetterAuth JWT + // plugin — we want the RAW session token (the DB row's `token` field) + // because that is what a browser would echo back inside the + // `acme.session_token` cookie. + await testHelper.rest('/iam/sign-in/email', { + method: 'POST', + payload: { email, password }, + statusCode: 200, + }); + // Better-Auth stores the userId on the session row sometimes as ObjectId, + // sometimes as string (depends on the IAM user shape) — query both. + const sessionRow = await db.collection('session').findOne( + { + $or: [ + { userId: iamUser?._id }, + { userId: iamUser?._id?.toString() }, + ...(user?._id ? [{ userId: user._id }, { userId: user._id.toString() }] : []), + ], + }, + { sort: { createdAt: -1 } }, + ); + expect(sessionRow?.token).toBeTruthy(); + const rawSessionToken: string = sessionRow.token; + + // testHelper.rest with a plain token auto-builds Better-Auth cookies via + // `buildBetterAuthCookies()` which goes through the SAME resolver as the + // runtime. Under COOKIE_PREFIX=acme this attaches `acme.session_token=…`. + // If the NestJS read path were still using `iam.session_token` the + // request would come back as `{ success: false }`. + const sessionRes: any = await testHelper.rest('/iam/session', { + cookies: rawSessionToken, + method: 'GET', + statusCode: 200, + }); + + expect(sessionRes?.success).toBe(true); + expect(sessionRes?.user?.email).toBe(email); + }); + + it('Sign-out: clears the overridden cookie (and only that one)', async () => { + if (!isBetterAuthEnabled) return; + + // Sign up first so we have a real session cookie to send along — sign-out + // without a session does nothing, so we would not exercise the clear path. + const email = generateTestEmail(); + const password = 'SecurePassword123!'; + const supertest = require('supertest'); + const signUpRes = await supertest(app.getHttpServer()) + .post('/iam/sign-up/email') + .send({ email, name: 'Sign-Out Test User', password, termsAndPrivacyAccepted: true }); + + const iamUser = await db.collection('iam_user').findOne({ email }); + if (iamUser) testIamUserIds.push(iamUser._id); + const user = await db.collection('users').findOne({ email }); + if (user) testUserIds.push(user._id.toString()); + + const setCookieFromSignUp: string[] = Array.isArray(signUpRes.headers['set-cookie']) + ? signUpRes.headers['set-cookie'] + : signUpRes.headers['set-cookie'] + ? [signUpRes.headers['set-cookie']] + : []; + const sessionCookieHeader = setCookieFromSignUp.find((h) => h.startsWith(`${overriddenCookieName}=`)); + expect(sessionCookieHeader).toBeDefined(); + const cookiePair = sessionCookieHeader!.split(';')[0]; + + // The custom controller exposes POST /iam/sign-out + const signOutRes = await supertest(app.getHttpServer()) + .post('/iam/sign-out') + .set('Cookie', cookiePair); + + const setCookieHeaders: string[] = Array.isArray(signOutRes.headers['set-cookie']) + ? signOutRes.headers['set-cookie'] + : signOutRes.headers['set-cookie'] + ? [signOutRes.headers['set-cookie']] + : []; + + const cookieNames = setCookieHeaders.map((h) => h.split('=')[0].trim()); + + // The NestJS layer's clear-cookie call MUST target the overridden name — + // otherwise the bug leaves the real session cookie behind in the browser. + expect(cookieNames).toContain(overriddenCookieName); + expect(cookieNames).not.toContain('iam.session_token'); + }); +}); diff --git a/tests/unit/better-auth-cookie-helper.spec.ts b/tests/unit/better-auth-cookie-helper.spec.ts index e00a15f..b0df881 100644 --- a/tests/unit/better-auth-cookie-helper.spec.ts +++ b/tests/unit/better-auth-cookie-helper.spec.ts @@ -4,13 +4,16 @@ * Tests the cookie domain feature, createCookieHelper factory, * and getDefaultCookieOptions behavior. */ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BetterAuthCookieHelper, type BetterAuthCookieHelperConfig, createCookieHelper, } from '../../src/core/modules/better-auth/core-better-auth-cookie.helper'; +import { extractSessionToken } from '../../src/core/modules/better-auth/core-better-auth-web.helper'; +import { CoreBetterAuthService } from '../../src/core/modules/better-auth/core-better-auth.service'; +import { TestHelper } from '../../src/test/test.helper'; describe('BetterAuthCookieHelper', () => { /** @@ -169,4 +172,88 @@ describe('BetterAuthCookieHelper', () => { expect(cookieCalls).toEqual(['iam.session_token']); }); }); + + /** + * Cross-layer lockstep under a COOKIE_PREFIX override. + * + * Regression guard for the bug where the session cookie name was derived from + * basePath INDEPENDENTLY in several places: Better-Auth set `acme.session_token` + * while the NestJS read/clear path still looked for `iam.session_token`, so a + * COOKIE_PREFIX override silently broke sign-in / authenticated requests / logout. + * + * This exercises the SET path (helper) and the READ path (extractSessionToken) + * together — exactly what a sign-in → request → sign-out e2e would catch — but + * deterministically and without a database. + */ + describe('COOKIE_PREFIX override (cross-layer lockstep)', () => { + const previousCookiePrefix = process.env.COOKIE_PREFIX; + + beforeEach(() => { + process.env.COOKIE_PREFIX = 'acme'; + }); + + afterEach(() => { + if (previousCookiePrefix === undefined) { + delete process.env.COOKIE_PREFIX; + } else { + process.env.COOKIE_PREFIX = previousCookiePrefix; + } + }); + + it('SET path: helper uses the overridden cookie name', () => { + const helper = new BetterAuthCookieHelper({ basePath: '/iam', secret: 'test-secret' }); + expect(helper.getCookieName()).toBe('acme.session_token'); + + const cookieCalls: string[] = []; + const mockRes = { cookie: vi.fn((name: string) => cookieCalls.push(name)) } as any; + helper.setSessionCookies(mockRes, 'tok'); + expect(cookieCalls).toContain('acme.session_token'); + + const clearCalls: string[] = []; + const clearRes = { cookie: vi.fn((name: string) => clearCalls.push(name)) } as any; + helper.clearSessionCookies(clearRes); + expect(clearCalls).toContain('acme.session_token'); + }); + + it('READ path: extractSessionToken reads the overridden cookie the SET path wrote', () => { + const req = { cookies: { 'acme.session_token': 'tok' }, headers: {} } as any; + expect(extractSessionToken(req, '/iam', { skipAuthHeader: true })).toBe('tok'); + }); + + it('READ path: the old basePath-derived name is NO LONGER honoured under override (catches the bug)', () => { + // With the previous per-site derivation this would still resolve 'tok'; + // now the read path resolves through the same resolver, so it must miss. + const req = { cookies: { 'iam.session_token': 'tok' }, headers: {} } as any; + expect(extractSessionToken(req, '/iam', { skipAuthHeader: true })).toBeNull(); + }); + + it('TestHelper.extractSessionToken: default cookie name follows the COOKIE_PREFIX override', () => { + // A test that calls extractSessionToken(res) WITHOUT an explicit name + // would silently return null under a COOKIE_PREFIX=acme app if the + // default was still the hardcoded 'iam.session_token'. + const response = { + headers: { 'set-cookie': ['acme.session_token=secret-token; Path=/; HttpOnly'] }, + }; + expect(TestHelper.extractSessionToken(response)).toBe('secret-token'); + }); + + it('Service cache: getCookiePrefix freezes the value on first read so a late env mutation cannot drift', () => { + // Service is constructed BEFORE process.env.COOKIE_PREFIX changes — this + // simulates a forked test worker that boots Better-Auth and only then a + // sibling test mutates the env. Without the cache, getCookiePrefix would + // return the new value while the Better-Auth instance is still pinned to + // the old one. + // ts-expect-error — bypass DI by passing all-undefined to the constructor. + const service: any = new (CoreBetterAuthService as any)(null, undefined, { basePath: '/iam' }); + + expect(service.getCookiePrefix()).toBe('acme'); + const cookieName = service.getSessionCookieName(); + expect(cookieName).toBe('acme.session_token'); + + // Late mutation — must NOT change what the service reports. + process.env.COOKIE_PREFIX = 'kit-test'; + expect(service.getCookiePrefix()).toBe('acme'); + expect(service.getSessionCookieName()).toBe('acme.session_token'); + }); + }); }); diff --git a/tests/unit/better-auth-cookie-prefix.spec.ts b/tests/unit/better-auth-cookie-prefix.spec.ts new file mode 100644 index 0000000..2017062 --- /dev/null +++ b/tests/unit/better-auth-cookie-prefix.spec.ts @@ -0,0 +1,107 @@ +/** + * Unit tests for resolveBetterAuthCookiePrefix. + * + * Precedence: + * 1. COOKIE_PREFIX env (dedicated, always wins) — for fully autonomous cookie + * isolation on a shared host. Mirrors the frontend NUXT_PUBLIC_COOKIE_PREFIX. + * 2. basePath-derived prefix ('/iam' → 'iam') — backward-compatible default. + */ +import { describe, expect, it } from 'vitest'; + +import { + detectCookiePrefixDrift, + resolveBetterAuthCookiePrefix, + resolveBetterAuthSessionCookieName, +} from '../../src/core/modules/better-auth/better-auth-cookie-prefix.helper'; + +describe('resolveBetterAuthCookiePrefix', () => { + describe('basePath-derived default (no COOKIE_PREFIX)', () => { + it("derives 'iam' from '/iam'", () => { + expect(resolveBetterAuthCookiePrefix('/iam', {})).toBe('iam'); + }); + + it("derives 'api.iam' from '/api/iam' (slashes → dots)", () => { + expect(resolveBetterAuthCookiePrefix('/api/iam', {})).toBe('api.iam'); + }); + + it('falls back to /iam when basePath is empty', () => { + expect(resolveBetterAuthCookiePrefix('', {})).toBe('iam'); + }); + + it('ignores an empty COOKIE_PREFIX and uses the basePath default', () => { + expect(resolveBetterAuthCookiePrefix('/iam', { COOKIE_PREFIX: '' })).toBe('iam'); + }); + + it('ignores a whitespace-only COOKIE_PREFIX', () => { + expect(resolveBetterAuthCookiePrefix('/iam', { COOKIE_PREFIX: ' ' })).toBe('iam'); + }); + }); + + describe('COOKIE_PREFIX env (always wins)', () => { + it('overrides the basePath-derived prefix', () => { + expect(resolveBetterAuthCookiePrefix('/iam', { COOKIE_PREFIX: 'acme' })).toBe('acme'); + }); + + it('overrides even a multi-segment basePath', () => { + expect(resolveBetterAuthCookiePrefix('/api/iam', { COOKIE_PREFIX: 'kit-test' })).toBe('kit-test'); + }); + + it('trims surrounding whitespace', () => { + expect(resolveBetterAuthCookiePrefix('/iam', { COOKIE_PREFIX: ' acme ' })).toBe('acme'); + }); + }); + + describe('sanitisation (mirrors the frontend resolver)', () => { + it('keeps the safe cookie-name subset [A-Za-z0-9._-]', () => { + expect(resolveBetterAuthCookiePrefix('/iam', { COOKIE_PREFIX: 'Acme.kit-test_01' })).toBe('Acme.kit-test_01'); + }); + + it('strips characters that would corrupt a Set-Cookie header', () => { + expect(resolveBetterAuthCookiePrefix('/iam', { COOKIE_PREFIX: 'a;c=me x\r\n' })).toBe('acmex'); + }); + + it('falls back to the basePath default when the prefix sanitises to empty', () => { + expect(resolveBetterAuthCookiePrefix('/iam', { COOKIE_PREFIX: ';;==' })).toBe('iam'); + }); + }); +}); + +describe('resolveBetterAuthSessionCookieName', () => { + it("derives '.session_token' by default", () => { + expect(resolveBetterAuthSessionCookieName('/iam', {})).toBe('iam.session_token'); + }); + + it('honours the COOKIE_PREFIX override', () => { + expect(resolveBetterAuthSessionCookieName('/iam', { COOKIE_PREFIX: 'acme' })).toBe('acme.session_token'); + }); + + it('sanitises the override before composing the name', () => { + expect(resolveBetterAuthSessionCookieName('/iam', { COOKIE_PREFIX: 'a;c=me' })).toBe('acme.session_token'); + }); +}); + +describe('detectCookiePrefixDrift', () => { + it('returns null when there is no advanced object', () => { + expect(detectCookiePrefixDrift('iam', undefined)).toBeNull(); + expect(detectCookiePrefixDrift('iam', null)).toBeNull(); + }); + + it('returns null when advanced has no cookiePrefix', () => { + expect(detectCookiePrefixDrift('iam', { foo: 'bar' })).toBeNull(); + }); + + it('returns null when programmatic prefix matches resolved prefix', () => { + expect(detectCookiePrefixDrift('iam', { cookiePrefix: 'iam' })).toBeNull(); + }); + + it('returns null when programmatic cookiePrefix is not a string', () => { + expect(detectCookiePrefixDrift('iam', { cookiePrefix: 123 })).toBeNull(); + }); + + it('returns a warning sentence when programmatic and resolved prefixes diverge', () => { + const warning = detectCookiePrefixDrift('acme', { cookiePrefix: 'kit-test' }); + expect(warning).toContain('options.advanced.cookiePrefix="kit-test"'); + expect(warning).toContain('NestJS layer still uses "acme"'); + expect(warning).toContain('COOKIE_PREFIX env variable'); + }); +});