From 1b840a95f99012c874b1a4ef91b5f0d2cc30c295 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 8 May 2026 21:50:12 +0200 Subject: [PATCH] Phase 10d: Token-Refresh + 401-Retry im Cards-Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards-Web läuft jetzt auch über die 15min-JWT-TTL hinaus weiter: - Login schickt credentials:'include' → SSO-Cookie auf .mana.how wird gesetzt (mana-auth-Standard). - tryRefresh() ruft mana-auth POST /api/v1/auth/refresh mit Cookie- Auth, holt frischen accessToken, legt ihn in localStorage. Multi- Caller werden zu einer Promise gecoalesced (kein Refresh-Storm). - ensureFreshToken() prüft beim Konstruktor + vor jedem API-Request, ob der Token noch ≥60s gültig ist. Wenn nicht, proaktiver Refresh. - API-Client: 401 → tryRefresh() → exactly-once Retry. Erst wenn das auch 401 gibt, wird die Session lokal geleert (führt User zurück auf /). - uploadMedia (multipart) parallel mit demselben Pattern. - Logout ruft mana-auth /logout mit Cookie-Auth, damit das SSO-Cookie für andere *.mana.how-Apps auch weg ist (best-effort, nicht-fatal). - Boot-Pfad: bei abgelaufenem Token im localStorage behalten wir das User-Profil temporär und versuchen einen still-Refresh — User sieht beim Reload kein Login-Flackern, wenn die Cookie-Session noch lebt. svelte-check 384 files 0 errors, 7 Web-Tests grün, prod-Build sauber. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/lib/api/client.ts | 31 ++++++++-- apps/web/src/lib/api/media.ts | 26 ++++++-- apps/web/src/lib/auth/dev-stub.svelte.ts | 76 ++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index fbcdfaa..bccf580 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -34,7 +34,7 @@ type RequestOptions = { signal?: AbortSignal; }; -export async function api(path: string, opts: RequestOptions = {}): Promise { +async function doFetch(path: string, opts: RequestOptions): Promise { const headers: Record = { 'Content-Type': 'application/json', }; @@ -43,18 +43,37 @@ export async function api(path: string, opts: RequestOptions = {}): Promise(path: string, opts: RequestOptions = {}): Promise { + // Proaktive Frische-Prüfung: wenn Token <60s gültig ist, refreshen + // wir, bevor der Request rausgeht. Coalesced über tryRefresh(). + await devUser.ensureFreshToken(); + + let res = await doFetch(path, opts); + + // Reaktive Refresh-Retry: trotz proaktiver Prüfung kann der Token + // zwischenzeitlich invalid werden (Server-Rotation, Clock-Skew). + // Genau ein Retry nach erfolgreichem Refresh, sonst klassisches 401. + if (res.status === 401 && devUser.token) { + const refreshed = await devUser.tryRefresh(); + if (refreshed) { + res = await doFetch(path, opts); + } + if (res.status === 401) { + // Auch nach Refresh 401 → Session ist tot. Lokal leeren, + // Landing zeigt Re-Login. devUser.clear(); } + } + + if (!res.ok) { let body: unknown = null; try { body = await res.json(); diff --git a/apps/web/src/lib/api/media.ts b/apps/web/src/lib/api/media.ts index 1987795..bc71c23 100644 --- a/apps/web/src/lib/api/media.ts +++ b/apps/web/src/lib/api/media.ts @@ -20,16 +20,32 @@ export async function uploadMedia(file: File | Blob, filename?: string): Promise const wrapped = file instanceof File ? file : new File([file], filename ?? 'upload.bin'); form.append('file', wrapped); - const headers: Record = {}; - if (devUser.token) headers['Authorization'] = `Bearer ${devUser.token}`; - else if (devUser.stubId) headers['X-User-Id'] = devUser.stubId; + await devUser.ensureFreshToken(); - const res = await fetch(`${API_BASE}/api/v1/media/upload`, { + const buildHeaders = (): Record => { + const h: Record = {}; + if (devUser.token) h['Authorization'] = `Bearer ${devUser.token}`; + else if (devUser.stubId) h['X-User-Id'] = devUser.stubId; + return h; + }; + + let res = await fetch(`${API_BASE}/api/v1/media/upload`, { method: 'POST', body: form, - headers, + headers: buildHeaders(), }); + if (res.status === 401 && devUser.token) { + const refreshed = await devUser.tryRefresh(); + if (refreshed) { + res = await fetch(`${API_BASE}/api/v1/media/upload`, { + method: 'POST', + body: form, + headers: buildHeaders(), + }); + } + } + if (!res.ok) { let body: unknown = null; try { diff --git a/apps/web/src/lib/auth/dev-stub.svelte.ts b/apps/web/src/lib/auth/dev-stub.svelte.ts index fc948ae..96fa783 100644 --- a/apps/web/src/lib/auth/dev-stub.svelte.ts +++ b/apps/web/src/lib/auth/dev-stub.svelte.ts @@ -50,10 +50,18 @@ function isExpired(claims: JwtClaims): boolean { return claims.exp * 1000 < Date.now(); } +function authBaseUrl(): string { + if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_MANA_AUTH_URL) { + return import.meta.env.PUBLIC_MANA_AUTH_URL; + } + return 'https://auth.mana.how'; +} + class Session { token = $state(null); user = $state(null); stubId = $state(null); // Phase-2-Übergangs-Fallback (?stub=) + private refreshing: Promise | null = null; constructor() { if (typeof window === 'undefined') return; @@ -64,6 +72,11 @@ class Session { if (claims && !isExpired(claims)) { this.token = stored; this.user = JSON.parse(userJson) as AuthUser; + } else if (claims) { + // JWT abgelaufen, aber Cookie-Session lebt evtl. noch. + // User-Profil temporär behalten und im Hintergrund refreshen. + this.user = JSON.parse(userJson) as AuthUser; + void this.tryRefresh(); } else { this.clearLocal(); } @@ -88,12 +101,10 @@ class Session { /** Login gegen mana-auth, speichert accessToken + Profil. */ async login(email: string, password: string): Promise { - const baseUrl = - (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_MANA_AUTH_URL) || - 'https://auth.mana.how'; - const r = await fetch(`${baseUrl}/api/v1/auth/login`, { + const r = await fetch(`${authBaseUrl()}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ email, password }), }); if (!r.ok) { @@ -120,6 +131,55 @@ class Session { } } + /** + * Versucht den accessToken via mana-auth-/refresh zu erneuern. + * Nutzt die SSO-Session-Cookie (`credentials: 'include'`), die + * mana-auth beim Login auf `.mana.how` setzt. Returnt true bei + * Erfolg. Mehrfach-Aufrufe werden zu einer Promise gecoalesced, + * damit gleichzeitige API-Calls keinen Refresh-Storm verursachen. + */ + async tryRefresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = (async () => { + try { + const r = await fetch(`${authBaseUrl()}/api/v1/auth/refresh`, { + method: 'POST', + credentials: 'include', + }); + if (!r.ok) return false; + const data = (await r.json()) as { accessToken?: string }; + if (!data.accessToken) return false; + this.token = data.accessToken; + if (typeof window !== 'undefined') { + window.localStorage.setItem(TOKEN_KEY, data.accessToken); + } + return true; + } catch { + return false; + } finally { + this.refreshing = null; + } + })(); + return this.refreshing; + } + + /** + * Stellt sicher, dass der token noch ≥60s gültig ist. Aufgerufen + * vom API-Client vor jedem Request. Wenn der token bald abläuft, + * versuchen wir einen stillen Refresh; klappt das nicht, lassen + * wir den Request mit dem alten Token durch — die 401-Behandlung + * im Client greift dann. + */ + async ensureFreshToken(): Promise { + if (!this.token) return; + const claims = decodeJwt(this.token); + if (!claims?.exp) return; + const remainingMs = claims.exp * 1000 - Date.now(); + if (remainingMs < 60_000) { + await this.tryRefresh(); + } + } + /** Setzt einen Dev-Stub (kein echtes Auth). Nur für Tests/Migration. */ set(userId: string) { const trimmed = userId.trim(); @@ -131,6 +191,14 @@ class Session { } clear() { + // Auch SSO-Cookie auf .mana.how aufräumen — best-effort, schlägt + // bei abgelaufener Session ohne Drama fehl. + if (this.token && typeof window !== 'undefined') { + void fetch(`${authBaseUrl()}/api/v1/auth/logout`, { + method: 'POST', + credentials: 'include', + }).catch(() => undefined); + } this.clearLocal(); this.stubId = null; if (typeof window !== 'undefined') {