diff --git a/backend/server.js b/backend/server.js index 566f6e4..977a8d9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -38,7 +38,9 @@ const app = express(); const PORT = process.env.PORT || 5000; // ── Rate Limiters ───────────────────────────── -const authLimiter = rateLimit({ +// Strict limit for login/OAuth attempts only — must NOT cover /api/auth/me, +// which the frontend calls on every page load. +const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, @@ -46,6 +48,14 @@ const authLimiter = rateLimit({ message: { status: 'error', message: 'Too many requests, please try again later.' }, }); +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false, + message: { status: 'error', message: 'Too many requests, please try again later.' }, +}); + const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 10, @@ -103,6 +113,7 @@ app.get('/api/health', (req, res) => { }); // ── API Routes ─────────────────────────────── +app.use('/api/auth/google', loginLimiter); // covers /google and /google/callback app.use('/api/auth', authLimiter, authRoutes); app.use('/api/courses', courseRoutes); app.use('/api/enrollments', enrollmentRoutes); diff --git a/frontend/src/store/authStore.js b/frontend/src/store/authStore.js index ea0cd64..6536e8a 100644 --- a/frontend/src/store/authStore.js +++ b/frontend/src/store/authStore.js @@ -31,6 +31,20 @@ const clearAuth = (set) => { set({ user: null, role: null, isAuthenticated: false, isLoading: false }); }; +/** + * Handle a failed /me fetch. Transient failures (rate limiting, server errors, + * network) keep the stored token so the session survives the next page load; + * anything else means the backend rejected the request, so discard it. + */ +const handleAuthError = (set, error) => { + const isTransient = !error?.status || error.status === 429 || error.status >= 500; + if (isTransient) { + set({ user: null, role: null, isAuthenticated: false, isLoading: false }); + } else { + clearAuth(set); + } +}; + const useAuthStore = create((set) => ({ user: null, role: null, @@ -51,8 +65,8 @@ const useAuthStore = create((set) => ({ try { await loadCurrentUser(set); - } catch { - clearAuth(set); + } catch (error) { + handleAuthError(set, error); } }, @@ -63,8 +77,8 @@ const useAuthStore = create((set) => ({ setToken(token); try { return await loadCurrentUser(set); - } catch { - clearAuth(set); + } catch (error) { + handleAuthError(set, error); return null; } }, diff --git a/frontend/src/tests/authStore.test.jsx b/frontend/src/tests/authStore.test.jsx index cd96dbf..2abe718 100644 --- a/frontend/src/tests/authStore.test.jsx +++ b/frontend/src/tests/authStore.test.jsx @@ -109,6 +109,22 @@ describe('useAuthStore Zustand Store', () => { expect(localStorage.getItem('studylabs_token')).toBeNull(); }); + test('should keep the stored token when /me fails with a transient error (429)', async () => { + localStorage.setItem('studylabs_token', 'still_valid_token'); + + global.fetch.mockImplementationOnce(() => mockFetchErrorResponse('Too many requests', 429)); + + await act(async () => { + await useAuthStore.getState().initialize(); + }); + + const state = useAuthStore.getState(); + expect(state.isLoading).toBe(false); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + expect(localStorage.getItem('studylabs_token')).toBe('still_valid_token'); + }); + test('should finish loading and remain unauthenticated when no token exists during initialization', async () => { await act(async () => { await useAuthStore.getState().initialize();