From 8ff67d19fbfe9d5aa005baef9ebfc10be2a626fa Mon Sep 17 00:00:00 2001 From: Claude VM Date: Tue, 19 May 2026 16:23:50 +0300 Subject: [PATCH] fix(auth): self-heal + auto re-login on refresh failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-layer fix for the "session keeps dying with invalid_grant" pain: 1. Authentik provider config (separate change via API): access_token_validity bumped 5min → 60min so refreshes are 12x less frequent. Refresh-token rotation collisions only happen during the refresh, so a longer access_token TTL means far fewer windows. 2. jwt callback (auth-options.ts): when Authentik responds 400 invalid_grant on refresh, the stored refresh_token is permanently dead — Authentik rotated it on a previous successful refresh and the old value can't be reused. Clear it (and the access_token) from the JWT so subsequent session checks see a clean RefreshAccessTokenError instead of looping into the same 400 every 5 minutes. 3. SessionErrorWatcher (new client component, mounted in providers tree): listens for session.error === "RefreshAccessTokenError" and calls signIn("authentik") with the current URL as callback. The cleared JWT cookie means Authentik runs a full OIDC flow, mints fresh tokens, and the user lands back where they were. No manual logout. Net effect: refresh storms become invisible — at worst there's a single redirect to Authentik (silent if the user is still SSO'd) instead of a broken session that 401s every API call. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/providers.tsx | 2 ++ src/core/auth/auth-options.ts | 13 ++++++++- src/core/auth/session-error-watcher.tsx | 37 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/core/auth/session-error-watcher.tsx diff --git a/src/app/providers.tsx b/src/app/providers.tsx index fab14ea..3bae5b4 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -6,6 +6,7 @@ import { StorageProvider } from '@/core/storage'; import { FeatureFlagProvider } from '@/core/feature-flags'; import { AuthProvider } from '@/core/auth'; import { VersionWatcher } from '@/core/version/version-watcher'; +import { SessionErrorWatcher } from '@/core/auth/session-error-watcher'; import { DEFAULT_FLAGS } from '@/config/flags'; // Ensure module registry is populated @@ -24,6 +25,7 @@ export function Providers({ children }: ProvidersProps) { {children} + diff --git a/src/core/auth/auth-options.ts b/src/core/auth/auth-options.ts index cfa750d..ec92cca 100644 --- a/src/core/auth/auth-options.ts +++ b/src/core/auth/auth-options.ts @@ -72,7 +72,18 @@ 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", errorAt: Date.now() }; + // invalid_grant means the refresh_token is permanently dead + // (rotation invalidated it, or it was revoked). Don't keep retrying + // — clear it so the next session check returns a clean + // "RefreshAccessTokenError" state and the client signs in afresh. + const permanent = res.status === 400 && body.error === "invalid_grant"; + return { + ...token, + accessToken: permanent ? undefined : token.accessToken, + refreshToken: permanent ? undefined : token.refreshToken, + error: "RefreshAccessTokenError", + errorAt: Date.now(), + }; } console.log("[auth] refresh OK expires_in=%d", body.expires_in ?? 300); return { diff --git a/src/core/auth/session-error-watcher.tsx b/src/core/auth/session-error-watcher.tsx new file mode 100644 index 0000000..7b8593d --- /dev/null +++ b/src/core/auth/session-error-watcher.tsx @@ -0,0 +1,37 @@ +"use client"; + +// Watches for NextAuth refresh failures and forces a fresh OIDC sign-in +// instead of leaving the user on a half-authenticated page that 401s on +// every API call. Mounted in the root providers tree. +// +// Trigger: session.error === "RefreshAccessTokenError" — set by the jwt +// callback when Authentik's refresh_token endpoint rejects (typically +// 400 invalid_grant after token rotation collision). The callback also +// clears the dead refresh_token, so just routing to /auth/signin restarts +// the OIDC flow without the user needing to manually sign out first. + +import { useEffect, useRef } from "react"; +import { useSession, signIn } from "next-auth/react"; + +export function SessionErrorWatcher() { + const { data: session, status } = useSession(); + const triggered = useRef(false); + + useEffect(() => { + if (status !== "authenticated") return; + const err = (session as { error?: string } | null)?.error; + if (err !== "RefreshAccessTokenError") { + triggered.current = false; + return; + } + if (triggered.current) return; + triggered.current = true; + // Auto-redirect to Authentik. callbackUrl preserves whatever map view + // the user was on so the panel re-renders after re-auth. + const callbackUrl = + typeof window !== "undefined" ? window.location.href : "/"; + void signIn("authentik", { callbackUrl }); + }, [session, status]); + + return null; +}