Skip to content

Commit a2aed6b

Browse files
committed
server side auth cookie renewal
1 parent d8483c7 commit a2aed6b

4 files changed

Lines changed: 418 additions & 22 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import React, { type JSX } from 'react';
2+
import {
3+
render,
4+
act,
5+
renderHook,
6+
type RenderResult,
7+
} from '@testing-library/react';
8+
import { configureStore } from '@reduxjs/toolkit';
9+
import { Provider } from 'react-redux';
10+
import { AuthSessionProvider, useAuthReady } from './AuthSessionProvider';
11+
import { setUserCookieSession } from '../services/session-service';
12+
import { anonymousLogin } from '../store/profile-reducer';
13+
14+
const RENEWAL_INTERVAL_MS = 5 * 60 * 1000;
15+
16+
// ---------- Mock: firebase ----------
17+
18+
let capturedAuthCallback: (user: unknown) => void = () => {};
19+
const mockUnsubscribe = jest.fn();
20+
21+
jest.mock('../../firebase', () => ({
22+
app: {
23+
auth: jest.fn(() => ({
24+
onIdTokenChanged: jest.fn((cb: (user: unknown) => void) => {
25+
capturedAuthCallback = cb;
26+
return mockUnsubscribe;
27+
}),
28+
currentUser: null,
29+
})),
30+
},
31+
}));
32+
33+
// ---------- Mock: session-service ----------
34+
35+
jest.mock('../services/session-service', () => ({
36+
setUserCookieSession: jest.fn().mockResolvedValue(undefined),
37+
}));
38+
39+
// ---------- Mock: profile-reducer ----------
40+
41+
jest.mock('../store/profile-reducer', () => ({
42+
anonymousLogin: jest.fn(() => ({ type: 'profile/anonymousLogin' })),
43+
}));
44+
45+
// ---------- Mock: react-redux (preserve Provider, stub useDispatch) ----------
46+
47+
const mockDispatch = jest.fn();
48+
jest.mock('react-redux', () => ({
49+
...jest.requireActual('react-redux'),
50+
useDispatch: () => mockDispatch,
51+
}));
52+
53+
// ---------- Helpers ----------
54+
55+
function makeStore(): ReturnType<typeof configureStore> {
56+
return configureStore({ reducer: { _: () => null } });
57+
}
58+
59+
function wrapper({ children }: { children: React.ReactNode }): JSX.Element {
60+
return <Provider store={makeStore()}>{children}</Provider>;
61+
}
62+
63+
function wrapperWithAuth({
64+
children,
65+
}: {
66+
children: React.ReactNode;
67+
}): JSX.Element {
68+
return (
69+
<Provider store={makeStore()}>
70+
<AuthSessionProvider>{children}</AuthSessionProvider>
71+
</Provider>
72+
);
73+
}
74+
75+
function renderProvider(): RenderResult {
76+
return render(
77+
<Provider store={makeStore()}>
78+
<AuthSessionProvider>
79+
<span data-testid='child' />
80+
</AuthSessionProvider>
81+
</Provider>,
82+
);
83+
}
84+
85+
const mockUser = { uid: 'user-1' };
86+
87+
// ---------- Tests ----------
88+
89+
beforeEach(() => {
90+
jest.useFakeTimers();
91+
jest.clearAllMocks();
92+
capturedAuthCallback = () => {};
93+
});
94+
95+
afterEach(() => {
96+
jest.useRealTimers();
97+
});
98+
99+
describe('AuthSessionProvider', () => {
100+
describe('useAuthReady', () => {
101+
it('returns false before any auth state change', () => {
102+
const { result } = renderHook(() => useAuthReady(), { wrapper });
103+
expect(result.current).toBe(false);
104+
});
105+
106+
it('returns true after onIdTokenChanged fires with a user', async () => {
107+
const { result } = renderHook(() => useAuthReady(), {
108+
wrapper: wrapperWithAuth,
109+
});
110+
111+
await act(async () => {
112+
capturedAuthCallback(mockUser);
113+
});
114+
115+
expect(result.current).toBe(true);
116+
});
117+
118+
it('returns false after onIdTokenChanged fires with null', async () => {
119+
const { result } = renderHook(() => useAuthReady(), {
120+
wrapper: wrapperWithAuth,
121+
});
122+
123+
await act(async () => {
124+
capturedAuthCallback(null);
125+
});
126+
127+
expect(result.current).toBe(false);
128+
});
129+
});
130+
131+
describe('when a user signs in', () => {
132+
it('calls setUserCookieSession immediately', async () => {
133+
renderProvider();
134+
135+
await act(async () => {
136+
capturedAuthCallback(mockUser);
137+
});
138+
139+
expect(setUserCookieSession).toHaveBeenCalledTimes(1);
140+
});
141+
142+
it('calls setUserCookieSession again after 5 minutes', async () => {
143+
renderProvider();
144+
145+
await act(async () => {
146+
capturedAuthCallback(mockUser);
147+
});
148+
149+
await act(async () => {
150+
jest.advanceTimersByTime(RENEWAL_INTERVAL_MS);
151+
});
152+
153+
expect(setUserCookieSession).toHaveBeenCalledTimes(2);
154+
});
155+
156+
it('keeps calling setUserCookieSession every 5 minutes', async () => {
157+
renderProvider();
158+
159+
await act(async () => {
160+
capturedAuthCallback(mockUser);
161+
});
162+
163+
await act(async () => {
164+
jest.advanceTimersByTime(RENEWAL_INTERVAL_MS * 3);
165+
});
166+
167+
expect(setUserCookieSession).toHaveBeenCalledTimes(4); // 1 immediate + 3 ticks
168+
});
169+
170+
it('clears the interval when auth state changes', async () => {
171+
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
172+
renderProvider();
173+
174+
await act(async () => {
175+
capturedAuthCallback(mockUser);
176+
});
177+
178+
await act(async () => {
179+
capturedAuthCallback(null);
180+
});
181+
182+
expect(clearIntervalSpy).toHaveBeenCalled();
183+
clearIntervalSpy.mockRestore();
184+
});
185+
186+
it('does not call setUserCookieSession after auth state changes to null', async () => {
187+
renderProvider();
188+
189+
await act(async () => {
190+
capturedAuthCallback(mockUser);
191+
});
192+
193+
await act(async () => {
194+
capturedAuthCallback(null);
195+
});
196+
197+
(setUserCookieSession as jest.Mock).mockClear();
198+
199+
await act(async () => {
200+
jest.advanceTimersByTime(RENEWAL_INTERVAL_MS);
201+
});
202+
203+
expect(setUserCookieSession).not.toHaveBeenCalled();
204+
});
205+
});
206+
207+
describe('when no user is present', () => {
208+
it('dispatches anonymousLogin', async () => {
209+
renderProvider();
210+
211+
await act(async () => {
212+
capturedAuthCallback(null);
213+
});
214+
215+
expect(mockDispatch).toHaveBeenCalledWith(
216+
(anonymousLogin as unknown as jest.Mock).mock.results[0].value,
217+
);
218+
});
219+
220+
it('does not call setUserCookieSession', async () => {
221+
renderProvider();
222+
223+
await act(async () => {
224+
capturedAuthCallback(null);
225+
});
226+
227+
expect(setUserCookieSession).not.toHaveBeenCalled();
228+
});
229+
});
230+
231+
describe('cleanup on unmount', () => {
232+
it('unsubscribes from onIdTokenChanged', () => {
233+
const { unmount } = renderProvider();
234+
unmount();
235+
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
236+
});
237+
238+
it('cancels the renewal interval on unmount', async () => {
239+
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
240+
241+
const { unmount } = renderProvider();
242+
243+
await act(async () => {
244+
capturedAuthCallback(mockUser);
245+
});
246+
247+
unmount();
248+
249+
expect(clearIntervalSpy).toHaveBeenCalled();
250+
clearIntervalSpy.mockRestore();
251+
});
252+
253+
it('does not call setUserCookieSession after unmount', async () => {
254+
const { unmount } = renderProvider();
255+
256+
await act(async () => {
257+
capturedAuthCallback(mockUser);
258+
});
259+
260+
unmount();
261+
(setUserCookieSession as jest.Mock).mockClear();
262+
263+
await act(async () => {
264+
jest.advanceTimersByTime(RENEWAL_INTERVAL_MS);
265+
});
266+
267+
expect(setUserCookieSession).not.toHaveBeenCalled();
268+
});
269+
});
270+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use client';
2+
3+
import {
4+
createContext,
5+
useContext,
6+
useEffect,
7+
useRef,
8+
useState,
9+
type ReactNode,
10+
type ReactElement,
11+
} from 'react';
12+
import { useDispatch } from 'react-redux';
13+
import { app } from '../../firebase';
14+
import { anonymousLogin } from '../store/profile-reducer';
15+
import { setUserCookieSession } from '../services/session-service';
16+
17+
const AuthReadyContext = createContext(false);
18+
19+
/**
20+
* Returns true once a Firebase user (anonymous or authenticated) is
21+
* available. Use this instead of registering your own
22+
* `onAuthStateChanged` listener.
23+
*/
24+
export function useAuthReady(): boolean {
25+
return useContext(AuthReadyContext);
26+
}
27+
28+
/**
29+
* Global auth session provider. Renders inside the Redux provider tree
30+
* and manages a single `onAuthStateChanged` listener that:
31+
*
32+
* 1. Triggers anonymous sign-in when no user exists.
33+
* 2. Re-establishes the `md_session` cookie on return visits (Firebase
34+
* restores auth from IndexedDB but the 1-hour cookie has expired).
35+
* 3. Schedules the next renewal at exactly `expiresAt - 5 min` using
36+
* a setTimeout derived from the value stored in localStorage.
37+
* 4. Deduplicates POSTs across tabs — localStorage is shared across all
38+
* tabs, so a renewal written by any tab is immediately visible to all
39+
* others via the `isCookieFresh` check in setUserCookieSession.
40+
* 5. Exposes `isAuthReady` via context.
41+
*/
42+
export function AuthSessionProvider({
43+
children,
44+
}: {
45+
children: ReactNode;
46+
}): ReactElement {
47+
const dispatch = useDispatch();
48+
const [isAuthReady, setIsAuthReady] = useState(false);
49+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
50+
51+
useEffect(() => {
52+
const unsubscribe = app.auth().onIdTokenChanged((user) => {
53+
if (intervalRef.current != null) {
54+
clearInterval(intervalRef.current);
55+
intervalRef.current = null;
56+
}
57+
58+
if (user != null) {
59+
setIsAuthReady(true);
60+
setUserCookieSession().catch(() => {});
61+
62+
// Check every 5 minutes; the cookie lasts 60 minutes, so this ensures renewal well before expiry
63+
// If the cookie is not expired, it will return early and skip the POST
64+
// The token will refresh 5 minutes before expiry which is why the 5 minute interval is used here.
65+
intervalRef.current = setInterval(
66+
() => {
67+
setUserCookieSession().catch(() => {});
68+
},
69+
5 * 60 * 1000,
70+
); // 5 minutes
71+
} else {
72+
setIsAuthReady(false);
73+
dispatch(anonymousLogin());
74+
}
75+
});
76+
77+
return () => {
78+
unsubscribe();
79+
if (intervalRef.current != null) clearInterval(intervalRef.current);
80+
};
81+
}, [dispatch]);
82+
83+
return (
84+
<AuthReadyContext.Provider value={isAuthReady}>
85+
{children}
86+
</AuthReadyContext.Provider>
87+
);
88+
}

0 commit comments

Comments
 (0)