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 {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue