Phase 10d: Token-Refresh + 401-Retry im Cards-Web
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
f1622e9a48
commit
1b840a95f9
3 changed files with 118 additions and 15 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue