44d1b88e3d
iOS Safari (especially PWA/standalone mode) unreliably exposes cookies via document.cookie, causing CSRF token mismatch on state-changing requests. The CSRF token is now included in /auth/login and /auth/me response bodies and stored in-memory on the client. Cookie remains as fallback. Retry mechanism also improved to read token from response body and handle expired sessions.
129 lines
3.8 KiB
JavaScript
129 lines
3.8 KiB
JavaScript
/**
|
|
* Modul: API-Client
|
|
* Zweck: Fetch-Wrapper mit Session-Auth, einheitlicher Fehlerbehandlung und JSON-Parsing
|
|
* Abhängigkeiten: keine
|
|
*/
|
|
|
|
const API_BASE = '/api/v1';
|
|
|
|
/** 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='))
|
|
?.slice('csrf-token='.length) ?? '';
|
|
}
|
|
|
|
/**
|
|
* Zentraler Fetch-Wrapper.
|
|
* Setzt Content-Type, handhabt 401-Redirects und parsed JSON-Fehler.
|
|
*
|
|
* @param {string} path - API-Pfad ohne /api/v1 (z.B. '/tasks')
|
|
* @param {RequestInit} options - Fetch-Optionen
|
|
* @returns {Promise<any>} Geparstes JSON oder wirft einen Fehler
|
|
*/
|
|
async function apiFetch(path, options = {}, _retried = false) {
|
|
const url = `${API_BASE}${path}`;
|
|
|
|
const method = options.method ?? 'GET';
|
|
const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
|
|
|
const response = await fetch(url, {
|
|
credentials: 'same-origin',
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(stateChanging ? { 'X-CSRF-Token': getCsrfToken() } : {}),
|
|
...options.headers,
|
|
},
|
|
...options,
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
// Session abgelaufen → zur Login-Seite
|
|
window.dispatchEvent(new CustomEvent('auth:expired'));
|
|
throw new Error('Sitzung abgelaufen.');
|
|
}
|
|
|
|
// 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) {
|
|
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);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Strukturierter API-Fehler mit HTTP-Status-Code.
|
|
*/
|
|
class ApiError extends Error {
|
|
constructor(message, status, data = null) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.data = data;
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Convenience-Methoden
|
|
// --------------------------------------------------------
|
|
|
|
const api = {
|
|
get: (path) => apiFetch(path, { method: 'GET' }),
|
|
|
|
post: (path, body) => apiFetch(path, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
put: (path, body) => apiFetch(path, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
patch: (path, body) => apiFetch(path, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
delete: (path) => apiFetch(path, { method: 'DELETE' }),
|
|
};
|
|
|
|
// --------------------------------------------------------
|
|
// Auth-spezifische Methoden
|
|
// --------------------------------------------------------
|
|
|
|
const auth = {
|
|
login: (username, password) => api.post('/auth/login', { username, password }),
|
|
logout: () => api.post('/auth/logout'),
|
|
me: () => api.get('/auth/me'),
|
|
getUsers: () => api.get('/auth/users'),
|
|
createUser: (data) => api.post('/auth/users', data),
|
|
deleteUser: (id) => api.delete(`/auth/users/${id}`),
|
|
};
|
|
|
|
export { api, auth, ApiError };
|