diff --git a/frontend/src/Api.ts b/frontend/src/Api.ts index 856869c..c374fd0 100644 --- a/frontend/src/Api.ts +++ b/frontend/src/Api.ts @@ -1,21 +1,108 @@ -import axios from 'axios' +import axios from "axios"; -const baseURL = (import.meta.env.VITE_BACKEND_URL as string) || 'http://localhost:8080'; +const baseURL = (import.meta.env.VITE_BACKEND_URL as string) || "http://localhost:8080"; +const REFRESH_ENDPOINT = "/api/auth/refresh"; +const ACCESS_TOKEN_COOKIE = "access_token"; +const REFRESH_THRESHOLD_MS = 60_000; const api = axios.create({ - baseURL, - withCredentials: true, - headers: { - 'Content-Type': 'application/json', - }, + baseURL, + withCredentials: true, + headers: { + "Content-Type": "application/json", + }, }); -export const setAuthToken = (token?: string) => { - if (token) { - api.defaults.headers.common['Authorization'] = `Bearer ${token}`; - } else { - delete api.defaults.headers.common['Authorization']; - } +type JwtPayload = { + exp?: number; }; +const getCookieValue = (name: string): string | undefined => { + if (typeof document === "undefined") { + return undefined; + } + + const cookies = document.cookie?.split(";") ?? []; + const cookie = cookies + .map((c) => c.trim()) + .find((c) => c.startsWith(`${name}=`)); + + return cookie ? decodeURIComponent(cookie.split("=")[1]) : undefined; +}; + +const decodeJwtPayload = (token: string): JwtPayload | null => { + try { + const [, payload] = token.split("."); + if (!payload) return null; + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const decoded = typeof atob === "function" ? atob(base64) : ""; + return decoded ? JSON.parse(decoded) : null; + } catch { + return null; + } +}; + +const isTokenExpiringSoon = (token?: string) => { + if (!token) return false; + const payload = decodeJwtPayload(token); + if (!payload?.exp) return false; + + const expiresAt = payload.exp * 1000; + const millisUntilExpiry = expiresAt - Date.now(); + return millisUntilExpiry <= REFRESH_THRESHOLD_MS; +}; + +export const setAuthToken = (token?: string) => { + if (token) { + api.defaults.headers.common["Authorization"] = `Bearer ${token}`; + } else { + delete api.defaults.headers.common["Authorization"]; + } +}; + +let refreshPromise: Promise | null = null; + +const refreshAccessToken = async () => { + if (!refreshPromise) { + refreshPromise = api + .post(REFRESH_ENDPOINT) + .then(() => { + const token = getCookieValue(ACCESS_TOKEN_COOKIE); + setAuthToken(token); + }) + .catch((error) => { + throw error; + }) + .finally(() => { + refreshPromise = null; + }); + } + + return refreshPromise; +}; + +api.interceptors.request.use(async (config) => { + const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT); + + if (!isRefreshCall) { + const token = getCookieValue(ACCESS_TOKEN_COOKIE); + if (isTokenExpiringSoon(token)) { + try { + await refreshAccessToken(); + } catch (error) { + refreshPromise = null; + throw error; + } + } + + const updatedToken = getCookieValue(ACCESS_TOKEN_COOKIE); + if (updatedToken) { + config.headers = config.headers ?? {}; + config.headers.Authorization = `Bearer ${updatedToken}`; + } + } + + return config; +}); + export default api;