From 293d15edf24bc80e7870064fe4dfefbb64c78fb1 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Tue, 19 May 2026 07:59:53 +0300 Subject: [PATCH] =?UTF-8?q?fix(auth):=20refresh=20cooldown=2060s=20?= =?UTF-8?q?=E2=80=94=20auto-recover=20from=20sticky=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous logic set token.error=RefreshAccessTokenError and never retried — once a refresh failed (likely a race during the early parallel-storm period), Marius's JWT cookie carried that error forever. New jwt calls all saw "blocked" → kept using the stale accessToken → api.gis.ac returned invalid_token on every call. Fix: store errorAt timestamp alongside the error flag. Block refresh attempts for 60s after a failure (avoids hot-loop on persistent Authentik issues), then unblock and retry. On the next failure, the 60s cooldown re-arms. For Marius's currently-stuck session: as soon as this deploys, his next jwt callback will pass the cooldown check (errorAt is hours ago) and trigger a fresh refresh. If Authentik is happy with his refresh_token, the error flag is cleared and he's back to normal — no relogin needed. Logs now show "blocked=true/false" alongside secLeft for visibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/auth/auth-options.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/core/auth/auth-options.ts b/src/core/auth/auth-options.ts index 7d7c7f5..b5b4898 100644 --- a/src/core/auth/auth-options.ts +++ b/src/core/auth/auth-options.ts @@ -64,7 +64,7 @@ async function refreshAuthentikToken(token: JWT): Promise { }; if (!res.ok || !body.access_token) { console.warn("[auth] refresh failed:", res.status, body.error); - return { ...token, error: "RefreshAccessTokenError" }; + return { ...token, error: "RefreshAccessTokenError", errorAt: Date.now() }; } console.log("[auth] refresh OK expires_in=%d", body.expires_in ?? 300); return { @@ -77,7 +77,7 @@ async function refreshAuthentikToken(token: JWT): Promise { } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.warn("[auth] refresh error:", msg); - return { ...token, error: "RefreshAccessTokenError" }; + return { ...token, error: "RefreshAccessTokenError", errorAt: Date.now() }; } finally { inflightRefreshes.delete(refreshToken); } @@ -142,18 +142,23 @@ export const authOptions: NextAuthOptions = { const exp = typeof token.accessTokenExpires === "number" ? token.accessTokenExpires : 0; + const errorAt = typeof token.errorAt === "number" ? token.errorAt : 0; + const cooldownDone = Date.now() - errorAt > 60_000; + const blocked = + token.error === "RefreshAccessTokenError" && !cooldownDone; const secLeft = Math.round((exp - Date.now()) / 1000); console.log( - "[auth] jwt secLeft=%d hasAccess=%s hasRefresh=%s err=%s", + "[auth] jwt secLeft=%d hasAccess=%s hasRefresh=%s err=%s blocked=%s", secLeft, !!token.accessToken, !!token.refreshToken, token.error ?? "none", + blocked, ); if ( token.accessToken && token.refreshToken && - token.error !== "RefreshAccessTokenError" && + !blocked && Date.now() > exp - 30_000 ) { return refreshAuthentikToken(token);