Skip to content

Commit 8ff9833

Browse files
authored
fix(NOISSUE-0000): Infinite refresh loop when session has RefreshTokenError (#2796)
1 parent e725f63 commit 8ff9833

2 files changed

Lines changed: 50 additions & 1 deletion

File tree

packages/auth-next-client/src/hooks.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,23 @@ describe('useImmutableSession', () => {
291291
// Should NOT have called update -- token is still valid
292292
expect(mockUpdate).not.toHaveBeenCalled();
293293
});
294+
295+
it('does not trigger refresh when session has error (prevents infinite loop)', async () => {
296+
// Simulate: token expired and last refresh failed (e.g. RefreshTokenError)
297+
const sessionWithError = createSession({
298+
accessTokenExpires: Date.now() - 1000, // expired
299+
error: 'RefreshTokenError',
300+
});
301+
setupUseSession(sessionWithError);
302+
303+
await act(async () => {
304+
renderHook(() => useImmutableSession());
305+
});
306+
307+
// Must NOT call update - otherwise we would retry refresh repeatedly
308+
// and cause an infinite loop (update -> same session with error -> effect re-runs -> update again).
309+
expect(mockUpdate).not.toHaveBeenCalled();
310+
});
294311
});
295312

296313
describe('getUser() respects pending refresh', () => {
@@ -317,5 +334,35 @@ describe('useImmutableSession', () => {
317334
// getUser() should have waited for the refresh and gotten the fresh token
318335
expect(user?.accessToken).toBe('user-fresh-token');
319336
});
337+
338+
it('getUser(true) still calls update with forceRefresh even when session has error', async () => {
339+
// Session is in error state (e.g. previous refresh failed)
340+
const sessionWithError = createSession({
341+
accessTokenExpires: Date.now() - 1000,
342+
error: 'RefreshTokenError',
343+
});
344+
setupUseSession(sessionWithError);
345+
346+
// Server recovers and returns a valid session (e.g. user re-authenticated elsewhere)
347+
const recoveredSession = createSession({
348+
accessToken: 'recovered-token',
349+
accessTokenExpires: Date.now() + 10 * 60 * 1000,
350+
user: { sub: 'user-1', email: 'recovered@test.com' },
351+
});
352+
mockUpdate.mockResolvedValue(recoveredSession);
353+
354+
const { result } = renderHook(() => useImmutableSession());
355+
356+
let user: any;
357+
await act(async () => {
358+
user = await result.current.getUser(true);
359+
});
360+
361+
// forceRefresh must have been attempted (proactive effect does NOT run when session.error is set)
362+
expect(mockUpdate).toHaveBeenCalledWith({ forceRefresh: true });
363+
// When server returns a good session, we get the user
364+
expect(user?.accessToken).toBe('recovered-token');
365+
expect(user?.profile?.email).toBe('recovered@test.com');
366+
});
320367
});
321368
});

packages/auth-next-client/src/hooks.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,16 @@ export function useImmutableSession(): UseImmutableSessionReturn {
221221
// `!isRefreshing` to briefly lose their cached data, resulting in UI flicker.
222222
useEffect(() => {
223223
if (!session?.accessTokenExpires) return;
224+
// Don't retry if the last refresh already failed - prevents infinite loops
225+
if (session?.error) return;
224226

225227
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
226228

227229
if (timeUntilExpiry <= 0) {
228230
// Already expired -- refresh silently
229231
deduplicatedUpdate(() => updateRef.current());
230232
}
231-
}, [session?.accessTokenExpires]);
233+
}, [session?.accessTokenExpires, session?.error]);
232234

233235
// ---------------------------------------------------------------------------
234236
// Sync idToken to localStorage

0 commit comments

Comments
 (0)