fix(pwa): gate events query on auth to stop OIDC state-cookie churn

Internal Server Error after returning from Authelia: processOAuthCallback threw
OAUTH_INVALID_RESPONSE ("unexpected state parameter") because the OIDC state
cookie no longer matched the state returned to /callback.

Root cause: eventsQuery (fetchEvents, redirect:'follow', retry:2) ran
concurrently with fetchMe on load. While unauthenticated, every /api/* request
hits the OIDC guard, which 302-redirects to Authelia AND sets a fresh state
cookie. fetchEvents could not follow the cross-origin redirect, so React Query
retried it up to 3x over ~3s — each retry overwriting the state cookie mid-login,
racing the single /api/login navigation that owns the real flow.

Fix: enabled: meQuery.isSuccess. Only fetchMe (redirect:'manual', retry:false)
touches a guarded endpoint while unauthenticated, so the top-level /api/login
navigation owns the state cookie uncontested. Realizes the documented design
intent that only fetchMe drives the login redirect.
This commit is contained in:
Lucas Berger
2026-06-07 15:57:17 -04:00
parent 69bc57221b
commit bb61d21c83
+13 -1
View File
@@ -93,10 +93,22 @@ export function CalendarShell() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}) })
// Fetch windowed occurrences — key includes start/end so navigation refetches // Fetch windowed occurrences — key includes start/end so navigation refetches.
//
// enabled: meQuery.isSuccess is load-bearing for the OIDC login flow, not just
// an optimization. When unauthenticated, every /api/* request hits the OIDC
// guard, which 302-redirects to Authelia AND sets a fresh state cookie. If this
// query ran concurrently with fetchMe (and retried), each /api/events redirect
// would overwrite the OIDC state cookie mid-login — so the state returned to
// /callback no longer matched the cookie, producing OAUTH_INVALID_RESPONSE
// ("unexpected state parameter") and an Internal Server Error after Authelia.
// Gating on a successful /api/me means only fetchMe (redirect:'manual',
// retry:false) touches a guarded endpoint while unauthenticated, so the single
// top-level /api/login navigation owns the state cookie uncontested.
const eventsQuery = useQuery({ const eventsQuery = useQuery({
queryKey: ['events', start, end], queryKey: ['events', start, end],
queryFn: () => fetchEvents(start, end), queryFn: () => fetchEvents(start, end),
enabled: meQuery.isSuccess,
retry: 2, retry: 2,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}) })