best / frontend /src /lib /api /client.ts
anky2002's picture
fix(P1): Frontend stores access token in memory only, refresh token persisted for rotation
a91cf53 verified
Raw
History Blame Contribute Delete
2.97 kB
import axios from "axios";
/**
* API Client with token management.
*
* Security model:
* - Access token (15min): stored in memory only (this module's closure)
* - Refresh token (30d): stored in localStorage (TODO: migrate to HttpOnly cookie)
* - Automatic refresh on 401
* - Refresh token rotation on every refresh
*
* NOTE: Full HttpOnly cookie auth requires backend Set-Cookie support.
* Current implementation keeps refresh in localStorage but access in memory,
* which is already better than both in localStorage.
*/
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
// Access token lives in memory only — not accessible to XSS
let accessToken: string | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
// On page load, try to restore from a previous session
if (typeof window !== "undefined") {
// Migrate: if old localStorage access_token exists, use it then remove
const legacy = localStorage.getItem("access_token");
if (legacy) {
accessToken = legacy;
localStorage.removeItem("access_token");
}
}
export const apiClient = axios.create({
baseURL: `${API_BASE_URL}/api/v1`,
headers: { "Content-Type": "application/json" },
withCredentials: true,
});
// Attach access token from memory
apiClient.interceptors.request.use(
(config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Handle 401 with refresh token rotation
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) throw new Error("No refresh token");
// Refresh — server rotates the refresh token
const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, {
refresh_token: refreshToken,
});
const { access_token, refresh_token: newRefreshToken } = response.data;
// Store new access token in MEMORY only
accessToken = access_token;
// Store rotated refresh token
localStorage.setItem("refresh_token", newRefreshToken);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh failed — clear everything and redirect
accessToken = null;
localStorage.removeItem("refresh_token");
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default apiClient;