This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 25ef835185c UI: Preserve proxied URL on login redirect (#66690)
25ef835185c is described below
commit 25ef835185cb268a800ce117ecb88c6eea81ce77
Author: Sai Teja Desu <[email protected]>
AuthorDate: Wed May 13 07:40:04 2026 -0700
UI: Preserve proxied URL on login redirect (#66690)
When the UI is reached through a proxy (Gitpod, Codespaces, ngrok,
reverse proxies), the auth-failure interceptor sent the API server's
absolute URL as the 'next' parameter, so post-login redirects went to
e.g. http://localhost:29091 instead of the URL the browser is on.
Send a same-origin path+search+hash instead, so the browser stays on
whatever origin it is currently using.
Add regression coverage for proxied subpaths so redirects also preserve
base paths such as /team-a/.
closes: #46533
---
airflow-core/src/airflow/ui/src/main.tsx | 4 +-
.../src/airflow/ui/src/utils/links.test.ts | 44 ++++++++++++++++++++++
airflow-core/src/airflow/ui/src/utils/links.ts | 7 ++++
3 files changed, 53 insertions(+), 2 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/main.tsx
b/airflow-core/src/airflow/ui/src/main.tsx
index 9fb55690e4c..4daeb12b19e 100644
--- a/airflow-core/src/airflow/ui/src/main.tsx
+++ b/airflow-core/src/airflow/ui/src/main.tsx
@@ -34,7 +34,7 @@ import { ChakraCustomProvider } from
"src/context/ChakraCustomProvider";
import { ColorModeProvider } from "src/context/colorMode";
import { TimezoneProvider } from "src/context/timezone";
import { router } from "src/router";
-import { getRedirectPath } from "src/utils/links.ts";
+import { getNextHref, getRedirectPath } from "src/utils/links.ts";
import i18n from "./i18n/config";
import { client } from "./queryClient";
@@ -75,7 +75,7 @@ axios.interceptors.response.use(
) {
const params = new URLSearchParams();
- params.set("next", globalThis.location.href);
+ params.set("next", getNextHref(globalThis.location));
const loginPath = getRedirectPath("api/v2/auth/login");
globalThis.location.replace(`${loginPath}?${params.toString()}`);
diff --git a/airflow-core/src/airflow/ui/src/utils/links.test.ts
b/airflow-core/src/airflow/ui/src/utils/links.test.ts
index a23711ded54..5afe9f0d4cc 100644
--- a/airflow-core/src/airflow/ui/src/utils/links.test.ts
+++ b/airflow-core/src/airflow/ui/src/utils/links.test.ts
@@ -22,6 +22,7 @@ import type { TaskInstanceResponse } from
"openapi/requests/types.gen";
import {
buildTaskInstanceUrl,
+ getNextHref,
getSafeExternalUrl,
getTaskInstanceAdditionalPath,
getTaskInstanceLink,
@@ -290,6 +291,49 @@ describe("buildTaskInstanceUrl", () => {
});
});
+describe("getNextHref", () => {
+ // Regression tests for https://github.com/apache/airflow/issues/46533 — the
+ // "next" parameter sent to the login redirect must be a same-origin relative
+ // URL so that proxied deployments (e.g. Gitpod) don't bounce the browser
+ // back to the API server's reported origin (e.g. http://localhost:29091).
+ it.each([
+ {
+ description: "preserves pathname only",
+ expected: "/dags/my_dag",
+ input: { hash: "", pathname: "/dags/my_dag", search: "" },
+ },
+ {
+ description: "preserves pathname and search",
+ expected: "/dags/my_dag?tab=graph",
+ input: { hash: "", pathname: "/dags/my_dag", search: "?tab=graph" },
+ },
+ {
+ description: "preserves pathname, search, and hash",
+ expected: "/dags/my_dag?tab=graph#section",
+ input: { hash: "#section", pathname: "/dags/my_dag", search:
"?tab=graph" },
+ },
+ {
+ description: "preserves proxied base path, search, and hash",
+ expected: "/team-a/dags/my_dag?tab=graph#section",
+ input: { hash: "#section", pathname: "/team-a/dags/my_dag", search:
"?tab=graph" },
+ },
+ {
+ description: "handles root path",
+ expected: "/",
+ input: { hash: "", pathname: "/", search: "" },
+ },
+ ])("$description", ({ expected, input }) => {
+ expect(getNextHref(input)).toBe(expected);
+ });
+
+ it("does not include the origin (no http(s) prefix)", () => {
+ const result = getNextHref({ hash: "", pathname: "/dags/my_dag", search:
"" });
+
+ expect(result.startsWith("http://")).toBe(false);
+ expect(result.startsWith("https://")).toBe(false);
+ });
+});
+
describe("getSafeExternalUrl", () => {
describe("allows", () => {
const safeCases = [
diff --git a/airflow-core/src/airflow/ui/src/utils/links.ts
b/airflow-core/src/airflow/ui/src/utils/links.ts
index 403f2801710..e0de230f7a4 100644
--- a/airflow-core/src/airflow/ui/src/utils/links.ts
+++ b/airflow-core/src/airflow/ui/src/utils/links.ts
@@ -47,6 +47,13 @@ export const getRedirectPath = (targetPath: string): string
=> {
return new URL(targetPath, baseUrl).pathname;
};
+// Build a same-origin "next" target (path + query + hash) from a Location.
+// Using a relative URL ensures redirects work correctly when the UI is
+// reached through a proxy or a different origin than the API server reports
+// (e.g. Gitpod port-based domains, see #46533).
+export const getNextHref = (location: Pick<Location, "hash" | "pathname" |
"search">): string =>
+ `${location.pathname}${location.search}${location.hash}`;
+
export const getTaskInstanceAdditionalPath = (pathname: string): string => {
const subRoutes = taskInstanceRoutes.filter((route) => route.path !==
undefined).map((route) => route.path);
// Look for patterns like /tasks/{taskId}/mapped/{mapIndex}/{sub-route}