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; +}