Loading…
Loading…
Access token stored in React state (in-memory), refresh token in HttpOnly cookie. Provides XSS protection for the refresh token while allowing client-side token access.
User submits credentials. Server validates and generates both tokens.
Refresh token in HttpOnly cookie, access token in response body.
Access token stored in React state or module variable (in-memory).
Access token in Authorization header, refresh cookie auto-sent.
// src/lib/token-storage.ts
// In-memory storage for access token
let inMemoryToken: string | null = null;
export const memoryCookieStorage = {
setTokens: (accessToken: string) => {
// Access token stored in memory (lost on page refresh)
inMemoryToken = accessToken;
// Refresh token is in HttpOnly cookie (server-managed)
},
getAccessToken: () => inMemoryToken,
getRefreshToken: () => null, // In HttpOnly cookie, not accessible
clearTokens: () => {
inMemoryToken = null;
},
};// src/providers/auth-provider.tsx
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authApi } from '@/api/auth/auth.api';
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
getAccessToken: () => Promise<string | null>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// In-memory token storage
let inMemoryToken: string | null = null;
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Check if user is logged in on mount
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
// Try to refresh token (cookie is auto-sent)
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include', // Send refresh cookie
});
if (res.ok) {
const { accessToken } = await res.json();
inMemoryToken = accessToken;
// Get user info
const userRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (userRes.ok) {
const userData = await userRes.json();
setUser(userData);
}
}
} 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 }),
credentials: 'include',
});
if (!res.ok) throw new Error('Login failed');
const data = await res.json();
// ⚠️ Store access token in memory
inMemoryToken = data.accessToken;
setUser(data.user);
};
const logout = async () => {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/logout`, {
method: 'POST',
credentials: 'include',
});
inMemoryToken = null;
setUser(null);
};
// Called before API requests to ensure valid token
const getAccessToken = async (): Promise<string | null> => {
// If we have a token in memory, return it
if (inMemoryToken) return inMemoryToken;
// Otherwise try to refresh
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
const { accessToken } = await res.json();
inMemoryToken = accessToken;
return accessToken;
}
} catch (error) {
console.error('Token refresh failed:', error);
}
return null;
};
return (
<AuthContext.Provider value={{
user,
accessToken: inMemoryToken,
isAuthenticated: !!user,
isLoading,
login,
logout,
getAccessToken,
}}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};// src/lib/api-client.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
// In-memory token reference (same as in auth provider)
let inMemoryToken: string | null = null;
export const setAccessToken = (token: string | null) => {
inMemoryToken = token;
};
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // Send refresh cookie
});
// Request interceptor: Add access token from memory
apiClient.interceptors.request.use(
(config) => {
if (inMemoryToken) {
config.headers.Authorization = `Bearer ${inMemoryToken}`;
}
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?: boolean };
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 {
// Refresh token is in HttpOnly cookie (auto-sent)
const res = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
{},
{ withCredentials: true }
);
const { accessToken } = res.data;
inMemoryToken = accessToken;
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
inMemoryToken = null;
window.location.href = '/auth/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);// Usage comparison: With vs Without this pattern
// ❌ WITHOUT: Token in localStorage (XSS vulnerable)
const token = localStorage.getItem('access_token');
// Any script can read this!
// ✅ WITH: Token in memory (XSS safe)
const token = inMemoryToken;
// Only accessible within this module's scope
// Cannot be stolen by injected scripts