From 3ee4da977508030ed4a660a51d27c9fa4495ae50 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 6 May 2026 12:16:08 +0200 Subject: [PATCH] fix(pwa): detect upstream proxy auth challenges and recover gracefully Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on /api/* calls surface as CORS errors (error.response === undefined) that the existing 401 interceptor never catches, leaving the PWA stuck with network-error toasts instead of re-authenticating. New connectivity module probes /api/health every 30s using fetch with cache:no-store and inspects Content-Type to reliably detect whether the server is reachable vs intercepted by an upstream proxy. axios interceptor changes: - On !error.response + navigator.onLine: run probeNow(); if the health probe also fails (proxy is intercepting all requests), trigger a guarded window.location.reload() so the edge proxy can intercept the top-level navigation and run its auth flow (covers CF Access and Pangolin 302 mode) - On error.response status 401 with text/html body: same reload path, covering Pangolin header-auth extended compatibility mode which returns 401+HTML instead of a 302 redirect. TREK own 401s are always JSON so there is no collision with the existing AUTH_REQUIRED branch. - sessionStorage flag prevents reload loops; cleared on any successful response so the guard resets after re-auth. /api/health excluded from SW NetworkFirst cache (vite.config.js regex) and Cache-Control: no-store added server-side so probes always hit the network and cannot be served stale from the 24h api-data cache. LoginPage caches last-known appConfig in localStorage so the SSO button renders in OIDC+UN/PW dual mode even when the config fetch is intercepted by the proxy. Auto-redirect to IdP skipped when config comes from cache to avoid redirect loops while the proxy is challenging. Fixes discussion #836. --- client/src/api/client.ts | 45 ++++++++++++++++++++++++++++--- client/src/main.tsx | 3 +++ client/src/pages/LoginPage.tsx | 30 +++++++++++++++------ client/src/sync/connectivity.ts | 47 +++++++++++++++++++++++++++++++++ client/vite.config.js | 2 +- server/src/app.ts | 5 +++- 6 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 client/src/sync/connectivity.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index e7b46df9..5f818e0c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance } from 'axios' import { getSocketId } from './websocket' +import { isReachable, probeNow } from '../sync/connectivity' import en from '../i18n/translations/en' import br from '../i18n/translations/br' import de from '../i18n/translations/de' @@ -69,10 +70,48 @@ export function isAuthPublicPath(pathname: string): boolean { return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p)) } -// Response interceptor - handle 401, 403 MFA, 429 rate limit +// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges apiClient.interceptors.response.use( - (response) => response, - (error) => { + (response) => { + sessionStorage.removeItem('proxy_reauth_attempted') + return response + }, + async (error) => { + // CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces + // as a CORS error with no response object. Probe the health endpoint to + // distinguish a proxy auth challenge from a genuine outage. If the server + // is reachable, a top-level reload lets the edge proxy run its auth flow. + if (!error.response && navigator.onLine) { + await probeNow() + // Both the original request and the health probe failed while the device + // has a network interface. This matches the proxy-auth-challenge pattern + // (CF Access / Pangolin intercept all requests and CORS-block XHR). + // A top-level reload lets the edge proxy intercept the navigation and + // run its auth flow. Guard with sessionStorage to prevent reload loops + // (server genuinely down would also land here, but only reloads once). + if (!isReachable()) { + const { pathname } = window.location + if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) { + sessionStorage.setItem('proxy_reauth_attempted', '1') + window.location.reload() + return Promise.reject(error) + } + } + } + // Pangolin header-auth extended compatibility mode: returns 401 with an + // HTML body (a JS redirect page) instead of a 302. TREK's own 401s are + // always application/json, so checking for text/html is unambiguous. + if (error.response?.status === 401) { + const ct = (error.response.headers?.['content-type'] as string | undefined) ?? '' + if (ct.includes('text/html')) { + const { pathname } = window.location + if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) { + sessionStorage.setItem('proxy_reauth_attempted', '1') + window.location.reload() + return Promise.reject(error) + } + } + } if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { const { pathname } = window.location if (!isAuthPublicPath(pathname)) { diff --git a/client/src/main.tsx b/client/src/main.tsx index fa94face..db9116c6 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App' import './index.css' +import { startConnectivityProbe } from './sync/connectivity' + +startConnectivityProbe() ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 6fa2c192..3f3728bd 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -117,15 +117,29 @@ export default function LoginPage(): React.ReactElement { return } - authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => { - if (config) { - setAppConfig(config) - if (!config.has_users) setMode('register') - if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { - window.location.href = '/api/auth/oidc/login' + const CONFIG_CACHE_KEY = 'trek_app_config_cache' + authApi.getAppConfig?.() + .then((config: AppConfig) => { + try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ } + return { config, fromCache: false } + }) + .catch(() => { + try { + const raw = localStorage.getItem(CONFIG_CACHE_KEY) + return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false } + } catch { return { config: null as AppConfig | null, fromCache: false } } + }) + .then(({ config, fromCache }) => { + if (config) { + setAppConfig(config) + if (!config.has_users) setMode('register') + // Skip auto-redirect when config is from cache — network is unreliable + // and auto-redirecting to the IdP could loop if the proxy changed. + if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { + window.location.href = '/api/auth/oidc/login' + } } - } - }) + }) }, [navigate, t, noRedirect]) // Language detection chain (runs once on mount, only if user has no saved preference): diff --git a/client/src/sync/connectivity.ts b/client/src/sync/connectivity.ts new file mode 100644 index 00000000..dbfc0ebd --- /dev/null +++ b/client/src/sync/connectivity.ts @@ -0,0 +1,47 @@ +const PROBE_INTERVAL_MS = 30_000 +const PROBE_TIMEOUT_MS = 1_500 + +let reachable = true +const listeners = new Set<(v: boolean) => void>() + +function setReachable(v: boolean): void { + if (reachable === v) return + reachable = v + listeners.forEach(fn => fn(v)) +} + +async function probe(): Promise { + if (!navigator.onLine) { setReachable(false); return } + try { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS) + const res = await fetch('/api/health', { + method: 'GET', + credentials: 'include', + cache: 'no-store', + signal: ctrl.signal, + }) + clearTimeout(t) + // /api/health returns JSON. CF Access / Pangolin will either return HTML + // (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws + // below. Both proxy-auth scenarios resolve to reachable = false. + const ct = res.headers.get('content-type') || '' + setReachable(res.ok && ct.includes('application/json')) + } catch { + setReachable(false) + } +} + +export function startConnectivityProbe(): void { + probe() + setInterval(probe, PROBE_INTERVAL_MS) + window.addEventListener('online', probe) + window.addEventListener('offline', () => setReachable(false)) +} + +export function isReachable(): boolean { return reachable } +export function probeNow(): Promise { return probe() } +export function onChange(fn: (v: boolean) => void): () => void { + listeners.add(fn) + return () => listeners.delete(fn) +} diff --git a/client/vite.config.js b/client/vite.config.js index 322e30b9..0d85df86 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -46,7 +46,7 @@ export default defineConfig({ { // API calls — prefer network, fall back to cache // Exclude sensitive endpoints (auth, admin, backup, settings) - urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i, + urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i, handler: 'NetworkFirst', options: { cacheName: 'api-data', diff --git a/server/src/app.ts b/server/src/app.ts index 88bdd461..be4c9424 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -276,7 +276,10 @@ export function createApp(): express.Application { app.use('/api/trips/:tripId/collab', collabRoutes); app.use('/api/trips/:tripId/reservations', reservationsRoutes); app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); - app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' })); + app.get('/api/health', (_req: Request, res: Response) => { + res.setHeader('Cache-Control', 'no-store, must-revalidate') + res.json({ status: 'ok' }) + }); app.use('/api/config', publicConfigRoutes); app.use('/api', assignmentsRoutes); app.use('/api/tags', tagsRoutes);