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) {
}
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) {
// Token vermutlich abgelaufen — Session lokal aufräumen, sodass
// die Landing-Page einen Re-Login zeigt.
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 {

View file

@ -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<string | null>(null);
user = $state<AuthUser | null>(null);
stubId = $state<string | null>(null); // Phase-2-Übergangs-Fallback (?stub=<uuid>)
private refreshing: Promise<boolean> | 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<void> {
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<boolean> {
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<void> {
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') {