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: