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:
Cristhian Zanforlin Lousa 2025-03-11 10:02:26 -03:00 committed by GitHub
commit ff02e3d20e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 137 additions and 22 deletions

View file

@ -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");

View file

@ -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;

View file

@ -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);

View file

@ -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,

View file

@ -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;
};

View file

@ -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),
});
}

View file

@ -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");
});
},
);

View file

@ -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: {