From 4feb9824cb30f3aaf480a5f4c9586ee6075d06fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Ko=CC=88nig?= Date: Mon, 13 Jul 2026 11:12:25 +0200 Subject: [PATCH] 1.8.4: verify the session before logging out on 401 so mislabeled permission errors no longer kick authenticated users out The client auth interceptor treated EVERY 401 from a non-auth endpoint as an expired session: clearUser() + hard redirect to login. Backends may mislabel permission errors (authenticated user, missing rights - semantically 403) as 401, so a mere missing right threw logged-in users out of the app. The interceptor now probes the session endpoint (GET /get-session via the JWT-aware fetchWithAuth) before clearing state: - session alive -> keep the user logged in (permission error, no logout) - session dead -> logout + redirect as before (real expiry) - probe undecided -> keep the user logged in (API unreachable != logged out) Recursion-safe: the probe URL matches the auth-endpoint ignore list and isHandling401 is held while it runs. Companion fix to lenneTech/nest-server (checkRights & friends now throw 403 for authenticated users), but effective against any backend that still sends 401 for permission errors. Tests: new test/auth-interceptor.test.ts (6 cases incl. red-green against the old behavior); suite 116/116, vue-tsc + build green. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- .../plugins/auth-interceptor.client.ts | 54 +++++++- test/auth-interceptor.test.ts | 124 ++++++++++++++++++ 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 test/auth-interceptor.test.ts diff --git a/package.json b/package.json index a251d9b..9772ff2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lenne.tech/nuxt-extensions", - "version": "1.8.3", + "version": "1.8.4", "description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects", "repository": { "type": "git", diff --git a/src/runtime/plugins/auth-interceptor.client.ts b/src/runtime/plugins/auth-interceptor.client.ts index 45a08b8..6a14d89 100644 --- a/src/runtime/plugins/auth-interceptor.client.ts +++ b/src/runtime/plugins/auth-interceptor.client.ts @@ -2,10 +2,17 @@ * Auth Interceptor Plugin * * This plugin intercepts all API responses and handles session expiration. - * When a 401 (Unauthorized) response is received, it automatically: + * When a 401 (Unauthorized) response is received, it verifies against the + * session endpoint that the session is genuinely dead and then automatically: * 1. Clears the user session state * 2. Redirects to the login page * + * The verification step exists because a 401 from a domain endpoint is not + * proof of an expired session: backends may mislabel permission errors + * (authenticated user, missing rights — semantically 403) as 401. Logging out + * on those would kick a logged-in user out of the app for a mere missing + * right. Only a dead session may clear state. + * * Note: This is a client-only plugin (.client.ts) since auth state * management only makes sense in the browser context. */ @@ -13,6 +20,7 @@ import type { NuxtApp } from '#app'; import { useLtAuth } from '../composables/auth/use-lt-auth'; +import { getLtApiBase } from '../lib/auth-state'; export default (nuxtApp: NuxtApp): void => { // Only run on client side @@ -81,9 +89,37 @@ export default (nuxtApp: NuxtApp): void => { return authEndpoints.some((endpoint) => url.includes(endpoint)); } + /** + * Probe the session endpoint to decide whether the session is genuinely dead. + * + * Returns `true` when the session is still alive, `false` when the backend + * confirms it is gone, and `null` when the probe could not be completed + * (e.g. network error / API unreachable — no verdict). + * + * Recursion-safe: the session URL matches {@link isAuthEndpoint}, so a 401 + * from the probe itself never re-enters {@link handleUnauthorized} (and + * `isHandling401` is set while the probe runs). + */ + async function isSessionStillAlive(): Promise { + try { + const { fetchWithAuth } = getAuth(); + const response = await fetchWithAuth(`${getLtApiBase()}/get-session`, { method: 'GET' }); + if (!response.ok) { + // The session endpoint itself rejects us → genuinely unauthenticated + return false; + } + // Better Auth returns 200 with a null body when there is no session + const data = (await response.json().catch(() => null)) as { session?: unknown; user?: unknown } | null; + return Boolean(data && (data.user || data.session)); + } catch { + return null; + } + } + /** * Handle 401 Unauthorized responses - * Clears user state and redirects to login page + * Verifies the session is genuinely dead, then clears user state and + * redirects to the login page */ async function handleUnauthorized(requestUrl?: string): Promise { // Prevent multiple simultaneous 401 handling @@ -107,6 +143,20 @@ export default (nuxtApp: NuxtApp): void => { // Only handle if user was authenticated (prevents redirect loops) const { clearUser, isAuthenticated } = getAuth(); if (isAuthenticated.value) { + // A 401 from a domain endpoint is not proof of an expired session: + // backends may mislabel permission errors as 401 instead of 403. Only + // log out when the session endpoint confirms the session is dead — an + // unverifiable probe (API unreachable) must not log the user out either. + const sessionAlive = await isSessionStillAlive(); + if (sessionAlive !== false) { + console.debug( + sessionAlive + ? `[LtAuth Interceptor] 401 from ${requestUrl ?? 'unknown URL'} but session is still valid — treating it as a permission error, not logging out` + : '[LtAuth Interceptor] 401 received but session state could not be verified — not logging out', + ); + return; + } + console.debug('[LtAuth Interceptor] Session expired, logging out...'); // Clear user state diff --git a/test/auth-interceptor.test.ts b/test/auth-interceptor.test.ts new file mode 100644 index 0000000..463e6ca --- /dev/null +++ b/test/auth-interceptor.test.ts @@ -0,0 +1,124 @@ +/** + * Auth-interceptor 401 hardening tests. + * + * A 401 from a domain endpoint is not proof of an expired session: backends may + * mislabel permission errors (authenticated user, missing rights — semantically + * 403) as 401. The interceptor therefore probes the session endpoint before + * logging out: + * - session alive → keep the user logged in (permission error, no logout) + * - session dead → clear state + redirect to login (real expiry) + * - probe undecided → keep the user logged in (API unreachable ≠ logged out) + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Controllable stand-in for useLtAuth(): the interceptor only reads +// isAuthenticated, clearUser and fetchWithAuth. +const authStub = vi.hoisted(() => ({ + clearUser: vi.fn(), + fetchWithAuth: vi.fn(), + isAuthenticated: { value: true }, +})); + +vi.mock('../src/runtime/composables/auth/use-lt-auth', () => ({ + useLtAuth: () => authStub, +})); + +// Pin the API base so the probe URL is deterministic; keep all other exports. +vi.mock('../src/runtime/lib/auth-state', async (importOriginal) => ({ + ...(await importOriginal>()), + getLtApiBase: () => 'https://api.example.com/iam', +})); + +type UnauthorizedHandler = (requestUrl?: string) => Promise; + +/** + * Run the plugin against a stubbed NuxtApp and hand back the + * `ltHandleUnauthorized` handler it provides — calling it directly keeps the + * tests deterministic (no racing through the fetch wrappers). + */ +async function setupInterceptor(): Promise { + const provide = vi.fn(); + const nuxtApp = { + $config: { public: { ltExtensions: { auth: {} } } }, + $router: { currentRoute: { value: { fullPath: '/app/board', path: '/app/board' } } }, + provide, + }; + const plugin = (await import('../src/runtime/plugins/auth-interceptor.client')).default; + plugin(nuxtApp as never); + const handler = provide.mock.calls.find(([name]) => name === 'ltHandleUnauthorized')?.[1]; + expect(handler).toBeTypeOf('function'); + return handler as UnauthorizedHandler; +} + +function probeResolvesWith(body: unknown, ok = true): void { + authStub.fetchWithAuth.mockResolvedValue({ + ok, + json: async () => body, + }); +} + +beforeEach(() => { + authStub.clearUser.mockReset(); + authStub.fetchWithAuth.mockReset(); + authStub.isAuthenticated.value = true; +}); + +describe('auth interceptor — 401 handling probes the session before logging out', () => { + it('keeps the user logged in when the session is still alive (mislabeled permission error)', async () => { + const handleUnauthorized = await setupInterceptor(); + probeResolvesWith({ session: { id: 's1' }, user: { id: 'u1' } }); + + await handleUnauthorized('https://api.example.com/measures'); + + expect(authStub.fetchWithAuth).toHaveBeenCalledWith('https://api.example.com/iam/get-session', { method: 'GET' }); + expect(authStub.clearUser).not.toHaveBeenCalled(); + }); + + it('logs out when the session endpoint returns an empty session (real expiry)', async () => { + const handleUnauthorized = await setupInterceptor(); + // Better Auth answers 200 with a null body when there is no session + probeResolvesWith(null); + + await handleUnauthorized('https://api.example.com/measures'); + + expect(authStub.clearUser).toHaveBeenCalledTimes(1); + }); + + it('logs out when the session endpoint itself rejects the request', async () => { + const handleUnauthorized = await setupInterceptor(); + probeResolvesWith(null, false); + + await handleUnauthorized('https://api.example.com/measures'); + + expect(authStub.clearUser).toHaveBeenCalledTimes(1); + }); + + it('keeps the user logged in when the probe cannot be completed (API unreachable)', async () => { + const handleUnauthorized = await setupInterceptor(); + authStub.fetchWithAuth.mockRejectedValue(new Error('network down')); + + await handleUnauthorized('https://api.example.com/measures'); + + expect(authStub.clearUser).not.toHaveBeenCalled(); + }); + + it('ignores 401s from auth endpoints without probing (expected failures, e.g. wrong password)', async () => { + const handleUnauthorized = await setupInterceptor(); + + await handleUnauthorized('https://api.example.com/iam/sign-in/email'); + + expect(authStub.fetchWithAuth).not.toHaveBeenCalled(); + expect(authStub.clearUser).not.toHaveBeenCalled(); + }); + + it('does nothing when no user is authenticated (no probe, no logout)', async () => { + const handleUnauthorized = await setupInterceptor(); + authStub.isAuthenticated.value = false; + + await handleUnauthorized('https://api.example.com/measures'); + + expect(authStub.fetchWithAuth).not.toHaveBeenCalled(); + expect(authStub.clearUser).not.toHaveBeenCalled(); + }); +});