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.
This commit is contained in:
jubnl
2026-05-06 12:16:08 +02:00
parent 48c0f97ab9
commit 3ee4da9775
6 changed files with 119 additions and 13 deletions
+42 -3
View File
@@ -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)) {
+3
View File
@@ -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(
<React.StrictMode>
+22 -8
View File
@@ -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):
+47
View File
@@ -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<void> {
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<void> { return probe() }
export function onChange(fn: (v: boolean) => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
+1 -1
View File
@@ -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',