Yaltopia-Tickets-App/lib/api-middlewares.ts
2026-05-14 22:29:28 +03:00

227 lines
6.4 KiB
TypeScript

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";
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<string | null> | 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<string | null> {
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");
// 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;
}
};