Loading…
Loading…
Both tokens stored in sessionStorage. Similar to localStorage but clears when tab/browser closes. Provides tab isolation but still vulnerable to XSS.
While sessionStorage provides tab isolation, it is still accessible to JavaScript. If an XSS attack occurs in the same tab, tokens can be stolen. The only advantage over localStorage is that tokens are limited to the current tab.
// localStorage vs sessionStorage
// localStorage: Persists forever (until explicitly cleared)
localStorage.setItem('key', 'value');
// ❌ Still there after browser restart
// ❌ Still there after tab close
// ✅ Shared across all tabs
// sessionStorage: Clears when tab closes
sessionStorage.setItem('key', 'value');
// ✅ Gone after tab/browser close
// ❌ Still there on page refresh (in same tab)
// ❌ Separate per tab (no cross-tab sync)// Tab Isolation: Each tab has its own sessionStorage
// Tab 1: User logs in
sessionStorage.setItem('token', 'abc123');
// Tab 1 has token: 'abc123'
// Tab 2: Opened later (no token!)
sessionStorage.getItem('token'); // null
// Tab 1: Refreshes (token persists)
sessionStorage.getItem('token'); // 'abc123'
// Tab 1: Closes (token gone!)
// Tab 2: Still no token
// This is DIFFERENT from localStorage:
// localStorage: All tabs share the same storage
// sessionStorage: Each tab has isolated storage// src/lib/token-storage.ts
const STORAGE_KEYS = {
ACCESS_TOKEN: 'auth_access_token',
REFRESH_TOKEN: 'auth_refresh_token',
};
export const sessionStorageStrategy = {
setTokens: (accessToken: string, refreshToken?: string) => {
// ⚠️ sessionStorage clears when tab/browser closes
sessionStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
if (refreshToken) {
sessionStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
}
},
getAccessToken: () => sessionStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN),
getRefreshToken: () => sessionStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN),
clearTokens: () => {
sessionStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
sessionStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
},
};// src/lib/api-client.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { sessionStorageStrategy } from './token-storage';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
headers: { 'Content-Type': 'application/json' },
// ⚠️ Do NOT use withCredentials for sessionStorage strategy
});
// Request interceptor: Add access token from sessionStorage
apiClient.interceptors.request.use(
(config) => {
const token = sessionStorageStrategy.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor: Auto-refresh on 401
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: any) => void;
}> = [];
const processQueue = (error: any, token: string | null) => {
failedQueue.forEach((prom) => {
error ? prom.reject(error) : prom.resolve(token!);
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: string };
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
});
}
originalRequest._retry = 'true';
isRefreshing = true;
try {
const refreshToken = sessionStorageStrategy.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token');
}
const res = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
{ refreshToken }
);
const { accessToken, refreshToken: newRefreshToken } = res.data;
sessionStorageStrategy.setTokens(accessToken, newRefreshToken);
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
sessionStorageStrategy.clearTokens();
window.location.href = '/auth/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);// src/providers/auth-provider.tsx
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { sessionStorageStrategy } from '@/lib/token-storage';
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check if user is logged in (token in sessionStorage)
checkAuth();
}, []);
const checkAuth = async () => {
try {
const token = sessionStorageStrategy.getAccessToken();
if (!token) {
setIsLoading(false);
return;
}
// Token exists, fetch user data
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
} else {
// Token expired, clear it
sessionStorageStrategy.clearTokens();
}
} catch (error) {
console.error('Auth check failed:', error);
} finally {
setIsLoading(false);
}
};
const login = async (email: string, password: string) => {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Login failed');
const data = await res.json();
// ⚠️ Store both tokens in sessionStorage
sessionStorageStrategy.setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
};
const logout = async () => {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/logout`, {
method: 'POST',
});
sessionStorageStrategy.clearTokens();
setUser(null);
};
return (
<AuthContext.Provider value={{
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
}}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};