Phase 10d: Token-Refresh + 401-Retry im Cards-Web
Some checks are pending
CI / validate (push) Waiting to run

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 21:50:12 +02:00
parent f1622e9a48
commit 1b840a95f9
3 changed files with 118 additions and 15 deletions

View file

@ -34,7 +34,7 @@ type RequestOptions = {
signal?: AbortSignal;
};
export async function api<T>(path: string, opts: RequestOptions = {}): Promise<T> {
async function doFetch(path: string, opts: RequestOptions): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
@ -43,18 +43,37 @@ export async function api<T>(path: string, opts: RequestOptions = {}): Promise<T
} else if (devUser.stubId) {
headers['X-User-Id'] = devUser.stubId;
}
const res = await fetch(`${API_BASE}${path}`, {
return fetch(`${API_BASE}${path}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
});
if (!res.ok) {
if (res.status === 401 && devUser.token) {
// Token vermutlich abgelaufen — Session lokal aufräumen, sodass
// die Landing-Page einen Re-Login zeigt.
}
export async function api<T>(path: string, opts: RequestOptions = {}): Promise<T> {
// 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();

View file

@ -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<string, string> = {};
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<string, string> => {
const h: Record<string, string> = {};
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 {