fix: recover from expired Authentik session without clearing site data
Store Tracker is a PWA behind the shared Authentik forward-auth provider. When the session expires (Android Chrome evicts the PWA cookie), the same-origin /api XHR is 302'd cross-origin and CORS-blocked, the app showed only a generic "backend connection" error, and the service worker served the cached shell for any navigation — so re-auth was impossible without clearing all site data. Fix: - App swaps to a "Session expired" screen on an auth error (axios error with no response, or 401/403) while online. Its "Sign in" button unregisters the service worker + deletes all caches before navigating (programmatic "clear site data"), so the re-auth navigation reaches forward-auth -> Authentik. - vite.config: navigateFallbackDenylist [/[?&]reauth=/] keeps the SW from serving the cached shell for the re-auth navigation. Same bug class fixed in books (#19), speedracer, and vpn-stats. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rXJ49eToZ6YFZzDYH8GXd
This commit is contained in:
parent
9f901ec50d
commit
df951ff2ab
2 changed files with 71 additions and 1 deletions
68
src/App.tsx
68
src/App.tsx
|
|
@ -1,3 +1,4 @@
|
|||
import axios from 'axios'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, LayoutGrid, Table2, ShoppingBag } from 'lucide-react'
|
||||
|
|
@ -13,6 +14,62 @@ import { DeleteConfirm } from './components/DeleteConfirm'
|
|||
import { Pagination } from './components/Pagination'
|
||||
import { ToastContainer, makeToast, type ToastMessage } from './components/Toast'
|
||||
|
||||
// The Authentik forward-auth session expires (Android Chrome evicts the PWA
|
||||
// cookie). When it does, the same-origin /api XHR is 302'd cross-origin to
|
||||
// Authentik and CORS-blocked, so axios sees no response (or a 401/403). A 5xx
|
||||
// is a backend problem, not a login problem, and must not trigger the screen.
|
||||
function isSessionExpired(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) return false
|
||||
if (!error.response) return true
|
||||
return error.response.status === 401 || error.response.status === 403
|
||||
}
|
||||
|
||||
function SessionExpiredScreen() {
|
||||
async function handleSignIn() {
|
||||
// The PWA service worker serves the precached app shell for every
|
||||
// navigation, so a plain reload never reaches the network — Authentik's
|
||||
// forward-auth redirect never fires and re-auth is impossible without
|
||||
// clearing site data. Do that programmatically: drop the service worker +
|
||||
// caches, then navigate so the request reaches forward-auth → login.
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map(k => caches.delete(k)))
|
||||
}
|
||||
} catch {
|
||||
// best-effort — navigate regardless of cleanup failures
|
||||
}
|
||||
window.location.href = `${window.location.origin}/?reauth=${Date.now()}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-900 grid place-items-center px-6">
|
||||
<div className="glass rounded-2xl px-6 py-8 max-w-xs w-full text-center flex flex-col items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||
<ShoppingBag size={20} className="text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-200">Session expired</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Your login timed out. Sign in again to continue.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignIn}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-white text-sm font-medium px-4 py-2.5 rounded-xl transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: StoreFilters = {
|
||||
search: '',
|
||||
category: '',
|
||||
|
|
@ -52,7 +109,7 @@ export default function App() {
|
|||
}))
|
||||
}
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['stores', filters],
|
||||
queryFn: () => fetchStores({
|
||||
page: filters.page,
|
||||
|
|
@ -126,6 +183,15 @@ export default function App() {
|
|||
const routes = filterOptions?.routes ?? []
|
||||
const availableTags = filterOptions?.tags ?? []
|
||||
|
||||
// An expired Authentik session can't be recovered from inside the SPA (the
|
||||
// service worker would serve the cached shell), so swap the app for a re-auth
|
||||
// screen that forces a fresh forward-auth navigation. Gate on `online` so an
|
||||
// offline blip doesn't demand re-login.
|
||||
const online = typeof navigator === 'undefined' ? true : navigator.onLine
|
||||
if (isError && online && isSessionExpired(error)) {
|
||||
return <SessionExpiredScreen />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-900">
|
||||
{/* Header */}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ export default defineConfig({
|
|||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
|
||||
// Re-auth navigations (?reauth=) must reach the network so Authentik's
|
||||
// forward-auth redirect can fire — never serve the cached app shell for
|
||||
// them (the default NavigationRoute otherwise blocks re-login).
|
||||
navigateFallbackDenylist: [/[?&]reauth=/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
|
|
|
|||
Loading…
Reference in a new issue