@lenne.tech/nuxt-extensions 1.8.2 → 1.8.4
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.md
CHANGED
|
@@ -133,6 +133,13 @@ The `useLtAuth()` composable manages the `lt-auth-state` cookie for authenticati
|
|
|
133
133
|
3. If `useLtAuth()` can't find a valid user in the cookie on the server, it initializes with `{user: null}` — this is intentional and correct
|
|
134
134
|
4. The `iam.session_token` httpOnly cookie (set by Better Auth) is the actual session identifier — `lt-auth-state` is a convenience cache for the client-side middleware
|
|
135
135
|
|
|
136
|
+
### Session User Merge (nest-server-only fields):
|
|
137
|
+
On session re-validation (`validateSession()` at app init / hard reload, and the passkey get-session fallback), `useLtAuth()` **merges** the Better-Auth session user onto the cached user of the SAME identity via the private `mergeSessionUser()`, instead of overwriting it. Consequences you must know:
|
|
138
|
+
|
|
139
|
+
- **nest-server-only fields persist across reloads.** Better-Auth's get-session returns only Better-Auth-owned fields (id/email/name + registered additionalFields). Fields it does not own (e.g. custom preferences like `leadTableColumns`) previously vanished on every reload; they now survive. If you built a workaround that re-fetched such fields after each reload, you can retire it.
|
|
140
|
+
- **Authorization fields are fail-closed.** `role`, `banned`, `banExpires`, `banReason`, `emailVerified`, `twoFactorEnabled` always reflect the session — any of these the session omits is dropped from the merge (never kept stale), so a backend downgrade is reflected client-side. If your project adds its OWN authorization-relevant user field, either register it as a Better-Auth additionalField (so get-session returns it) or add it to the `AUTHZ_KEYS` list in `use-lt-auth.ts` — otherwise it could persist stale in the client cache.
|
|
141
|
+
- **Keep the cached user lean.** `lt-auth-state` is a non-httpOnly cookie sent on every same-site request and capped at ~4 KB by browsers. Because the merge keeps preference fields across reloads, large blobs on the user object can push the cookie over the limit — the browser then silently rejects the write and the next reload reads as a logout. `setUser` emits a dev-mode warning above ~3.5 KB; move large preference data off the user object and fetch it from an authenticated API.
|
|
142
|
+
|
|
136
143
|
### Custom Auth Middleware Pattern:
|
|
137
144
|
```typescript
|
|
138
145
|
// CORRECT — read-only check, no cookie mutation
|
|
@@ -58,7 +58,13 @@ export function useLtAuth() {
|
|
|
58
58
|
if (import.meta.client) {
|
|
59
59
|
const maxAge = 60 * 60 * 24 * 7;
|
|
60
60
|
const secure = globalThis.location?.protocol === "https:" ? "; secure" : "";
|
|
61
|
-
|
|
61
|
+
const encoded = encodeURIComponent(JSON.stringify(newState));
|
|
62
|
+
if (import.meta.dev && encoded.length > 3500) {
|
|
63
|
+
console.warn(
|
|
64
|
+
`[lt-auth] lt-auth-state cookie is ${encoded.length} bytes (encoded), approaching the ~4KB browser limit \u2014 an oversized cookie is dropped on write and reads as a logout on reload. Keep the cached user lean: move large preference data off the user object.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
document.cookie = `${stateCookieName}=${encoded}; path=/; max-age=${maxAge}; samesite=lax${secure}`;
|
|
62
68
|
}
|
|
63
69
|
}
|
|
64
70
|
function clearUser() {
|
|
@@ -123,6 +129,20 @@ export function useLtAuth() {
|
|
|
123
129
|
}
|
|
124
130
|
return response;
|
|
125
131
|
}
|
|
132
|
+
const AUTHZ_KEYS = ["banExpires", "banReason", "banned", "emailVerified", "role", "twoFactorEnabled"];
|
|
133
|
+
function mergeSessionUser(sessionUser) {
|
|
134
|
+
const cached = resolvedAuthState.value?.user;
|
|
135
|
+
if (!cached?.id || !sessionUser?.id || cached.id !== sessionUser.id) {
|
|
136
|
+
return sessionUser;
|
|
137
|
+
}
|
|
138
|
+
const merged = { ...cached, ...sessionUser };
|
|
139
|
+
for (const key of AUTHZ_KEYS) {
|
|
140
|
+
if (!(key in sessionUser)) {
|
|
141
|
+
delete merged[key];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return merged;
|
|
145
|
+
}
|
|
126
146
|
async function validateSession() {
|
|
127
147
|
try {
|
|
128
148
|
const session = authClient.useSession();
|
|
@@ -141,7 +161,7 @@ export function useLtAuth() {
|
|
|
141
161
|
});
|
|
142
162
|
}
|
|
143
163
|
if (session.value.data?.user) {
|
|
144
|
-
setUser(session.value.data.user, "cookie");
|
|
164
|
+
setUser(mergeSessionUser(session.value.data.user), "cookie");
|
|
145
165
|
switchToJwtMode().catch(() => {
|
|
146
166
|
});
|
|
147
167
|
return true;
|
|
@@ -297,8 +317,9 @@ export function useLtAuth() {
|
|
|
297
317
|
if (sessionResponse.ok) {
|
|
298
318
|
const sessionData = await sessionResponse.json();
|
|
299
319
|
if (sessionData?.user) {
|
|
300
|
-
|
|
301
|
-
|
|
320
|
+
const mergedUser = mergeSessionUser(sessionData.user);
|
|
321
|
+
result.user = mergedUser;
|
|
322
|
+
setUser(mergedUser, "cookie");
|
|
302
323
|
switchToJwtMode().catch(() => {
|
|
303
324
|
});
|
|
304
325
|
}
|
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
* Auth Interceptor Plugin
|
|
3
3
|
*
|
|
4
4
|
* This plugin intercepts all API responses and handles session expiration.
|
|
5
|
-
* When a 401 (Unauthorized) response is received, it
|
|
5
|
+
* When a 401 (Unauthorized) response is received, it verifies against the
|
|
6
|
+
* session endpoint that the session is genuinely dead and then automatically:
|
|
6
7
|
* 1. Clears the user session state
|
|
7
8
|
* 2. Redirects to the login page
|
|
8
9
|
*
|
|
10
|
+
* The verification step exists because a 401 from a domain endpoint is not
|
|
11
|
+
* proof of an expired session: backends may mislabel permission errors
|
|
12
|
+
* (authenticated user, missing rights — semantically 403) as 401. Logging out
|
|
13
|
+
* on those would kick a logged-in user out of the app for a mere missing
|
|
14
|
+
* right. Only a dead session may clear state.
|
|
15
|
+
*
|
|
9
16
|
* Note: This is a client-only plugin (.client.ts) since auth state
|
|
10
17
|
* management only makes sense in the browser context.
|
|
11
18
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useLtAuth } from "../composables/auth/use-lt-auth.js";
|
|
2
|
+
import { getLtApiBase } from "../lib/auth-state.js";
|
|
2
3
|
export default (nuxtApp) => {
|
|
3
4
|
if (import.meta.server) return;
|
|
4
5
|
let _authInstance = null;
|
|
@@ -42,6 +43,19 @@ export default (nuxtApp) => {
|
|
|
42
43
|
];
|
|
43
44
|
return authEndpoints.some((endpoint) => url.includes(endpoint));
|
|
44
45
|
}
|
|
46
|
+
async function isSessionStillAlive() {
|
|
47
|
+
try {
|
|
48
|
+
const { fetchWithAuth } = getAuth();
|
|
49
|
+
const response = await fetchWithAuth(`${getLtApiBase()}/get-session`, { method: "GET" });
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const data = await response.json().catch(() => null);
|
|
54
|
+
return Boolean(data && (data.user || data.session));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
45
59
|
async function handleUnauthorized(requestUrl) {
|
|
46
60
|
if (isHandling401) {
|
|
47
61
|
return;
|
|
@@ -56,6 +70,13 @@ export default (nuxtApp) => {
|
|
|
56
70
|
try {
|
|
57
71
|
const { clearUser, isAuthenticated } = getAuth();
|
|
58
72
|
if (isAuthenticated.value) {
|
|
73
|
+
const sessionAlive = await isSessionStillAlive();
|
|
74
|
+
if (sessionAlive !== false) {
|
|
75
|
+
console.debug(
|
|
76
|
+
sessionAlive ? `[LtAuth Interceptor] 401 from ${requestUrl ?? "unknown URL"} but session is still valid \u2014 treating it as a permission error, not logging out` : "[LtAuth Interceptor] 401 received but session state could not be verified \u2014 not logging out"
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
59
80
|
console.debug("[LtAuth Interceptor] Session expired, logging out...");
|
|
60
81
|
clearUser();
|
|
61
82
|
const router = nuxtApp.$router;
|