diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index ba9c8098..17550db0 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -147,7 +147,9 @@ export async function apiClient( try { const refreshResponse = await refreshTokens(); tokenStorage.setAccessToken(refreshResponse.accessToken); - notifyTokenRefreshed(refreshResponse.accessToken); + // Forward the fresh user too so AuthContext updates both the user + // object and isAuthenticated, not just the stored token (#560). + notifyTokenRefreshed(refreshResponse.accessToken, refreshResponse.user); // Retry the original request with hasTriedRefresh = true return attemptRequest(attempt, true); } catch (refreshError) { diff --git a/frontend/src/api/tokens.ts b/frontend/src/api/tokens.ts index e45f8c3d..0ef8d901 100644 --- a/frontend/src/api/tokens.ts +++ b/frontend/src/api/tokens.ts @@ -1,12 +1,20 @@ +import type { User } from './types'; + const ACCESS_TOKEN_KEY = 'accessToken'; -// Called by apiClient after a silent token refresh so AuthContext can stay in sync. -let _onTokenRefreshed: ((accessToken: string) => void) | null = null; -export const setTokenRefreshCallback = (cb: (accessToken: string) => void) => { +/** + * Callback invoked after `apiClient` silently refreshes the access token, so + * AuthContext can keep its reactive `isAuthenticated` / `user` state in sync. + * The fresh `user` from the refresh response is forwarded when available. + */ +type TokenRefreshCallback = (accessToken: string, user?: User | null) => void; + +let _onTokenRefreshed: TokenRefreshCallback | null = null; +export const setTokenRefreshCallback = (cb: TokenRefreshCallback) => { _onTokenRefreshed = cb; }; -export const notifyTokenRefreshed = (accessToken: string) => { - _onTokenRefreshed?.(accessToken); +export const notifyTokenRefreshed = (accessToken: string, user?: User | null) => { + _onTokenRefreshed?.(accessToken, user); }; export const tokenStorage = { diff --git a/frontend/src/context/AuthContext.test.tsx b/frontend/src/context/AuthContext.test.tsx new file mode 100644 index 00000000..0f09c3b1 --- /dev/null +++ b/frontend/src/context/AuthContext.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { AuthProvider, useAuth } from './AuthContext'; +import { tokenStorage, notifyTokenRefreshed } from '../api/tokens'; +import { User, UserRole } from '../api/types'; + +/** Build a JWT-shaped token whose `exp` is `offsetSec` from now. */ +const makeToken = (offsetSec: number): string => { + const payload = btoa( + JSON.stringify({ exp: Math.floor(Date.now() / 1000) + offsetSec }), + ); + return `header.${payload}.signature`; +}; + +const sampleUser: User = { + id: '1', + email: 'alice@example.com', + firstName: 'Alice', + lastName: 'Doe', + role: UserRole.USER, +}; + +const Consumer: React.FC = () => { + const { isAuthenticated, user } = useAuth(); + return ( +
+ {String(isAuthenticated)} + {user?.email ?? 'none'} +
+ ); +}; + +const renderAuth = () => + render( + + + , + ); + +beforeEach(() => { + localStorage.clear(); +}); + +describe('AuthContext silent token refresh (#560)', () => { + it('flips isAuthenticated to true and sets the user after a silent refresh', () => { + // Start unauthenticated (no token, no user). + renderAuth(); + expect(screen.getByTestId('auth').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe('none'); + + // Simulate apiClient's background refresh writing a fresh token + user. + act(() => { + tokenStorage.setAccessToken(makeToken(3600)); + notifyTokenRefreshed(makeToken(3600), sampleUser); + }); + + // Context updates immediately — no waiting for the 5-minute expiry check. + expect(screen.getByTestId('auth').textContent).toBe('true'); + expect(screen.getByTestId('user').textContent).toBe('alice@example.com'); + }); + + it('updates the user object from the refresh response', () => { + localStorage.setItem('user', JSON.stringify(sampleUser)); + tokenStorage.setAccessToken(makeToken(3600)); + + renderAuth(); + expect(screen.getByTestId('user').textContent).toBe('alice@example.com'); + + const updated: User = { ...sampleUser, email: 'alice.new@example.com' }; + act(() => { + tokenStorage.setAccessToken(makeToken(3600)); + notifyTokenRefreshed(makeToken(3600), updated); + }); + + expect(screen.getByTestId('user').textContent).toBe('alice.new@example.com'); + expect(screen.getByTestId('auth').textContent).toBe('true'); + }); + + it('keeps isAuthenticated true when a refresh carries no user (token only)', () => { + localStorage.setItem('user', JSON.stringify(sampleUser)); + tokenStorage.setAccessToken(makeToken(-100)); // expired going in + + renderAuth(); + // Mount cleared the expired token, so we are unauthenticated. + expect(screen.getByTestId('auth').textContent).toBe('false'); + + // A refresh without a user should still re-authenticate if a user exists... + // here there is no user (it was cleared), so it stays false but does not throw. + act(() => { + notifyTokenRefreshed(makeToken(3600)); + }); + expect(screen.getByTestId('auth').textContent).toBe('false'); + }); + + it('ignores a refreshed token that is already expired', () => { + renderAuth(); + act(() => { + notifyTokenRefreshed(makeToken(-100), sampleUser); + }); + // Expired token must not authenticate. + expect(screen.getByTestId('auth').textContent).toBe('false'); + }); +}); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 2175dc62..c2e8ef55 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -40,6 +40,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }); const [isLoading, setIsLoading] = useState(true); + // Track the access token in React state so `isAuthenticated` is reactive. + // Reading `tokenStorage` directly during render would not re-render when a + // silent refresh writes a new token, leaving consumers with stale auth state. + const [accessToken, setAccessTokenState] = useState(() => + tokenStorage.getAccessToken(), + ); // #559 — derive isAuthenticated once per token/user change, not on every render const isAuthenticated = useMemo(() => { @@ -50,16 +56,27 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => useEffect(() => { // Check token expiration on app load const checkTokenExpiration = () => { + const currentToken = tokenStorage.getAccessToken(); + + if (currentToken && isTokenExpired(currentToken)) { + // Token is expired, clear auth state const accessToken = tokenStorage.getAccessToken(); if (accessToken && isTokenExpired(accessToken)) { console.warn('Access token expired, clearing authentication state'); tokenStorage.clearTokens(); setUserState(null); + setAccessTokenState(null); localStorage.removeItem('user'); + } else if (!currentToken) { + // No token, clear user state } else if (!accessToken) { setUserState(null); + setAccessTokenState(null); localStorage.removeItem('user'); + } else { + // Valid token — keep reactive state in sync with storage. + setAccessTokenState(currentToken); } setIsLoading(false); @@ -67,6 +84,29 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => checkTokenExpiration(); + // Keep AuthContext in sync when apiClient silently refreshes the access + // token. Updating the reactive token (and user, when the refresh response + // carries one) re-renders consumers so `isAuthenticated` reflects the new + // valid token immediately, instead of waiting for the 5-minute check (#560). + setTokenRefreshCallback((newAccessToken, refreshedUser) => { + if (isTokenExpired(newAccessToken)) { + return; + } + setAccessTokenState(newAccessToken); + if (refreshedUser) { + setUserState(refreshedUser); + } + setIsLoading(false); + }); + + // Set up periodic token expiration check (every 5 minutes) + const interval = setInterval(checkTokenExpiration, 5 * 60 * 1000); + + return () => { + clearInterval(interval); + // Drop the callback so a torn-down provider can't update stale state. + setTokenRefreshCallback(() => {}); + }; const interval = setInterval(checkTokenExpiration, 5 * 60 * 1000); return () => clearInterval(interval); }, []); @@ -87,6 +127,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => localStorage.removeItem('user'); tokenStorage.clearTokens(); + setAccessTokenState(null); }, [user]); const setUser = (nextUser: User | null) => setUserState(nextUser); @@ -94,6 +135,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const clearAuth = () => { setUserState(null); tokenStorage.clearTokens(); + setAccessTokenState(null); localStorage.removeItem('user'); };