fix(auth): refresh cooldown 60s — auto-recover from sticky errors

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) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 07:59:53 +03:00
parent b85e074e3a
commit 293d15edf2
+9 -4
View File
@@ -64,7 +64,7 @@ async function refreshAuthentikToken(token: JWT): Promise<JWT> {
};
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<JWT> {
} 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);