This is an automated email from the ASF dual-hosted git repository.

jscheffl pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new 68be9e81fcd [v3-2-test] Fix redirect loop when stale root-path 
`_token` cookie exists from older Airflow instance (#64955) (#65177)
68be9e81fcd is described below

commit 68be9e81fcd4385c0c9dff235a93f49a1e452af6
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Apr 13 21:15:56 2026 +0200

    [v3-2-test] Fix redirect loop when stale root-path `_token` cookie exists 
from older Airflow instance (#64955) (#65177)
    
    * Fix redirect loop when stale root-path `_token` cookie exists from older 
Airflow instance
    
    * Adapt conditions to clear stale root path cookies
    
    * Extend test for clearing stale root path cookies on logout
    (cherry picked from commit ad269edda8512793587c55bde682b518650d6afd)
    
    Co-authored-by: Daniel Wolf <[email protected]>
---
 .../api_fastapi/auth/middlewares/refresh_token.py  | 15 ++++++++-
 .../api_fastapi/core_api/routes/public/auth.py     | 12 ++++++-
 .../auth/middlewares/test_refresh_token.py         | 38 ++++++++++++++++++++--
 .../core_api/routes/public/test_auth.py            |  6 +++-
 4 files changed, 66 insertions(+), 5 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py 
b/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py
index ac2a3d0dee5..b8a3a268ba8 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py
@@ -61,16 +61,29 @@ class JWTRefreshMiddleware(BaseHTTPMiddleware):
             response = await call_next(request)
 
             if new_token is not None:
+                cookie_path = get_cookie_path()
                 secure = bool(conf.get("api", "ssl_cert", fallback=""))
                 response.set_cookie(
                     COOKIE_NAME_JWT_TOKEN,
                     new_token,
-                    path=get_cookie_path(),
+                    path=cookie_path,
                     httponly=True,
                     secure=secure,
                     samesite="lax",
                     max_age=0 if new_token == "" else None,
                 )
+                # Clear any stale _token cookie at root path "/".
+                # Older Airflow instances may have set the cookie there;
+                # without this, the root-path cookie keeps being sent on
+                # every request, causing an infinite redirect loop.
+                if cookie_path != "/":
+                    response.delete_cookie(
+                        key=COOKIE_NAME_JWT_TOKEN,
+                        path="/",
+                        httponly=True,
+                        secure=secure,
+                        samesite="lax",
+                    )
         except HTTPException as exc:
             # If any HTTPException is raised during user resolution or 
refresh, return it as response
             return JSONResponse(status_code=exc.status_code, 
content={"detail": exc.detail})
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
index 17f5edd1347..f85bcec3a61 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
@@ -66,12 +66,22 @@ def logout(request: Request, auth_manager: AuthManagerDep) 
-> RedirectResponse:
         auth_manager.revoke_token(token_str)
 
     secure = request.base_url.scheme == "https" or bool(conf.get("api", 
"ssl_cert", fallback=""))
+    cookie_path = get_cookie_path()
     response = RedirectResponse(auth_manager.get_url_login())
     response.delete_cookie(
         key=COOKIE_NAME_JWT_TOKEN,
-        path=get_cookie_path(),
+        path=cookie_path,
         secure=secure,
         httponly=True,
     )
+    # Clear any stale _token cookie at root path "/" left by
+    # older Airflow instances to prevent redirect loops.
+    if cookie_path != "/":
+        response.delete_cookie(
+            key=COOKIE_NAME_JWT_TOKEN,
+            path="/",
+            secure=secure,
+            httponly=True,
+        )
 
     return response
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py 
b/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py
index 09943c2f6cf..34b30ba1e7e 100644
--- a/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py
+++ b/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py
@@ -159,5 +159,39 @@ class TestJWTRefreshMiddleware:
         call_next = AsyncMock(return_value=Response())
         response = await middleware.dispatch(mock_request, call_next)
 
-        set_cookie_headers = response.headers.get("set-cookie", "")
-        assert "Path=/team-a/" in set_cookie_headers
+        set_cookie_headers = response.headers.getlist("set-cookie")
+        assert any("Path=/team-a/" in h for h in set_cookie_headers)
+        # Stale root-path cookie must also be cleared
+        assert any(
+            "Path=/" in h and "Path=/team-a/" not in h and "Max-Age=0" in h 
for h in set_cookie_headers
+        )
+
+    
@patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_cookie_path", 
return_value="/team-a/")
+    @patch.object(
+        JWTRefreshMiddleware,
+        "_refresh_user",
+        side_effect=HTTPException(status_code=403, detail="Invalid JWT token"),
+    )
+    @patch("airflow.api_fastapi.auth.middlewares.refresh_token.conf")
+    @pytest.mark.asyncio
+    async def test_dispatch_invalid_token_clears_root_cookie(
+        self,
+        mock_conf,
+        mock_refresh_user,
+        mock_cookie_path,
+        middleware,
+        mock_request,
+    ):
+        """When a stale _token exists at root path, clearing must target both 
the subpath and root."""
+        mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "stale_root_token"}
+        mock_conf.get.return_value = ""
+
+        call_next = AsyncMock(return_value=Response(status_code=401))
+        response = await middleware.dispatch(mock_request, call_next)
+
+        set_cookie_headers = response.headers.getlist("set-cookie")
+        # Expect two delete cookies: one at the subpath and one at root "/"
+        assert any("Path=/team-a/" in h and "Max-Age=0" in h for h in 
set_cookie_headers)
+        assert any(
+            "Path=/" in h and "Path=/team-a/" not in h and "Max-Age=0" in h 
for h in set_cookie_headers
+        )
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
index c860c847501..f0e170daf60 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
@@ -149,8 +149,12 @@ class TestLogout(TestAuthEndpoint):
 
         assert response.status_code == 307
         cookies = response.headers.get_list("set-cookie")
-        token_cookie = next(c for c in cookies if f"{COOKIE_NAME_JWT_TOKEN}=" 
in c)
+        token_cookie = next(c for c in cookies if f"{COOKIE_NAME_JWT_TOKEN}=" 
in c and f"Path={SUBPATH}" in c)
         assert f"Path={SUBPATH}" in token_cookie
+        # Stale root-path cookie must also be cleared
+        assert any(
+            f"{COOKIE_NAME_JWT_TOKEN}=" in c and "Path=/" in c and 
f"Path={SUBPATH}" not in c for c in cookies
+        )
 
 
 class TestLogoutTokenRevocation:

Reply via email to