@lenne.tech/nuxt-extensions 1.7.1 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.mjs CHANGED
@@ -73,6 +73,9 @@ const module$1 = defineNuxtModule({
73
73
  if (!rc.public.apiUrl) {
74
74
  rc.public.apiUrl = "";
75
75
  }
76
+ if (!rc.public.cookiePrefix) {
77
+ rc.public.cookiePrefix = "";
78
+ }
76
79
  nuxt.options.runtimeConfig.public.ltExtensions = {
77
80
  ai: {
78
81
  basePath: resolvedOptions.ai?.basePath || "/ai",
@@ -1,6 +1,6 @@
1
- import { useNuxtApp, useCookie, useState, ref, computed, watch } from "#imports";
1
+ import { useNuxtApp, useCookie, useState, useRequestHeaders, ref, computed, watch } from "#imports";
2
2
  import { ltArrayBufferToBase64Url, ltBase64UrlToUint8Array } from "../../utils/crypto.js";
3
- import { clearLtAuthCookies, getLtApiBase, getLtAuthCookieNames } from "../../lib/auth-state.js";
3
+ import { clearLtAuthCookies, getLtApiBase, getLtAuthCookieNames, resolveLtAuthState } from "../../lib/auth-state.js";
4
4
  import { useLtAuthClient } from "../use-lt-auth-client.js";
5
5
  function useTranslation() {
6
6
  const nuxtApp = useNuxtApp();
@@ -23,32 +23,28 @@ export function useLtAuth() {
23
23
  });
24
24
  if (import.meta.client) {
25
25
  try {
26
- const cookieStr = document.cookie.split("; ").find((row) => row.startsWith(`${stateCookieName}=`));
27
- if (cookieStr) {
28
- const parts = cookieStr.split("=");
29
- const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join("=")) : "";
30
- if (value) {
31
- const parsed = JSON.parse(value);
32
- if (parsed?.user && !authState.value?.user) {
33
- authState.value = parsed;
34
- }
35
- }
26
+ const resolved = resolveLtAuthState(document.cookie, stateCookieName);
27
+ if (resolved?.user && !authState.value?.user) {
28
+ authState.value = resolved;
36
29
  }
37
30
  } catch {
38
31
  }
39
32
  }
40
- if (import.meta.server && (authState.value === null || authState.value === void 0)) {
41
- authState.value = { user: null, authMode: "cookie" };
42
- }
33
+ const ssrCookieHeader = import.meta.server ? useRequestHeaders(["cookie"]).cookie || "" : "";
43
34
  const jwtToken = useCookie(tokenCookieName, {
44
35
  maxAge: 60 * 60 * 24 * 7,
45
36
  // 7 days
46
37
  sameSite: "lax"
47
38
  });
48
39
  const isLoading = ref(false);
49
- const authMode = computed(() => authState.value?.authMode || "cookie");
40
+ const resolvedAuthState = computed(() => {
41
+ const refVal = authState.value;
42
+ const cookieString = import.meta.client ? document.cookie : ssrCookieHeader;
43
+ return resolveLtAuthState(cookieString, stateCookieName) ?? refVal ?? null;
44
+ });
45
+ const authMode = computed(() => resolvedAuthState.value?.authMode || "cookie");
50
46
  const isJwtMode = computed(() => authMode.value === "jwt");
51
- const user = computed(() => authState.value?.user ?? null);
47
+ const user = computed(() => resolvedAuthState.value?.user ?? null);
52
48
  const isAuthenticated = computed(() => !!user.value);
53
49
  const isAdmin = computed(() => user.value?.role === "admin");
54
50
  const is2FAEnabled = computed(() => user.value?.twoFactorEnabled ?? false);
@@ -56,7 +52,9 @@ export function useLtAuth() {
56
52
  const featuresFetched = useState("lt-auth-features-fetched", () => false);
57
53
  function setUser(userData, mode = "cookie") {
58
54
  const newState = { user: userData, authMode: mode };
59
- authState.value = newState;
55
+ if (import.meta.client || userData) {
56
+ authState.value = newState;
57
+ }
60
58
  if (import.meta.client) {
61
59
  const maxAge = 60 * 60 * 24 * 7;
62
60
  const secure = globalThis.location?.protocol === "https:" ? "; secure" : "";
@@ -64,10 +62,9 @@ export function useLtAuth() {
64
62
  }
65
63
  }
66
64
  function clearUser() {
67
- const clearedState = { user: null, authMode: "cookie" };
68
- authState.value = clearedState;
69
- jwtToken.value = null;
70
65
  if (import.meta.client) {
66
+ authState.value = { user: null, authMode: "cookie" };
67
+ jwtToken.value = null;
71
68
  clearLtAuthCookies();
72
69
  }
73
70
  }
@@ -11,21 +11,85 @@
11
11
  *
12
12
  * The state is persisted in cookies for SSR compatibility.
13
13
  */
14
- import type { LtAuthMode } from '../types/index.js';
14
+ import type { LtAuthMode, LtAuthState } from '../types/index.js';
15
15
  /** Default name of the auth-state cookie. Used when the module config has not been resolved yet. */
16
16
  export declare const LT_AUTH_STATE_COOKIE_DEFAULT = "lt-auth-state";
17
17
  /** Default name of the JWT-token cookie. Used when the module config has not been resolved yet. */
18
18
  export declare const LT_JWT_TOKEN_COOKIE_DEFAULT = "lt-jwt-token";
19
+ /**
20
+ * Resolve the cookie prefix that drives the auth cookie names.
21
+ *
22
+ * A single, dedicated, OPT-IN knob with a safe default:
23
+ * - **`cookiePrefix`** (`NUXT_PUBLIC_COOKIE_PREFIX` →
24
+ * `runtimeConfig.public.cookiePrefix`) → `<prefix>-auth-state` /
25
+ * `<prefix>-jwt-token`. Lets a project run with its own cookie namespace —
26
+ * e.g. several lenne.tech apps on a shared host during development, where
27
+ * cookies collide by host (not port) and would otherwise read each other's
28
+ * `lt-auth-state` (a "ghost" user from the other project).
29
+ * - otherwise `''` → the default `lt-auth-state` / `lt-jwt-token`.
30
+ *
31
+ * `storagePrefix` deliberately does NOT influence the cookie name. It is a
32
+ * localStorage-namespacing convention; coupling it to cookies would silently
33
+ * rename auth cookies on a mere upgrade (logging every user out, bouncing valid
34
+ * sessions in custom middleware that reads `lt-auth-state` directly) and force
35
+ * frontend/backend to be deployed in lockstep. Cookie naming is therefore
36
+ * controlled ONLY by this explicit, opt-in knob → fully backward compatible.
37
+ *
38
+ * IMPORTANT: when you set `cookiePrefix`, mirror it on the backend
39
+ * (`COOKIE_PREFIX` env — see nest-server `resolveBetterAuthCookiePrefix`) so
40
+ * both sides always agree on the cookie name.
41
+ */
42
+ export declare function resolveLtCookiePrefix(pub: null | Record<string, any> | undefined): string;
19
43
  /**
20
44
  * Resolve the configured auth cookie names from runtime config.
21
45
  *
22
- * Falls back to the defaults (`lt-auth-state`, `lt-jwt-token`) so callers
23
- * keep working even outside a Nuxt context (tests, edge SSR boots).
46
+ * **Per-project isolation (collision avoidance).** Cookies are scoped by
47
+ * host+path, NOT by port. Two lenne.tech apps sharing a host during development
48
+ * (e.g. both on `localhost`) would otherwise read each other's `lt-auth-state`
49
+ * cookie — surfacing a "ghost" user from the other project. The cookie name is
50
+ * therefore derived from a per-project prefix (see {@link resolveLtCookiePrefix}).
51
+ *
52
+ * Resolution order:
53
+ * 1. an explicitly configured `cookieNames.state/token` (exact name) always wins;
54
+ * 2. otherwise `<prefix>-auth-state` / `<prefix>-jwt-token` where `prefix`
55
+ * comes from {@link resolveLtCookiePrefix} (the opt-in `cookiePrefix`);
56
+ * 3. otherwise fall back to the legacy `lt-auth-state` / `lt-jwt-token`
57
+ * (backward compatible — no behaviour change for apps without a prefix).
58
+ *
59
+ * Falls back to the defaults so callers keep working even outside a Nuxt
60
+ * context (tests, edge SSR boots).
24
61
  */
25
62
  export declare function getLtAuthCookieNames(): {
26
63
  state: string;
27
64
  token: string;
28
65
  };
66
+ /**
67
+ * Resolve the authoritative auth state from a raw `Cookie` header /
68
+ * `document.cookie` string, tolerating **multiple** auth-state cookies with the
69
+ * same name.
70
+ *
71
+ * A deployed setup can end up with TWO auth-state cookies in parallel — a
72
+ * host-only one (written by `useCookie` / `setUser`) and a domain-scoped one
73
+ * (e.g. set by a backend SAML callback with `Domain=<appHost>` so it is
74
+ * readable across `app` and `api.app`). They can disagree: one carries the
75
+ * signed-in user, the other a stale `{ user: null }` left behind by a partial
76
+ * clear. A naive single-value read (`useCookie`, or `document.cookie.find`)
77
+ * may pick the stale twin and wrongly report the user as logged out — which
78
+ * makes SSR auth guards bounce a perfectly valid session to the login page.
79
+ *
80
+ * This scans ALL matching entries and prefers the one that actually carries a
81
+ * `user`; only when none do does it return the (user-less) fallback state.
82
+ *
83
+ * SECURITY NOTE: preferring the user-bearing twin is intentional and required —
84
+ * preferring the `{ user: null }` twin is exactly what caused the random logout.
85
+ * `lt-auth-state` is a NON-authoritative client convenience cache for the app
86
+ * shell, NOT the session identifier (that is the httpOnly session cookie, e.g.
87
+ * `iam.session_token`). A forged/injected user-bearing cookie therefore yields
88
+ * at most UI spoofing ("looks logged in") — every real API/server call still
89
+ * authenticates against the httpOnly session and 401s. This trust boundary is
90
+ * unchanged by the duplicate-tolerant read; it only decides which twin wins.
91
+ */
92
+ export declare function resolveLtAuthState(cookieString: string, stateCookieName?: string): LtAuthState | null;
29
93
  /**
30
94
  * Determines if HTTP requests should use the Nuxt Vite dev proxy.
31
95
  *
@@ -1,18 +1,47 @@
1
1
  import { useRuntimeConfig } from "#imports";
2
2
  export const LT_AUTH_STATE_COOKIE_DEFAULT = "lt-auth-state";
3
3
  export const LT_JWT_TOKEN_COOKIE_DEFAULT = "lt-jwt-token";
4
+ function sanitizeCookiePrefix(raw) {
5
+ if (typeof raw !== "string") {
6
+ return "";
7
+ }
8
+ return raw.trim().replace(/[^A-Za-z0-9._-]/g, "");
9
+ }
10
+ export function resolveLtCookiePrefix(pub) {
11
+ return sanitizeCookiePrefix(pub?.cookiePrefix);
12
+ }
4
13
  export function getLtAuthCookieNames() {
5
14
  try {
6
15
  const runtimeConfig = useRuntimeConfig();
7
- const configured = runtimeConfig.public?.ltExtensions?.auth?.cookieNames;
8
- return {
9
- state: configured?.state || LT_AUTH_STATE_COOKIE_DEFAULT,
10
- token: configured?.token || LT_JWT_TOKEN_COOKIE_DEFAULT
11
- };
16
+ const pub = runtimeConfig.public;
17
+ const configured = pub?.ltExtensions?.auth?.cookieNames;
18
+ const prefix = resolveLtCookiePrefix(pub);
19
+ const state = configured?.state && configured.state !== LT_AUTH_STATE_COOKIE_DEFAULT ? configured.state : prefix ? `${prefix}-auth-state` : LT_AUTH_STATE_COOKIE_DEFAULT;
20
+ const token = configured?.token && configured.token !== LT_JWT_TOKEN_COOKIE_DEFAULT ? configured.token : prefix ? `${prefix}-jwt-token` : LT_JWT_TOKEN_COOKIE_DEFAULT;
21
+ return { state, token };
12
22
  } catch {
13
23
  return { state: LT_AUTH_STATE_COOKIE_DEFAULT, token: LT_JWT_TOKEN_COOKIE_DEFAULT };
14
24
  }
15
25
  }
26
+ export function resolveLtAuthState(cookieString, stateCookieName) {
27
+ const name = stateCookieName || getLtAuthCookieNames().state;
28
+ const prefix = `${name}=`;
29
+ let fallback = null;
30
+ for (const entry of (cookieString || "").split("; ")) {
31
+ if (!entry.startsWith(prefix)) continue;
32
+ try {
33
+ const state = JSON.parse(decodeURIComponent(entry.slice(prefix.length)));
34
+ if (state?.user) {
35
+ return state;
36
+ }
37
+ if (state) {
38
+ fallback = state;
39
+ }
40
+ } catch {
41
+ }
42
+ }
43
+ return fallback;
44
+ }
16
45
  let _proxyFallbackWarned = false;
17
46
  export function isLocalDevApiProxy() {
18
47
  if (import.meta.server) {
@@ -65,31 +94,20 @@ export function buildLtApiUrl(path) {
65
94
  }
66
95
  export function getLtAuthMode() {
67
96
  if (import.meta.server) return "cookie";
68
- try {
69
- const { state: stateCookieName } = getLtAuthCookieNames();
70
- const cookie = document.cookie.split("; ").find((row) => row.startsWith(`${stateCookieName}=`));
71
- if (cookie) {
72
- const parts = cookie.split("=");
73
- const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join("=")) : "";
74
- const state = JSON.parse(value);
75
- return state?.authMode || "cookie";
76
- }
77
- } catch {
78
- }
79
- return "cookie";
97
+ return resolveLtAuthState(document.cookie)?.authMode || "cookie";
80
98
  }
81
99
  export function getLtJwtToken() {
82
100
  if (import.meta.server) return null;
83
101
  try {
84
102
  const { token: tokenCookieName } = getLtAuthCookieNames();
85
- const cookie = document.cookie.split("; ").find((row) => row.startsWith(`${tokenCookieName}=`));
86
- if (cookie) {
87
- const parts = cookie.split("=");
88
- const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join("=")) : "";
89
- if (value.startsWith('"') && value.endsWith('"')) {
90
- return JSON.parse(value);
103
+ const cookiePrefix = `${tokenCookieName}=`;
104
+ for (const row of document.cookie.split("; ")) {
105
+ if (!row.startsWith(cookiePrefix)) continue;
106
+ const raw = decodeURIComponent(row.slice(cookiePrefix.length));
107
+ const value = raw.startsWith('"') && raw.endsWith('"') ? JSON.parse(raw) : raw;
108
+ if (value) {
109
+ return value;
91
110
  }
92
- return value || null;
93
111
  }
94
112
  } catch {
95
113
  }
@@ -110,27 +128,32 @@ export function clearLtAuthCookies() {
110
128
  if (import.meta.server) return;
111
129
  const { state: stateCookieName, token: tokenCookieName } = getLtAuthCookieNames();
112
130
  const secure = globalThis.location?.protocol === "https:" ? "; secure" : "";
131
+ const host = globalThis.location?.hostname;
132
+ const domainAttr = host ? `; domain=${host}` : "";
113
133
  document.cookie = `${stateCookieName}=; path=/; max-age=0; samesite=lax${secure}`;
134
+ if (domainAttr) {
135
+ document.cookie = `${stateCookieName}=; path=/; max-age=0; samesite=lax${secure}${domainAttr}`;
136
+ }
114
137
  document.cookie = `${tokenCookieName}=; path=/; max-age=0; samesite=lax${secure}`;
138
+ if (domainAttr) {
139
+ document.cookie = `${tokenCookieName}=; path=/; max-age=0; samesite=lax${secure}${domainAttr}`;
140
+ }
115
141
  const sessionCookieNames = ["better-auth.session_token", "better-auth.session", "__Secure-better-auth.session_token", "session_token", "session"];
116
142
  for (const name of sessionCookieNames) {
117
- document.cookie = `${name}=; path=/; max-age=0`;
118
- document.cookie = `${name}=; path=/api; max-age=0`;
119
- document.cookie = `${name}=; path=/api/iam; max-age=0`;
120
- document.cookie = `${name}=; path=/iam; max-age=0`;
143
+ for (const path of ["/", "/api", "/api/iam", "/iam"]) {
144
+ document.cookie = `${name}=; path=${path}; max-age=0`;
145
+ if (domainAttr) {
146
+ document.cookie = `${name}=; path=${path}; max-age=0${domainAttr}`;
147
+ }
148
+ }
121
149
  }
122
150
  }
123
151
  export function setLtAuthMode(mode) {
124
152
  if (import.meta.server) return;
125
153
  try {
126
154
  const { state: stateCookieName } = getLtAuthCookieNames();
127
- const cookie = document.cookie.split("; ").find((row) => row.startsWith(`${stateCookieName}=`));
128
- let state = { user: null, authMode: mode };
129
- if (cookie) {
130
- const parts = cookie.split("=");
131
- const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join("=")) : "";
132
- state = { ...JSON.parse(value), authMode: mode };
133
- }
155
+ const existing = resolveLtAuthState(document.cookie, stateCookieName);
156
+ const state = { ...existing ?? { user: null }, authMode: mode };
134
157
  const maxAge = 60 * 60 * 24 * 7;
135
158
  const secure = globalThis.location?.protocol === "https:" ? "; secure" : "";
136
159
  document.cookie = `${stateCookieName}=${encodeURIComponent(JSON.stringify(state))}; path=/; max-age=${maxAge}; samesite=lax${secure}`;
@@ -170,18 +193,7 @@ export async function attemptLtJwtSwitch(basePath = "/iam") {
170
193
  }
171
194
  export function isLtAuthenticated() {
172
195
  if (import.meta.server) return false;
173
- try {
174
- const { state: stateCookieName } = getLtAuthCookieNames();
175
- const cookie = document.cookie.split("; ").find((row) => row.startsWith(`${stateCookieName}=`));
176
- if (cookie) {
177
- const parts = cookie.split("=");
178
- const value = parts.length > 1 ? decodeURIComponent(parts.slice(1).join("=")) : "";
179
- const state = JSON.parse(value);
180
- return !!state?.user;
181
- }
182
- } catch {
183
- }
184
- return false;
196
+ return !!resolveLtAuthState(document.cookie)?.user;
185
197
  }
186
198
  export function createLtAuthFetch(basePath = "/iam") {
187
199
  return async function ltAuthFetch2(input, init) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nuxt-extensions",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,7 +51,8 @@
51
51
  "prepare": "git config core.hooksPath .githooks",
52
52
  "prepack": "node scripts/sync-module-version.mjs && nuxt-module-build build",
53
53
  "c": "pnpm run check",
54
- "check": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm run dev:prepare && pnpm test:types && pnpm test && pnpm run build",
54
+ "check": "node scripts/check.mjs",
55
+ "check:raw": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm run dev:prepare && pnpm test:types && pnpm test && pnpm run build",
55
56
  "check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm test:types && pnpm test && pnpm run build",
56
57
  "check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm test:types && pnpm test && pnpm run build",
57
58
  "cf": "pnpm run check:fix",
@@ -121,7 +122,14 @@
121
122
  "brace-expansion@>=5.0.0 <5.0.6": "5.0.6",
122
123
  "devalue@>=5.6.3 <=5.8.0": "5.8.1",
123
124
  "kysely@>=0.26.0 <0.28.17": "0.28.17",
124
- "ws@>=8.0.0 <8.20.1": "8.21.0"
125
+ "ws@>=8.0.0 <8.20.1": "8.21.0",
126
+ "shell-quote@<1.8.4": "1.8.4",
127
+ "nuxt@<4.4.7": "4.4.7",
128
+ "vite@>=7.0.0 <7.3.5": "7.3.5",
129
+ "tar@<7.5.16": "7.5.16",
130
+ "launch-editor@<2.14.1": "2.14.1",
131
+ "esbuild@<0.28.1": "0.28.1",
132
+ "@babel/core@<7.29.6": "7.29.6"
125
133
  }
126
134
  },
127
135
  "keywords": [