222 lines
6.2 KiB
TypeScript
222 lines
6.2 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";
|
|
|
|
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}`,
|
|
};
|
|
}
|
|
|
|
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");
|
|
|
|
// 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;
|
|
}
|
|
};
|