import { Middleware } from "@simple-api/core"; import { useAuthStore } from "./auth-store"; import { toast } from "./toast-store"; /** * Decode base64url string (used for JWT payloads) * React Native does not have a global Buffer, so we use a simple decoder. */ function decodeBase64(str: string) { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; let out = ""; str = str.replace(/[-_]/g, (m) => ({ "-": "+", _: "/" })[m] || m); while (str.length % 4) str += "="; for (let i = 0; i < str.length; i += 4) { const a = chars.indexOf(str.charAt(i)); const b = chars.indexOf(str.charAt(i + 1)); const c = chars.indexOf(str.charAt(i + 2)); const d = chars.indexOf(str.charAt(i + 3)); out += String.fromCharCode((a << 2) | (b >> 4)); if (c !== 64) out += String.fromCharCode(((b & 15) << 4) | (c >> 2)); if (d !== 64) out += String.fromCharCode(((c & 3) << 6) | d); } return out; } /** * Extract payload from a JWT token */ function decodeJwtPayload(token: string) { try { const parts = token.split("."); if (parts.length !== 3) return null; const json = decodeBase64(parts[1]); return JSON.parse(json); } catch (e) { return null; } } /** * Check if a token is expired or near expiry */ function isTokenExpired(token: string | null, bufferSeconds = 30) { if (!token) return true; const payload = decodeJwtPayload(token); if (!payload || !payload.exp) return true; const expiresAtMs = payload.exp * 1000; return Date.now() + bufferSeconds * 1000 >= expiresAtMs; } /** * Middleware to inject the authentication token into requests. * Now proactively refreshes token if it's about to expire. */ export const authMiddleware: Middleware = async ({ config, options }, next) => { let { token } = useAuthStore.getState(); const isAuthPath = config.path === "auth/login" || config.path === "auth/register" || config.path === "auth/refresh" || config.path === "auth/google/mobile" || config.path === "auth/login-or-register-owner" || config.path === "auth/forget-pin" || config.path === "auth/forget-pin/verify"; if (token && !isAuthPath) { // Proactive Expiration Check if (isTokenExpired(token)) { console.log( `[AuthProactive] Token near expiry or invalid. Refreshing proactively...`, ); const newToken = await refreshTokens({ path: config.path, type: "PROACTIVE_REFRESH", }); if (newToken) { token = newToken; } } options.headers = { ...options.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json", }; } return await next(options); }; // Global promise to handle concurrent refreshes let refreshPromise: Promise | null = null; /** * Proactively refreshes the access token using the current refresh token. * Coordinates multiple concurrent calls to ensure only one network request is made. */ export async function refreshTokens( config?: any, force = false, ): Promise { const { refreshToken, token, setTokens, logout, isAuthenticated } = useAuthStore.getState(); if (!isAuthenticated || !refreshToken) { return null; } // If not forced, check if we actually need a refresh if (!force && !isTokenExpired(token)) { return token; } // Coordination: If a refresh is already in progress, wait for it if (refreshPromise) { return await refreshPromise; } // No refresh in progress, start one refreshPromise = (async () => { try { const refreshUrl = `https://api.yaltopiaticket.com/auth/refresh`; console.log("[AuthRefresh] Starting network refresh..."); const response = await fetch(refreshUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ refreshToken }), }); if (response.status === 401) { console.error( `[AuthRefresh] 401 on refresh. Session definitively dead. Path: ${config?.path || "Heartbeat"}`, ); toast.show({ type: "error", title: "Session Expired", message: "You have been logged out because your session has expired. Please sign in again.", duration: 9000, }); logout(); return null; } if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( errorData.message || `Refresh failed: ${response.status}`, ); } const data = await response.json(); const newAT = data.accessToken || data.access_token; const newRT = data.refreshToken || data.refresh_token; if (!newAT) throw new Error("No access token in response"); setTokens(newAT, newRT); console.log("[AuthRefresh] Tokens successfully updated."); return newAT; } catch (err: any) { console.error("[AuthRefresh] Refresh failed:", err.message); return null; } finally { refreshPromise = null; } })(); return await refreshPromise; } /** * Middleware to handle token refreshment on 401 Unauthorized errors. * Includes a queue mechanism to handle concurrent 401s. */ export const refreshMiddleware: Middleware = async ( { config, options }, next, ) => { try { return await next(options); } catch (error: any) { const status = error.status || error.statusCode; const { refreshToken } = useAuthStore.getState(); const isAuthPath = config.path?.includes("auth/login") || config.path?.includes("auth/refresh") || config.path?.includes("auth/google/mobile") || config.path?.includes("auth/login-or-register-owner") || config.path?.includes("auth/forget-pin"); // Force refresh on 401 even if we think it's fresh (since server says it's not) if (status === 401 && !isAuthPath) { if (refreshToken) { console.log( `[API Refresh] 401 detected for ${config.path}. Forcing refresh...`, ); try { const accessToken = await refreshTokens(config, true); if (!accessToken) { throw new Error("Failed to obtain fresh access token"); } // Retry the original request console.log(`[API Refresh] Retrying ${config.path} with new token.`); options.headers = { ...options.headers, Authorization: `Bearer ${accessToken}`, }; return await next(options); } catch (refreshError: any) { throw refreshError; } } } throw error; } };