diff --git a/CHANGELOG.md b/CHANGELOG.md index eb147fc..7328966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.4] - 2026-04-14 + +### Fixed +- iOS: persistent "forbidden" (403) errors caused by iOS Safari/PWA not reliably exposing CSRF cookie via `document.cookie`. CSRF token is now returned in the response body of `/auth/login` and `/auth/me` and stored in-memory, bypassing cookie read issues entirely. Cookie is still set as fallback. +- CSRF retry: `/auth/me` refresh now reads the token from the response body instead of relying on the cookie being available. Also handles expired sessions (401) during retry instead of silently failing. + ## [0.19.3] - 2026-04-14 ### Added diff --git a/package.json b/package.json index 24fc5c0..f865bdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.19.3", + "version": "0.19.4", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/api.js b/public/api.js index e6b9f50..8ec11e9 100644 --- a/public/api.js +++ b/public/api.js @@ -6,8 +6,12 @@ const API_BASE = '/api/v1'; -/** Liest den CSRF-Token aus dem Cookie (gesetzt vom Server nach Login). */ +/** In-Memory CSRF-Token (zuverlaessiger als document.cookie auf iOS Safari/PWA). */ +let _csrfToken = ''; + +/** Liest den CSRF-Token: bevorzugt In-Memory, Fallback auf Cookie. */ function getCsrfToken() { + if (_csrfToken) return _csrfToken; return document.cookie.split(';') .map((c) => c.trim()) .find((c) => c.startsWith('csrf-token=')) @@ -46,14 +50,23 @@ async function apiFetch(path, options = {}, _retried = false) { } // CSRF-Token-Desync (haeufig nach iOS-PWA-Resume): einmal GET /auth/me - // ausfuehren um den CSRF-Cookie zu erneuern, dann den Request wiederholen. + // ausfuehren um den CSRF-Token zu erneuern, dann den Request wiederholen. if (response.status === 403 && stateChanging && !_retried) { - await fetch(`${API_BASE}/auth/me`, { credentials: 'same-origin', cache: 'no-store' }); + const meRes = await fetch(`${API_BASE}/auth/me`, { credentials: 'same-origin', cache: 'no-store' }); + if (meRes.status === 401) { + window.dispatchEvent(new CustomEvent('auth:expired')); + throw new Error('Sitzung abgelaufen.'); + } + const meData = await meRes.json().catch(() => null); + if (meData?.csrfToken) _csrfToken = meData.csrfToken; return apiFetch(path, options, true); } const data = await response.json().catch(() => null); + // CSRF-Token aus Response-Body extrahieren (zuverlaessiger als Cookie auf iOS) + if (data?.csrfToken) _csrfToken = data.csrfToken; + if (!response.ok) { const message = data?.error || `HTTP ${response.status}`; throw new ApiError(message, response.status, data); diff --git a/server/auth.js b/server/auth.js index 178e1d6..5ecfca9 100644 --- a/server/auth.js +++ b/server/auth.js @@ -209,6 +209,7 @@ router.post('/login', loginLimiter, async (req, res) => { avatar_color: user.avatar_color, role: user.role, }, + csrfToken: req.session.csrfToken, }); }); } catch (err) { @@ -260,7 +261,7 @@ router.get('/me', requireAuth, (req, res) => { maxAge: 1000 * 60 * 60 * 24 * 7, }); - res.json({ user }); + res.json({ user, csrfToken: req.session.csrfToken }); } catch (err) { log.error('/me Fehler:', err); res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });