From 0fa154c7d6571a8f994a8c8ce59449452b3b96f7 Mon Sep 17 00:00:00 2001 From: Wuesteon Date: Fri, 12 Dec 2025 20:47:43 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(shared-auth):=20add=20automa?= =?UTF-8?q?tic=20token=20refresh=20on=20401=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add backendUrl parameter to initializeWebAuth() for interceptor config - Expand isTokenExpiredResponse() to match more error patterns: - "invalid token", "token validation failed", "claim" (jose errors) - ERR_JWT_EXPIRED error code - Update all web apps to pass backendUrl for automatic refresh: - picture (3006), chat (3002), zitare (3007), contacts (3015) - calendar (3014), clock (3017), todo (3018) - Fix API client default port in picture web app This prevents users from being randomly signed out when JWT expires. The interceptor now catches 401 responses and automatically refreshes the token before retrying the request. --- .../apps/web/src/lib/stores/auth.svelte.ts | 15 ++++++++++++++- .../apps/web/src/lib/stores/auth.svelte.ts | 15 ++++++++++++++- .../apps/web/src/lib/stores/auth.svelte.ts | 15 ++++++++++++++- .../apps/web/src/lib/stores/auth.svelte.ts | 7 +++++-- apps/picture/apps/web/src/lib/api/client.ts | 8 +++++--- .../apps/web/src/lib/stores/auth.svelte.ts | 6 +++++- .../apps/web/src/lib/stores/auth.svelte.ts | 15 ++++++++++++++- .../apps/web/src/lib/stores/auth.svelte.ts | 7 +++++-- packages/shared-auth/src/index.ts | 19 +++++++++++++++++-- .../src/interceptors/fetchInterceptor.ts | 17 +++++++++++++---- 10 files changed, 106 insertions(+), 18 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts index 9ede84176..ced1c1ad4 100644 --- a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts @@ -20,6 +20,16 @@ function getAuthUrl(): string { return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; } +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3014'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014'; +} + // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; let _tokenManager: ReturnType['tokenManager'] | null = null; @@ -27,7 +37,10 @@ let _tokenManager: ReturnType['tokenManager'] | null = function getAuthService() { if (!browser) return null; if (!_authService) { - const auth = initializeWebAuth({ baseUrl: getAuthUrl() }); + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses + }); _authService = auth.authService; _tokenManager = auth.tokenManager; } diff --git a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts index 92064596d..ed412923c 100644 --- a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts @@ -20,6 +20,16 @@ function getAuthUrl(): string { return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; } +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3002'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3002'; +} + // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; let _tokenManager: ReturnType['tokenManager'] | null = null; @@ -27,7 +37,10 @@ let _tokenManager: ReturnType['tokenManager'] | null = function getAuthService() { if (!browser) return null; if (!_authService) { - const auth = initializeWebAuth({ baseUrl: getAuthUrl() }); + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses + }); _authService = auth.authService; _tokenManager = auth.tokenManager; } diff --git a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts index 2613ee0b5..931f6327e 100644 --- a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts @@ -19,6 +19,16 @@ function getAuthUrl(): string { return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; } +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3017'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3017'; +} + // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; let _tokenManager: ReturnType['tokenManager'] | null = null; @@ -26,7 +36,10 @@ let _tokenManager: ReturnType['tokenManager'] | null = function getAuthService() { if (!browser) return null; if (!_authService) { - const auth = initializeWebAuth({ baseUrl: getAuthUrl() }); + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses + }); _authService = auth.authService; _tokenManager = auth.tokenManager; } diff --git a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts index 3122bf4f1..3f7320628 100644 --- a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts @@ -8,8 +8,8 @@ import { initializeWebAuth } from '@manacore/shared-auth'; import type { UserData } from '@manacore/shared-auth'; // Initialize Mana Core Auth only on the client side -// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available const MANA_AUTH_URL = 'http://localhost:3001'; +const BACKEND_URL = 'http://localhost:3015'; // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; @@ -18,7 +18,10 @@ let _tokenManager: ReturnType['tokenManager'] | null = function getAuthService() { if (!browser) return null; if (!_authService) { - const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL }); + const auth = initializeWebAuth({ + baseUrl: MANA_AUTH_URL, + backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses + }); _authService = auth.authService; _tokenManager = auth.tokenManager; } diff --git a/apps/picture/apps/web/src/lib/api/client.ts b/apps/picture/apps/web/src/lib/api/client.ts index 5e15d3754..3cac5de48 100644 --- a/apps/picture/apps/web/src/lib/api/client.ts +++ b/apps/picture/apps/web/src/lib/api/client.ts @@ -2,14 +2,16 @@ * API Client for Picture Backend * Replaces direct Supabase calls with backend API calls. * - * Token handling: Uses authStore.getValidToken() which automatically - * refreshes expired tokens before making requests. + * Token handling: + * - Uses authStore.getValidToken() which automatically refreshes expired tokens + * - The fetch interceptor (setupFetchInterceptor) handles 401 responses by refreshing and retrying + * - If refresh fails, the request fails and user should be redirected to login */ import { env } from '$env/dynamic/public'; import { authStore } from '$lib/stores/auth.svelte'; -const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3003'; +const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3006'; type FetchOptions = { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; diff --git a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts index b9ad5b753..b63abb804 100644 --- a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts @@ -7,6 +7,7 @@ import { browser } from '$app/environment'; import { env } from '$env/dynamic/public'; const MANA_AUTH_URL = env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; +const BACKEND_URL = env.PUBLIC_BACKEND_URL || 'http://localhost:3006'; export interface UserData { id: string; @@ -28,7 +29,10 @@ async function getAuthService() { if (!_authService) { try { const { initializeWebAuth } = await import('@manacore/shared-auth'); - const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL }); + const auth = initializeWebAuth({ + baseUrl: MANA_AUTH_URL, + backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses + }); _authService = auth.authService; _tokenManager = auth.tokenManager; } catch (error) { diff --git a/apps/todo/apps/web/src/lib/stores/auth.svelte.ts b/apps/todo/apps/web/src/lib/stores/auth.svelte.ts index 7c3210601..817c251fd 100644 --- a/apps/todo/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/auth.svelte.ts @@ -20,6 +20,16 @@ function getAuthUrl(): string { return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; } +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3018'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3018'; +} + // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; let _tokenManager: ReturnType['tokenManager'] | null = null; @@ -27,7 +37,10 @@ let _tokenManager: ReturnType['tokenManager'] | null = function getAuthService() { if (!browser) return null; if (!_authService) { - const auth = initializeWebAuth({ baseUrl: getAuthUrl() }); + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses + }); _authService = auth.authService; _tokenManager = auth.tokenManager; } diff --git a/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts b/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts index 3122bf4f1..688e7e17f 100644 --- a/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts @@ -8,8 +8,8 @@ import { initializeWebAuth } from '@manacore/shared-auth'; import type { UserData } from '@manacore/shared-auth'; // Initialize Mana Core Auth only on the client side -// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available const MANA_AUTH_URL = 'http://localhost:3001'; +const BACKEND_URL = 'http://localhost:3007'; // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; @@ -18,7 +18,10 @@ let _tokenManager: ReturnType['tokenManager'] | null = function getAuthService() { if (!browser) return null; if (!_authService) { - const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL }); + const auth = initializeWebAuth({ + baseUrl: MANA_AUTH_URL, + backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses + }); _authService = auth.authService; _tokenManager = auth.tokenManager; } diff --git a/packages/shared-auth/src/index.ts b/packages/shared-auth/src/index.ts index ce6391bb3..77db0d5f9 100644 --- a/packages/shared-auth/src/index.ts +++ b/packages/shared-auth/src/index.ts @@ -81,13 +81,21 @@ export type { ContactsClientConfig, ContactSearchOptions } from './clients/conta * ```typescript * import { initializeWebAuth } from '@manacore/shared-auth'; * + * // Basic setup (interceptor only for auth URL) * const { authService, tokenManager } = initializeWebAuth({ - * baseUrl: 'https://api.example.com', + * baseUrl: 'https://auth.example.com', + * }); + * + * // With backend URL (interceptor for both auth and backend - recommended) + * const { authService, tokenManager } = initializeWebAuth({ + * baseUrl: 'https://auth.example.com', + * backendUrl: 'https://api.example.com', * }); * ``` */ export function initializeWebAuth(config: { baseUrl: string; + backendUrl?: string; storageKeys?: Partial; }) { // Set up adapters @@ -99,8 +107,15 @@ export function initializeWebAuth(config: { const authService = _createAuthService(config); const tokenManager = _createTokenManager(authService); - // Set up interceptor + // Set up interceptor for auth URL _setupFetchInterceptor(authService, tokenManager); + // Set up interceptor for backend URL if provided (for automatic token refresh on 401) + if (config.backendUrl) { + _setupFetchInterceptor(authService, tokenManager, { + backendUrl: config.backendUrl, + }); + } + return { authService, tokenManager }; } diff --git a/packages/shared-auth/src/interceptors/fetchInterceptor.ts b/packages/shared-auth/src/interceptors/fetchInterceptor.ts index 259679352..78736519a 100644 --- a/packages/shared-auth/src/interceptors/fetchInterceptor.ts +++ b/packages/shared-auth/src/interceptors/fetchInterceptor.ts @@ -201,8 +201,9 @@ async function makeRequestWithToken( } /** - * Check if response indicates token expiration - * Only return true for explicit token expiration, not generic unauthorized errors + * Check if response indicates a token issue that warrants a refresh attempt + * Any 401 response should trigger a refresh attempt - if the refresh fails, + * then we know the session is truly invalid */ function isTokenExpiredResponse(responseData: Record): boolean { const error = responseData.error as Record | undefined; @@ -211,13 +212,21 @@ function isTokenExpiredResponse(responseData: Record): boolean ).toLowerCase(); const errorCode = String(responseData.code || error?.code || ''); - // Only trigger refresh for explicit token expiration messages + // Trigger refresh for any token-related auth error + // This includes: + // - Explicit expiration: "jwt expired", "token expired" + // - Generic validation failures: "invalid token", "token validation failed" + // - Backend passthrough errors: "exp claim", "claim timestamp" return ( errorMessage.includes('jwt expired') || errorMessage.includes('token expired') || errorMessage.includes('token has expired') || + errorMessage.includes('invalid token') || + errorMessage.includes('token validation failed') || + errorMessage.includes('claim') || // Catches jose errors like "exp claim timestamp check failed" errorCode === 'PGRST301' || - errorCode === 'TOKEN_EXPIRED' + errorCode === 'TOKEN_EXPIRED' || + errorCode === 'ERR_JWT_EXPIRED' ); }