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