From 8fda2486837ce9b578f8025142e3be561759cd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 30 Jun 2026 11:53:38 +0200 Subject: [PATCH] fix(frontend): seltener Relogin durch stillen Token-Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Lücken behoben, die bei abgelaufenem Access-Token (v.a. nach inaktivem Tab, dessen Renew-Timer gedrosselt wurde) zu vollständigen Neuanmeldungen führten: - getAccessToken erneuert das Token jetzt proaktiv (signinSilent), wenn es abgelaufen ist, statt ein abgelaufenes Token zu senden. Profitiert auch den direkten fetch-Aufrufen (Bilder/PDFs). - Der 401-Interceptor versucht zuerst einen stillen Refresh und wiederholt die Anfrage einmal; erst wenn das scheitert (Refresh-Token ungültig), erfolgt der Login-Redirect. Gleichzeitige Refreshes werden dedupliziert (renewToken), um invalid_grant bei Refresh-Token-Rotation zu vermeiden. Co-Authored-By: Claude Opus 4.8 --- paperless-frontend/src/api/client.ts | 21 +++++++++++++++++--- paperless-frontend/src/auth/oidc.ts | 29 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/paperless-frontend/src/api/client.ts b/paperless-frontend/src/api/client.ts index 6623c72..75eb460 100644 --- a/paperless-frontend/src/api/client.ts +++ b/paperless-frontend/src/api/client.ts @@ -1,8 +1,12 @@ -import axios from 'axios'; -import { getAccessToken } from '../auth/oidc'; +import axios, { type InternalAxiosRequestConfig } from 'axios'; +import { getAccessToken, renewToken } from '../auth/oidc'; import { triggerLoginRedirect } from '../auth/sessionRedirect'; import { getEnv } from '../utils/env'; +// Markiert eine bereits einmal wiederholte Anfrage, um Endlosschleifen zu +// vermeiden. +type RetryConfig = InternalAxiosRequestConfig & { _retried?: boolean }; + const api = axios.create({ baseURL: getEnv('VITE_API_URL') || '', timeout: 30000, @@ -19,7 +23,18 @@ api.interceptors.request.use(async (config) => { api.interceptors.response.use( (response) => response, async (error) => { - if (error.response?.status === 401) { + const original = error.config as RetryConfig | undefined; + if (error.response?.status === 401 && original && !original._retried) { + original._retried = true; + // Erst still erneuern und die Anfrage einmal wiederholen, bevor wir den + // Nutzer zu einer kompletten Neuanmeldung zwingen. + const user = await renewToken(); + if (user?.access_token) { + original.headers.Authorization = `Bearer ${user.access_token}`; + return api(original); + } + // Erneuerung fehlgeschlagen (Refresh-Token ungültig/abgelaufen) → + // echte Neuanmeldung nötig. await triggerLoginRedirect(); } return Promise.reject(error); diff --git a/paperless-frontend/src/auth/oidc.ts b/paperless-frontend/src/auth/oidc.ts index ad55866..a5de680 100644 --- a/paperless-frontend/src/auth/oidc.ts +++ b/paperless-frontend/src/auth/oidc.ts @@ -34,9 +34,36 @@ export async function getUser(): Promise { return userManager.getUser(); } +// Laufender Silent-Renew-Vorgang – mehrere gleichzeitige Aufrufe teilen sich +// denselben Refresh (Dedup), damit nicht parallele signinSilent-Aufrufe mit +// einem rotierenden Refresh-Token kollidieren (invalid_grant). +let renewPromise: Promise | null = null; + +/** + * Erneuert das Token still über den Refresh-Token (signinSilent). Schlägt die + * Erneuerung fehl, wird null geliefert (statt zu werfen). Gleichzeitige Aufrufe + * werden dedupliziert. + */ +export function renewToken(): Promise { + if (!renewPromise) { + renewPromise = userManager.signinSilent().catch((err: unknown) => { + console.error('OIDC: signinSilent fehlgeschlagen', err); + return null; + }); + void renewPromise.finally(() => { + renewPromise = null; + }); + } + return renewPromise; +} + export async function getAccessToken(): Promise { const user = await getUser(); - return user?.access_token ?? null; + if (!user) return null; // nicht eingeloggt + if (!user.expired) return user.access_token ?? null; + // Token ist abgelaufen → vor dem Senden still erneuern. + const renewed = await renewToken(); + return renewed?.access_token ?? null; } export { userManager };