fix(auth): self-heal + auto re-login on refresh failure
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) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { StorageProvider } from '@/core/storage';
|
|||||||
import { FeatureFlagProvider } from '@/core/feature-flags';
|
import { FeatureFlagProvider } from '@/core/feature-flags';
|
||||||
import { AuthProvider } from '@/core/auth';
|
import { AuthProvider } from '@/core/auth';
|
||||||
import { VersionWatcher } from '@/core/version/version-watcher';
|
import { VersionWatcher } from '@/core/version/version-watcher';
|
||||||
|
import { SessionErrorWatcher } from '@/core/auth/session-error-watcher';
|
||||||
import { DEFAULT_FLAGS } from '@/config/flags';
|
import { DEFAULT_FLAGS } from '@/config/flags';
|
||||||
|
|
||||||
// Ensure module registry is populated
|
// Ensure module registry is populated
|
||||||
@@ -24,6 +25,7 @@ export function Providers({ children }: ProvidersProps) {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
<VersionWatcher />
|
<VersionWatcher />
|
||||||
|
<SessionErrorWatcher />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</FeatureFlagProvider>
|
</FeatureFlagProvider>
|
||||||
</StorageProvider>
|
</StorageProvider>
|
||||||
|
|||||||
@@ -72,7 +72,18 @@ async function refreshAuthentikToken(token: JWT): Promise<JWT> {
|
|||||||
};
|
};
|
||||||
if (!res.ok || !body.access_token) {
|
if (!res.ok || !body.access_token) {
|
||||||
console.warn("[auth] refresh failed:", res.status, body.error);
|
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);
|
console.log("[auth] refresh OK expires_in=%d", body.expires_in ?? 300);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user