Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
54 changes: 52 additions & 2 deletions src/runtime/plugins/auth-interceptor.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@
* 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.
*/

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
Expand Down Expand Up @@ -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).
Comment thread
NicoKaempf marked this conversation as resolved.
*/
async function isSessionStillAlive(): Promise<boolean | null> {
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<void> {
// Prevent multiple simultaneous 401 handling
Expand All @@ -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
Expand Down
124 changes: 124 additions & 0 deletions test/auth-interceptor.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
getLtApiBase: () => 'https://api.example.com/iam',
}));

type UnauthorizedHandler = (requestUrl?: string) => Promise<void>;

/**
* 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<UnauthorizedHandler> {
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);
Comment thread
NicoKaempf marked this conversation as resolved.

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();
});
});
Loading