mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 07:11:46 +00:00
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:
@@ -1,5 +1,6 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
import en from '../i18n/translations/en'
|
import en from '../i18n/translations/en'
|
||||||
import br from '../i18n/translations/br'
|
import br from '../i18n/translations/br'
|
||||||
import de from '../i18n/translations/de'
|
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))
|
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(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
(error) => {
|
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') {
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
const { pathname } = window.location
|
const { pathname } = window.location
|
||||||
if (!isAuthPublicPath(pathname)) {
|
if (!isAuthPublicPath(pathname)) {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
|
||||||
|
startConnectivityProbe()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -117,15 +117,29 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
|
||||||
if (config) {
|
authApi.getAppConfig?.()
|
||||||
setAppConfig(config)
|
.then((config: AppConfig) => {
|
||||||
if (!config.has_users) setMode('register')
|
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
|
||||||
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
return { config, fromCache: false }
|
||||||
window.location.href = '/api/auth/oidc/login'
|
})
|
||||||
|
.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])
|
}, [navigate, t, noRedirect])
|
||||||
|
|
||||||
// Language detection chain (runs once on mount, only if user has no saved preference):
|
// Language detection chain (runs once on mount, only if user has no saved preference):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
// API calls — prefer network, fall back to cache
|
// API calls — prefer network, fall back to cache
|
||||||
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||||
urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i,
|
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||||
handler: 'NetworkFirst',
|
handler: 'NetworkFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'api-data',
|
cacheName: 'api-data',
|
||||||
|
|||||||
+4
-1
@@ -276,7 +276,10 @@ export function createApp(): express.Application {
|
|||||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
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/config', publicConfigRoutes);
|
||||||
app.use('/api', assignmentsRoutes);
|
app.use('/api', assignmentsRoutes);
|
||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
|
|||||||
Reference in New Issue
Block a user