Authentication and Session Management
Authentication and Session Management
Section titled “Authentication and Session Management”Problem and Rationale
Section titled “Problem and Rationale”RednGift requires secure user authentication without password friction. The platform uses magic link authentication (passwordless login via email) combined with JWT tokens for session management.
Context and Constraints
Section titled “Context and Constraints”- User experience: Minimize signup friction; users should authenticate quickly
- Security: Prevent unauthorized access to groups and wishlists
- Session persistence: Users shouldn’t be logged out on page refresh
- Multi-device: Support login from different devices simultaneously
- Backend integration: Stateless authentication compatible with microservices
Why JWT?
Section titled “Why JWT?”- Stateless: No server-side session storage required
- Scalable: Works across distributed backend services
- Portable: Token contains user identity and can be verified independently
Design
Section titled “Design”Authentication Flow
Section titled “Authentication Flow”User Request Login │ ▼Enter Email ──────────────────────────────────┐ │ │ ▼ ▼Backend sends magic link ──────────────► Email inbox │ │ │ ▼ │ User clicks link │ │ ▼ ▼User arrives at /auth/magic/:token ◄──────────┘ │ ▼Frontend calls authHandlers.loginWithMagic(token) │ ▼Backend validates token & returns JWT │ ▼Frontend stores JWT in localStorage │ ▼Fetch user info (/users/me) │ ▼Update AuthContext.user │ ▼Redirect to home pageKey Types
Section titled “Key Types”export type AuthUserType = null | Record<string, any>;
export type AuthStateType = { loading: boolean; // true during initialization or login user: AuthUserType; // null if unauthenticated};
export type JWTContextType = { user: AuthUserType; method: string; // Always 'jwt' loading: boolean; authenticated: boolean; // Computed: user !== null unauthenticated: boolean; // Computed: user === null login: (magicToken: string) => Promise<void>; register: (email: string, password: string, firstName: string, lastName: string) => Promise<void>; logout: () => Promise<void>; refreshUser: () => Promise<void>; // Re-fetch user data from backend setAuthToken: (token: string) => Promise<void>;};Session Storage
Section titled “Session Storage”Token location: localStorage.getItem('accessToken')
Why localStorage?
- Persists across browser tabs and page refreshes
- Accessible to axios interceptors for automatic header injection
- Trade-off: Vulnerable to XSS attacks (mitigated by React’s default escaping and CSP)
Why not cookies?
- Backend is stateless and doesn’t expect cookies
- Simplifies CORS configuration
- Client-side single-page app architecture
Lifecycle and Data Flow
Section titled “Lifecycle and Data Flow”1. Initial Load (Page Refresh)
Section titled “1. Initial Load (Page Refresh)”App mounts │ ▼AuthProvider initializes │ ▼Read localStorage.accessToken │ ├─ Token exists ──────────┐ │ │ │ ▼ │ setSession(token) │ │ │ ▼ │ userHandlers.userInfo() │ │ │ ├─ Success ──────┐ │ │ │ │ │ ▼ │ │ dispatch(INITIAL, user) │ │ │ │ │ ▼ │ │ loading = false │ │ │ │ │ ▼ │ │ Render app (authenticated) │ │ │ └─ Error ────────┐ │ │ │ ▼ │ Clear token │ │ │ ▼ │ dispatch(INITIAL, null) │ │ │ ▼ │ Redirect to /auth/login │ └─ No token ──────────┐ │ ▼ dispatch(INITIAL, null) │ ▼ loading = false │ ▼ Render app (unauthenticated)2. Magic Link Login
Section titled “2. Magic Link Login”User clicks magic link → Browser opens /auth/magic/:token → Page component extracts token from URL → Calls login(token):
// src/auth/context/jwt/auth-provider.tsx (simplified)const login = useCallback(async (magikToken: string) => { const response = await authHandlers.loginWithMagic(magikToken); if (response.success) { await setAuthToken(response.token); localStorage.setItem('isUserReturning', 'true'); }}, [setAuthToken]);
const setAuthToken = useCallback(async (token: string) => { setSession(token); // Store in localStorage and set axios default header const userResponse = await userHandlers.userInfo(); dispatch({ type: Types.LOGIN, payload: { user: { ...userResponse, token } }, });}, []);3. Logout
Section titled “3. Logout”const logout = useCallback(async () => { setSession(null); // Clear localStorage and axios header dispatch({ type: Types.LOGOUT });}, []);After logout:
AuthContext.userbecomesnullAuthGuarddetects unauthenticated state and redirects to/auth/login- WebSocket connection is closed (via
WebSocketProvider)
4. Token Attachment to API Requests
Section titled “4. Token Attachment to API Requests”// src/utils/axios.ts (simplified)import axios from 'axios';
const axiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_HOST_API,});
// Request interceptor: attach token to all outgoing requestsaxiosInstance.interceptors.request.use( (config) => { const accessToken = localStorage.getItem('accessToken'); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }, (error) => Promise.reject(error));
// Response interceptor: handle 401 UnauthorizedaxiosInstance.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { // Token expired or invalid localStorage.removeItem('accessToken'); window.location.href = '/auth/login'; } return Promise.reject(error); });Implementation Notes
Section titled “Implementation Notes”Route Protection with AuthGuard
Section titled “Route Protection with AuthGuard”// src/auth/guard/auth-guard.tsx (simplified)export function AuthGuard({ children }: Props) { const { authenticated, loading } = useAuthContext(); const router = useRouter();
useEffect(() => { if (!loading && !authenticated) { router.replace('/auth/login'); } }, [authenticated, loading, router]);
if (loading) { return <LoadingScreen />; }
if (!authenticated) { return null; // Will redirect in useEffect }
return <>{children}</>;}Usage: Wrap authenticated layouts in AuthGuard:
// src/app/(home)/layout.tsxexport default function Layout({ children }: Props) { return ( <AuthGuard> <WebSocketProvider> <HomeLayout>{children}</HomeLayout> </WebSocketProvider> </AuthGuard> );}GuestGuard (Inverse)
Section titled “GuestGuard (Inverse)”GuestGuard redirects authenticated users away from auth pages (e.g., /auth/login). If user is already logged in, redirect to home.
Patterns and Anti-patterns
Section titled “Patterns and Anti-patterns”✅ DO:
- Always check
loadingstate before rendering protected content - Use
refreshUser()after profile updates to syncAuthContext - Capture auth errors with Sentry for debugging token issues
❌ DON’T:
- Don’t store tokens in Redux (localStorage is the single source of truth)
- Don’t manually set
axios.defaults.headersoutsidesetSession() - Don’t call API handlers before
AuthProviderinitialization completes
Error Handling
Section titled “Error Handling”- Invalid token: Backend returns 401 → axios interceptor clears token and redirects to login
- Network error during login:
login()throws error → UI displays toast with retry option - Token expiration: No automatic refresh; user must re-authenticate
Assumption: Backend tokens have a long expiration (e.g., 30 days). Refresh token flow not implemented.
Performance Considerations
Section titled “Performance Considerations”- Initial load:
AuthProviderinitialization blocks rendering until token validation completes - Optimization: Use
React.memo()on components that consumeAuthContextto prevent unnecessary re-renders - Lazy loading: Auth pages are not code-split; consider dynamic imports if bundle size grows
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”// Test AuthProvider reducerdescribe('AuthProvider reducer', () => { it('should set user on INITIAL action', () => { const state = reducer(initialState, { type: Types.INITIAL, payload: { user: { id: 1, name: 'John' } }, }); expect(state.user).toEqual({ id: 1, name: 'John' }); expect(state.loading).toBe(false); });
it('should clear user on LOGOUT action', () => { const state = reducer( { user: { id: 1 }, loading: false }, { type: Types.LOGOUT } ); expect(state.user).toBeNull(); });});Integration Tests
Section titled “Integration Tests”// Test login flowit('should authenticate user with valid magic token', async () => { const mockToken = 'valid-magic-token'; const mockUser = { id: 1, email: 'test@example.com' };
jest.spyOn(authHandlers, 'loginWithMagic').mockResolvedValue({ success: true, token: 'jwt-token', }); jest.spyOn(userHandlers, 'userInfo').mockResolvedValue(mockUser);
const { result } = renderHook(() => useAuthContext(), { wrapper: AuthProvider, });
await act(async () => { await result.current.login(mockToken); });
expect(result.current.authenticated).toBe(true); expect(result.current.user).toMatchObject(mockUser); expect(localStorage.getItem('accessToken')).toBe('jwt-token');});E2E Tests
Section titled “E2E Tests”- Test magic link login flow from email to home page
- Test logout clears session and redirects to login
- Test 401 response auto-logout behavior
Extending This Concept
Section titled “Extending This Concept”Adding OAuth Providers (Google, Facebook)
Section titled “Adding OAuth Providers (Google, Facebook)”- Extend
JWTContextTypewith new methods:
export type JWTContextType = { // ... existing methods loginWithGoogle: () => Promise<void>; loginWithFacebook: () => Promise<void>;};- Implement OAuth flow in
AuthProvider:
const loginWithGoogle = useCallback(async () => { // Redirect to backend OAuth endpoint window.location.href = `${HOST_API}/auth/google`;}, []);- Handle OAuth callback in
/auth/callbackpage:
// Extract token from query params and call setAuthTokenconst { token } = useSearchParams();useEffect(() => { if (token) setAuthToken(token);}, [token]);Adding Token Refresh
Section titled “Adding Token Refresh”- Store refresh token in
httpOnlycookie (backend change required) - Add refresh endpoint handler:
const refreshToken = async () => { const res = await axios.post('/auth/refresh', {}, { withCredentials: true }); return res.data.accessToken;};- Update axios interceptor to retry 401 requests after refresh:
axiosInstance.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401 && !error.config._retry) { error.config._retry = true; const newToken = await refreshToken(); setSession(newToken); return axiosInstance(error.config); } return Promise.reject(error); });Further Reading
Section titled “Further Reading”- API Layer and Data Fetching - How authenticated requests are made
- Error Handling and Monitoring - Sentry integration for auth errors
- Routing and Navigation - Route guards and navigation patterns
- Group Invitations - How auth integrates with invitations