mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
135 lines
3.6 KiB
TypeScript
135 lines
3.6 KiB
TypeScript
import axios from "axios";
|
|
|
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
|
import {
|
|
emitApiError,
|
|
extractApiErrorPayload,
|
|
} from "@/components/errors/ApiErrorModal";
|
|
import { captureApiError } from "@/lib/posthog";
|
|
import {
|
|
AUTH_TOKEN_COOKIE,
|
|
REFRESH_TOKEN_COOKIE,
|
|
clearSessionCookies,
|
|
getCookie,
|
|
setCookie,
|
|
} from "./cookies";
|
|
import type { AuthTokens } from "./types";
|
|
|
|
type RetriableRequest = {
|
|
_retry?: boolean;
|
|
headers?: Record<string, string>;
|
|
url?: string;
|
|
};
|
|
|
|
const api = axios.create({
|
|
baseURL: `${API_BASE_URL}/api`,
|
|
withCredentials: true,
|
|
});
|
|
|
|
let refreshPromise: Promise<AuthTokens> | null = null;
|
|
|
|
const applyTokens = ({ token, refreshToken }: AuthTokens) => {
|
|
setCookie(AUTH_TOKEN_COOKIE, token);
|
|
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
|
|
};
|
|
|
|
/**
|
|
* Single-flight token refresh: concurrent callers (the 401 interceptor and
|
|
* the proactive scheduler) share one in-flight request so the refresh token
|
|
* is only rotated once. Throws if no refresh token is stored or the server
|
|
* rejects it — callers decide how to end the session.
|
|
*/
|
|
const refreshSessionTokens = async (): Promise<AuthTokens> => {
|
|
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
|
if (!refreshToken) {
|
|
throw new Error("missing refresh token");
|
|
}
|
|
|
|
refreshPromise ??= api
|
|
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
|
.then((response) => response.data)
|
|
.finally(() => {
|
|
refreshPromise = null;
|
|
});
|
|
|
|
const tokens = await refreshPromise;
|
|
applyTokens(tokens);
|
|
return tokens;
|
|
};
|
|
|
|
api.interceptors.request.use((config) => {
|
|
const token = getCookie(AUTH_TOKEN_COOKIE);
|
|
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
|
|
return config;
|
|
});
|
|
|
|
api.interceptors.response.use(
|
|
(response) => {
|
|
if (
|
|
response.data &&
|
|
typeof response.data === "object" &&
|
|
"success" in response.data &&
|
|
"data" in response.data
|
|
) {
|
|
response.data = response.data.data;
|
|
}
|
|
|
|
return response;
|
|
},
|
|
async (error) => {
|
|
const originalRequest = error.config as RetriableRequest | undefined;
|
|
|
|
// Report the failure to PostHog. Hooked here rather than inside
|
|
// `emitApiError`, which stays silent on suppressed paths (warehouse /
|
|
// mile / onboarding) — those failures still need reporting.
|
|
// 401s are skipped: an expired session is refreshed below, not a defect.
|
|
if (!error.response || error.response.status !== 401) {
|
|
captureApiError(error);
|
|
}
|
|
|
|
if (
|
|
error.response?.status !== 401 ||
|
|
!originalRequest ||
|
|
originalRequest._retry ||
|
|
originalRequest.url?.includes("/auth/login") ||
|
|
originalRequest.url?.includes("/auth/mfa-verify") ||
|
|
originalRequest.url?.includes("/auth/refresh-token")
|
|
) {
|
|
// Surface the server's actual error message in the global error modal
|
|
// (401s are handled by the session-refresh flow, so skip them).
|
|
if (error.response && error.response.status !== 401) {
|
|
const payload = extractApiErrorPayload(error);
|
|
if (payload) emitApiError(payload);
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
|
|
clearSessionCookies();
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
|
|
try {
|
|
const tokens = await refreshSessionTokens();
|
|
originalRequest.headers = {
|
|
...originalRequest.headers,
|
|
Authorization: `Bearer ${tokens.token}`,
|
|
};
|
|
|
|
return api(originalRequest);
|
|
} catch (refreshError) {
|
|
clearSessionCookies();
|
|
window.location.replace("/auth");
|
|
return Promise.reject(refreshError);
|
|
}
|
|
},
|
|
);
|
|
|
|
export { api, applyTokens, refreshSessionTokens };
|