Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/api/auth/[...nextauth]/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ export const authOptions: AuthOptions = {
token.expires_at = account.expires_at;
token.refresh_token = account.refresh_token;
return token;
} else if (token.error === 'RefreshAccessTokenError') {
// A previous refresh already failed. Return the token as-is instead of
// retrying.
//
// This callback runs on every session fetch, and a failed refresh used to
// leave `expires_at` in the past - so every subsequent fetch attempted the
// same refresh, and failed the same way. Combined with SessionGuard reacting
// to the error by signing out (which triggers another session fetch), that
// produced an unbounded loop: 35,685 signout calls and 107,612 session
// fetches from a single client in one day, peaking at 6,230 requests/minute.
//
// The session is unrecoverable at this point. The user has to sign in again,
// which SessionGuard handles; hammering Keycloak in the meantime helps
// nobody.
return token;
} else if (nowTimeStamp < (token.expires_at as number)) {
// token has not expired yet, return it
return token;
Expand Down
32 changes: 29 additions & 3 deletions components/SessionGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
'use client';

import { ReactNode, useEffect } from 'react';
import { ReactNode, useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { signIn, signOut, useSession } from 'next-auth/react';

export default function SessionGuard({ children }: { children: ReactNode }) {
const { data } = useSession();
const pathname = usePathname();

// Guards against re-running the cleanup for an error we have already acted on.
//
// Without this, an expired session produced an unbounded loop: the effect called
// signOut(), useSession refetched, the session still carried
// RefreshAccessTokenError, and the effect fired again - as fast as the network
// allowed. One client produced 35,685 signout calls and 107,612 session fetches in
// a day, peaking at 6,230 requests/minute, which exhausted the backend's per-IP
// rate limit and returned 429s to every other user behind the same NAT address.
//
// The loop was self-sustaining: once rate-limited, the signOut call itself started
// failing, so the session was never cleared and the condition never resolved.
const cleanupAttempted = useRef(false);

useEffect(() => {
if (data?.error !== 'RefreshAccessTokenError') return;
if (data?.error !== 'RefreshAccessTokenError') {
// Session recovered (or the user signed in again) - allow a future cleanup.
cleanupAttempted.current = false;
return;
}

if (cleanupAttempted.current) return;
cleanupAttempted.current = true;

const clearExpiredSession = async () => {
await signOut({ redirect: false });
try {
await signOut({ redirect: false });
} catch {
// Deliberately swallowed. If signOut fails - the backend being rate-limited
// is exactly when it will - retrying immediately is what created the loop.
// The flag above stays set, so this settles instead of spinning.
}

if (pathname.includes('dashboard')) {
signIn('keycloak');
Expand Down
16 changes: 16 additions & 0 deletions types/next-auth-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,19 @@ declare module 'next-auth' {
} & DefaultSession['user'];
}
}

declare module 'next-auth/jwt' {
/**
* The shape of the token returned by the `jwt` callback in
* app/api/auth/[...nextauth]/options.ts.
*
* `error` is declared here because that callback both writes it (on a failed
* refresh) and reads it (to avoid retrying a refresh that has already failed).
* JWT extends Record<string, unknown>, so this compiled without the declaration -
* but the read was typed `unknown`, which is exactly the sort of thing that
* silently stops meaning what you think it means.
*/
interface JWT {
error?: string;
}
}
Loading