From ee609376a352fe0037eb85c216053807633a6db9 Mon Sep 17 00:00:00 2001 From: Ulas Date: Wed, 15 Apr 2026 18:15:40 +0200 Subject: [PATCH] fix: resolve recurring iOS PWA forbidden errors via CSRF response header iOS Safari in PWA standalone mode unreliably handles cookies, causing CSRF token desync between client and server after app resume. Previous fixes (response body token in /auth/me and /auth/login) still left a window where the token could go stale. Now the server sends X-CSRF-Token response header on every API response (via csrfMiddleware), including 403 error responses. The client reads this header from every response, enabling instant self-healing: a 403 extracts the correct token from the error response itself and retries without needing an extra /auth/me round-trip. SW cache bumped to v33 to ensure existing iOS PWA installs pick up the new client code. --- CHANGELOG.md | 3 +++ public/api.js | 14 +++++++++++++- public/sw.js | 2 +- server/middleware/csrf.js | 4 ++++ 4 files changed, 21 insertions(+), 2 deletions(-) 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();