fix: Add robust auto-login system with exponential backoff to fix authentication redirects in make command environments (#6848)
* 📝 (frontend): add IS_AUTO_LOGIN constant to handle auto login functionality 🔧 (frontend): update API calls to consider IS_AUTO_LOGIN constant for authentication errors ♻️ (frontend): refactor useGetAutoLogin to handle auto login retries and errors ♻️ (frontend): refactor usePostRefreshAccess to handle auto login retries ♻️ (frontend): refactor request processor to include retry logic with exponential backoff 🔧 (frontend): update Vite config to include LANGFLOW_AUTO_LOGIN environment variable * 🐛 (constants.ts): fix logic in IS_AUTO_LOGIN constant to correctly evaluate the auto login condition based on the environment variable LANGFLOW_AUTO_LOGIN * ✨ (index.tsx): Add support for testMockAutoLogin to simulate auto login for testing purposes 🔧 (constants.ts): Refactor IS_AUTO_LOGIN to handle optional chaining for process.env properties ✨ (auto-login-off.spec.ts): Add test cases to simulate auto login behavior for testing 🔧 (vite.config.mts): Update vite configuration to load environment variables from .env file and handle optional chaining for envLangflow properties
This commit is contained in:
parent
9ade6977c7
commit
ff02e3d20e
8 changed files with 137 additions and 22 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
IS_AUTO_LOGIN,
|
||||
LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV,
|
||||
} from "@/constants/constants";
|
||||
|
|
@ -11,6 +12,14 @@ export const ProtectedRoute = ({ children }) => {
|
|||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const { mutate: mutateRefresh } = useRefreshAccessToken();
|
||||
const autoLogin = useAuthStore((state) => state.autoLogin);
|
||||
const isAutoLoginEnv = IS_AUTO_LOGIN;
|
||||
const testMockAutoLogin = sessionStorage.getItem("testMockAutoLogin");
|
||||
|
||||
const shouldRedirect =
|
||||
!isAuthenticated &&
|
||||
autoLogin !== undefined &&
|
||||
!autoLogin &&
|
||||
!isAutoLoginEnv;
|
||||
|
||||
useEffect(() => {
|
||||
const envRefreshTime = LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV;
|
||||
|
|
@ -30,7 +39,8 @@ export const ProtectedRoute = ({ children }) => {
|
|||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
if (!isAuthenticated && autoLogin !== undefined && !autoLogin) {
|
||||
|
||||
if (shouldRedirect || testMockAutoLogin) {
|
||||
const currentPath = window.location.pathname;
|
||||
const isHomePath = currentPath === "/" || currentPath === "/flows";
|
||||
const isLoginPage = location.pathname.includes("login");
|
||||
|
|
|
|||
|
|
@ -1009,4 +1009,11 @@ export const POLLING_MESSAGES = {
|
|||
STREAMING_NOT_SUPPORTED: "Streaming not supported",
|
||||
} as const;
|
||||
|
||||
export const POLLING_INTERVAL = 100; // milliseconds between polling attempts
|
||||
export const POLLING_INTERVAL = 100;
|
||||
|
||||
export const IS_AUTO_LOGIN =
|
||||
!process?.env?.LANGFLOW_AUTO_LOGIN ||
|
||||
String(process?.env?.LANGFLOW_AUTO_LOGIN)?.toLowerCase() !== "false";
|
||||
|
||||
export const AUTO_LOGIN_RETRY_DELAY = 2000;
|
||||
export const AUTO_LOGIN_MAX_RETRY_DELAY = 60000;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LANGFLOW_ACCESS_TOKEN } from "@/constants/constants";
|
||||
import { IS_AUTO_LOGIN, LANGFLOW_ACCESS_TOKEN } from "@/constants/constants";
|
||||
import { useCustomApiHeaders } from "@/customization/hooks/use-custom-api-headers";
|
||||
import useAuthStore from "@/stores/authStore";
|
||||
import { useUtilityStore } from "@/stores/utilityStore";
|
||||
|
|
@ -65,7 +65,7 @@ function ApiInterceptor() {
|
|||
const isAuthenticationError =
|
||||
error?.response?.status === 403 || error?.response?.status === 401;
|
||||
|
||||
if (isAuthenticationError) {
|
||||
if (isAuthenticationError && !IS_AUTO_LOGIN) {
|
||||
if (autoLogin !== undefined && !autoLogin) {
|
||||
if (error?.config?.url?.includes("github")) {
|
||||
return Promise.reject(error);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import {
|
||||
AUTO_LOGIN_MAX_RETRY_DELAY,
|
||||
AUTO_LOGIN_RETRY_DELAY,
|
||||
IS_AUTO_LOGIN,
|
||||
} from "@/constants/constants";
|
||||
import { AuthContext } from "@/contexts/authContext";
|
||||
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
|
||||
import useAuthStore from "@/stores/authStore";
|
||||
import { AxiosError } from "axios";
|
||||
import { useContext } from "react";
|
||||
import { useContext, useRef } from "react";
|
||||
import { useQueryFunctionType, Users } from "../../../../types/api";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
|
|
@ -27,6 +32,9 @@ export const useGetAutoLogin: useQueryFunctionType<undefined, undefined> = (
|
|||
const navigate = useCustomNavigate();
|
||||
const { mutateAsync: mutationLogout } = useLogout();
|
||||
|
||||
const retryCountRef = useRef(0);
|
||||
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
async function getAutoLoginFn(): Promise<null> {
|
||||
try {
|
||||
const response = await api.get<Users>(`${getURL("AUTOLOGIN")}`);
|
||||
|
|
@ -36,29 +44,61 @@ export const useGetAutoLogin: useQueryFunctionType<undefined, undefined> = (
|
|||
login(user["access_token"], "auto");
|
||||
setUserData(user);
|
||||
setAutoLogin(true);
|
||||
resetTimer();
|
||||
}
|
||||
} catch (e) {
|
||||
const error = e as AxiosError;
|
||||
if (error.name !== "CanceledError") {
|
||||
setAutoLogin(false);
|
||||
if (!isLoginPage) {
|
||||
if (!isAuthenticated) {
|
||||
await mutationLogout();
|
||||
const currentPath = window.location.pathname;
|
||||
const isHomePath = currentPath === "/" || currentPath === "/flows";
|
||||
navigate(
|
||||
"/login" +
|
||||
(!isHomePath && !isLoginPage ? "?redirect=" + currentPath : ""),
|
||||
);
|
||||
} else {
|
||||
getUser();
|
||||
}
|
||||
await handleAutoLoginError();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const resetTimer = () => {
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoLoginError = async () => {
|
||||
const manualLoginNotAuthenticated = !isAuthenticated && !IS_AUTO_LOGIN;
|
||||
const autoLoginNotAuthenticated = !isAuthenticated && IS_AUTO_LOGIN;
|
||||
|
||||
if (manualLoginNotAuthenticated) {
|
||||
await mutationLogout();
|
||||
const currentPath = window.location.pathname;
|
||||
const isHomePath = currentPath === "/" || currentPath === "/flows";
|
||||
navigate(
|
||||
"/login" +
|
||||
(!isHomePath && !isLoginPage ? "?redirect=" + currentPath : ""),
|
||||
);
|
||||
} else if (autoLoginNotAuthenticated) {
|
||||
const retryCount = retryCountRef.current;
|
||||
const delay = Math.min(
|
||||
AUTO_LOGIN_RETRY_DELAY * Math.pow(2, retryCount),
|
||||
AUTO_LOGIN_MAX_RETRY_DELAY,
|
||||
);
|
||||
|
||||
retryCountRef.current += 1;
|
||||
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
}
|
||||
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
getAutoLoginFn();
|
||||
}, delay);
|
||||
} else {
|
||||
getUser();
|
||||
}
|
||||
};
|
||||
|
||||
const queryResult = query(["useGetAutoLogin"], getAutoLoginFn, {
|
||||
refetchOnWindowFocus: false,
|
||||
...options,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LANGFLOW_REFRESH_TOKEN } from "@/constants/constants";
|
||||
import { IS_AUTO_LOGIN, LANGFLOW_REFRESH_TOKEN } from "@/constants/constants";
|
||||
import { useMutationFunctionType } from "@/types/api";
|
||||
import { Cookies } from "react-cookie";
|
||||
import { api } from "../../api";
|
||||
|
|
@ -25,7 +25,10 @@ export const useRefreshAccessToken: useMutationFunctionType<
|
|||
return res.data;
|
||||
}
|
||||
|
||||
const mutation = mutate(["useRefreshAccessToken"], refreshAccess, options);
|
||||
const mutation = mutate(["useRefreshAccessToken"], refreshAccess, {
|
||||
...options,
|
||||
retry: IS_AUTO_LOGIN ? 0 : 3,
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export function UseRequestProcessor(): {
|
|||
return useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
retry: 5,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
|
@ -40,6 +42,8 @@ export function UseRequestProcessor(): {
|
|||
options.onSettled && options.onSettled(data, error, variables, context);
|
||||
},
|
||||
...options,
|
||||
retry: options.retry ?? 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,20 @@ test(
|
|||
});
|
||||
});
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.process = window.process || {};
|
||||
|
||||
const newEnv = { ...window.process.env, LANGFLOW_AUTO_LOGIN: "false" };
|
||||
|
||||
Object.defineProperty(window.process, "env", {
|
||||
value: newEnv,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sessionStorage.setItem("testMockAutoLogin", "true");
|
||||
});
|
||||
|
||||
const randomName = Math.random().toString(36).substring(5);
|
||||
const randomPassword = Math.random().toString(36).substring(5);
|
||||
const secondRandomName = Math.random().toString(36).substring(5);
|
||||
|
|
@ -28,6 +42,10 @@ test(
|
|||
await page.getByPlaceholder("Username").fill("langflow");
|
||||
await page.getByPlaceholder("Password").fill("langflow");
|
||||
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.removeItem("testMockAutoLogin");
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await page.waitForSelector('[data-testid="mainpage_title"]', {
|
||||
|
|
@ -161,6 +179,10 @@ test(
|
|||
|
||||
await page.getByTestId("user-profile-settings").click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.setItem("testMockAutoLogin", "true");
|
||||
});
|
||||
|
||||
await page.getByText("Logout", { exact: true }).click();
|
||||
|
||||
await page.waitForSelector("text=sign in to langflow", { timeout: 30000 });
|
||||
|
|
@ -174,6 +196,10 @@ test(
|
|||
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.removeItem("testMockAutoLogin");
|
||||
});
|
||||
|
||||
await page.waitForSelector('[id="new-project-btn"]', {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
|
@ -231,6 +257,10 @@ test(
|
|||
|
||||
await page.getByTestId("user-profile-settings").click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.setItem("testMockAutoLogin", "true");
|
||||
});
|
||||
|
||||
await page.getByText("Logout", { exact: true }).click();
|
||||
|
||||
await page.waitForSelector("text=sign in to langflow", { timeout: 30000 });
|
||||
|
|
@ -238,6 +268,10 @@ test(
|
|||
await page.getByPlaceholder("Username").fill("langflow");
|
||||
await page.getByPlaceholder("Password").fill("langflow");
|
||||
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.removeItem("testMockAutoLogin");
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await page.waitForSelector('[data-testid="mainpage_title"]', {
|
||||
|
|
@ -255,5 +289,9 @@ test(
|
|||
await expect(page.getByText(randomFlowName, { exact: true })).toBeVisible({
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.removeItem("testMockAutoLogin");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import react from "@vitejs/plugin-react-swc";
|
||||
import * as dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
|
@ -12,6 +14,12 @@ import {
|
|||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
const envLangflowResult = dotenv.config({
|
||||
path: path.resolve(__dirname, "../../.env"),
|
||||
});
|
||||
|
||||
const envLangflow = envLangflowResult.parsed || {};
|
||||
|
||||
const apiRoutes = API_ROUTES || ["^/api/v1/", "/health"];
|
||||
|
||||
const target =
|
||||
|
|
@ -35,11 +43,16 @@ export default defineConfig(({ mode }) => {
|
|||
outDir: "build",
|
||||
},
|
||||
define: {
|
||||
"process.env.BACKEND_URL": JSON.stringify(env.BACKEND_URL),
|
||||
"process.env.ACCESS_TOKEN_EXPIRE_SECONDS": JSON.stringify(
|
||||
env.ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
"process.env.BACKEND_URL": JSON.stringify(
|
||||
envLangflow.BACKEND_URL ?? "http://127.0.0.1:7860",
|
||||
),
|
||||
"process.env.ACCESS_TOKEN_EXPIRE_SECONDS": JSON.stringify(
|
||||
envLangflow.ACCESS_TOKEN_EXPIRE_SECONDS ?? 60,
|
||||
),
|
||||
"process.env.CI": JSON.stringify(envLangflow.CI ?? false),
|
||||
"process.env.LANGFLOW_AUTO_LOGIN": JSON.stringify(
|
||||
envLangflow.LANGFLOW_AUTO_LOGIN ?? true,
|
||||
),
|
||||
"process.env.CI": JSON.stringify(env.CI),
|
||||
},
|
||||
plugins: [react(), svgr(), tsconfigPaths()],
|
||||
server: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue