diff --git a/CHANGELOG.md b/CHANGELOG.md index ea578df..57db728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- iOS PWA: recurring "forbidden" (403) errors caused by CSRF token desync after app resume. The server now sends the correct CSRF token as `X-CSRF-Token` response header on every API response (not just `/auth/me` and `/auth/login`). The client reads the header from every response - including 403 errors - enabling instant self-healing without an extra `/auth/me` round-trip. SW cache bumped to v33 to ensure iOS PWA users pick up the fix. + ## [0.20.0] - 2026-04-15 ### Added diff --git a/public/api.js b/public/api.js index 8ec11e9..3469a32 100644 --- a/public/api.js +++ b/public/api.js @@ -52,6 +52,14 @@ async function apiFetch(path, options = {}, _retried = false) { // CSRF-Token-Desync (haeufig nach iOS-PWA-Resume): einmal GET /auth/me // ausfuehren um den CSRF-Token zu erneuern, dann den Request wiederholen. if (response.status === 403 && stateChanging && !_retried) { + // Token aus der 403-Antwort selbst extrahieren (Server liefert den + // korrekten Token im Header mit, auch bei Fehlschlag) + const errorCsrf = response.headers.get('X-CSRF-Token'); + if (errorCsrf) { + _csrfToken = errorCsrf; + return apiFetch(path, options, true); + } + // Fallback: /auth/me aufrufen um Token zu erneuern const meRes = await fetch(`${API_BASE}/auth/me`, { credentials: 'same-origin', cache: 'no-store' }); if (meRes.status === 401) { window.dispatchEvent(new CustomEvent('auth:expired')); @@ -62,9 +70,13 @@ async function apiFetch(path, options = {}, _retried = false) { return apiFetch(path, options, true); } + // CSRF-Token aus Response-Header extrahieren (wird bei jeder API-Antwort mitgeliefert) + const csrfHeader = response.headers.get('X-CSRF-Token'); + if (csrfHeader) _csrfToken = csrfHeader; + const data = await response.json().catch(() => null); - // CSRF-Token aus Response-Body extrahieren (zuverlaessiger als Cookie auf iOS) + // Fallback: CSRF-Token aus Response-Body (fuer /auth/me und /auth/login) if (data?.csrfToken) _csrfToken = data.csrfToken; if (!response.ok) { diff --git a/public/sw.js b/public/sw.js index 57da731..ab80dd0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -12,7 +12,7 @@ * API: Immer Netzwerk (kein Caching von Nutzerdaten) */ -const SHELL_CACHE = 'oikos-shell-v32'; +const SHELL_CACHE = 'oikos-shell-v33'; const PAGES_CACHE = 'oikos-pages-v28'; const ASSETS_CACHE = 'oikos-assets-v27'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE]; diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js index f00596c..472ecd8 100644 --- a/server/middleware/csrf.js +++ b/server/middleware/csrf.js @@ -41,6 +41,10 @@ function csrfMiddleware(req, res, next) { maxAge: 1000 * 60 * 60 * 24 * 7, // 7 Tage (gleich wie Session) }); + // Token auch als Response-Header senden (zuverlaessiger als Cookie auf iOS-PWA, + // und bei jedem Request aktuell - nicht nur bei /auth/me und /auth/login) + res.setHeader('X-CSRF-Token', req.session.csrfToken); + // Safe Methods benötigen keine Validierung if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { return next();