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

pierrejeambrun 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 ea2aae77d1e Keep extra cookies set by a route in JWTRefreshMiddleware 
(#75411)
ea2aae77d1e is described below

commit ea2aae77d1e8513c32881485601a86f0a865d2d6
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Wed Oct 28 13:53:09 2026 +0100

    Keep extra cookies set by a route in JWTRefreshMiddleware (#75411)
    
    * Keep extra cookies set by a route in JWTRefreshMiddleware
    
    The route-wins rule only covered the token cookie, so a cookie returned
    by get_extra_cookies was still appended after the same cookie set by the
    route. When the session was invalidated, that cleared the cookie a login
    callback had just issued.
    
    The generate_new_user_token test passed one-element tuples to a single
    parametrized argument, so the case without a new user never ran.
    
    * Split token and cleanup cookie handling in JWTRefreshMiddleware
    
    * Let route cookies win by sending the middleware cookies first
    
    Browsers key cookies on name and path, so matching the route's cookies by
    name alone dropped our clear when the route set the same name on another
    path. Sending our headers before the route's lets the later Set-Cookie win
    for the same name and path, without parsing them.
    
    * Never send a refreshed token when the route sets its own
    
    Sending the refreshed token first and letting the route's logout cookie
    override it still put a freshly minted, unrevoked JWT on the wire, and
    clients such as Python's http.cookiejar keep it because they don't apply
    Set-Cookie headers in order.
---
 .../api_fastapi/auth/middlewares/refresh_token.py  | 24 ++++--
 .../auth/middlewares/test_refresh_token.py         | 92 +++++++++++++++++-----
 2 files changed, 90 insertions(+), 26 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 01aa96ffe79..ac175e98474 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
@@ -102,13 +102,20 @@ class JWTRefreshMiddleware:
             if message["type"] == "http.response.start":
                 response_started = True
                 headers = MutableHeaders(scope=message)
-                cookie_values = cleanup_cookies
-                # The token set by the route itself wins: appending ours after 
it would override it.
-                if token_cookie and not any(
-                    value.startswith(f"{COOKIE_NAME_JWT_TOKEN}=") for value in 
headers.getlist("set-cookie")
-                ):
-                    cookie_values = [token_cookie, *cleanup_cookies]
-                for value in cookie_values:
+                route_cookies = headers.getlist("set-cookie")
+                route_set_token = any(
+                    value.startswith(f"{COOKIE_NAME_JWT_TOKEN}=") for value in 
route_cookies
+                )
+                del headers["set-cookie"]
+                # The route owns the token it sets: never send ours, so a 
refreshed token can't outlive a logout.
+                cookie_values = (
+                    cleanup_cookies
+                    if route_set_token or not token_cookie
+                    else [token_cookie, *cleanup_cookies]
+                )
+                # Ours go first: for the same cookie name and path the later 
Set-Cookie wins, so the
+                # cookies set by the route itself take precedence.
+                for value in [*cookie_values, *route_cookies]:
                     headers.append("set-cookie", value)
             await send(message)
 
@@ -130,7 +137,8 @@ class JWTRefreshMiddleware:
         """
         Get extra cookies from the request context to set in a new token 
response.
 
-        Setting a cookie to "" will cause it to be cleared by 
build_cookie_headers()
+        Setting a cookie to "" will cause it to be cleared by 
build_cookie_headers(). A cookie with the
+        same name and path set by the route itself takes precedence over the 
one returned here.
         """
         return {}
 
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 39aa7216b9d..9134ad08bb0 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
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 import threading
+from http.cookies import SimpleCookie
 from unittest.mock import MagicMock, patch
 
 import pytest
@@ -37,6 +38,15 @@ from tests_common.test_utils.config import conf_vars
 MODULE = "airflow.api_fastapi.auth.middlewares.refresh_token"
 
 
+def _get_browser_cookies(response) -> dict[tuple[str, str], str | None]:
+    """Apply the ``Set-Cookie`` headers in order like a browser, keyed on name 
and path; ``None`` is cleared."""
+    cookies: dict[tuple[str, str], str | None] = {}
+    for header in response.headers.get_list("set-cookie"):
+        for name, morsel in SimpleCookie(header).items():
+            cookies[(name, morsel["path"])] = None if morsel["max-age"] == "0" 
else morsel.value
+    return cookies
+
+
 def _build_app(static_dir=None, root_path: str = "") -> tuple[FastAPI, dict]:
     """Return an app wrapped by the middleware and a dict capturing what the 
endpoint saw."""
     seen: dict = {}
@@ -64,6 +74,19 @@ def _build_app(static_dir=None, root_path: str = "") -> 
tuple[FastAPI, dict]:
         )
         return response
 
+    @app.get("/auth/login_callback_with_extra_cookie")
+    def login_callback_with_extra_cookie():
+        response = PlainTextResponse("logged in")
+        response.set_cookie(COOKIE_NAME_JWT_TOKEN, "issued_token", 
path=refresh_token.get_cookie_path())
+        response.set_cookie("foo", "issued_foo", 
path=refresh_token.get_cookie_path())
+        return response
+
+    @app.get("/auth/clear_root_extra_cookie")
+    def clear_root_extra_cookie():
+        response = PlainTextResponse("cleared")
+        response.delete_cookie("foo", path="/")
+        return response
+
     @app.get("/api/v2/auth/logout")
     def logout():
         response = PlainTextResponse("logged out")
@@ -158,9 +181,7 @@ class TestJWTRefreshMiddleware:
 
         response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"stale_token"}).get("/auth/login_callback")
 
-        assert response.headers.get_list("set-cookie") == [
-            "_token=issued_token; HttpOnly; Path=/; SameSite=lax"
-        ]
+        assert _get_browser_cookies(response) == {(COOKIE_NAME_JWT_TOKEN, 
"/"): "issued_token"}
 
     def test_refreshed_token_does_not_override_logout(self, mock_resolve_user, 
mock_auth_manager):
         mock_auth_manager.refresh_user.return_value = MagicMock(spec=BaseUser)
@@ -169,10 +190,9 @@ class TestJWTRefreshMiddleware:
 
         response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"valid_token"}).get("/api/v2/auth/logout")
 
-        set_cookie_headers = response.headers.get_list("set-cookie")
-        assert len(set_cookie_headers) == 1
-        assert "Max-Age=0" in set_cookie_headers[0]
-        assert "new_token" not in set_cookie_headers[0]
+        assert _get_browser_cookies(response) == {(COOKIE_NAME_JWT_TOKEN, 
"/"): None}
+        # Clients that don't apply Set-Cookie headers in order must not pick 
up a token minted during logout
+        assert not any("new_token" in header for header in 
response.headers.get_list("set-cookie"))
 
     @patch(f"{MODULE}.get_cookie_path", autospec=True, return_value="/team-a/")
     def test_root_cookie_cleared_when_route_sets_token(
@@ -183,10 +203,10 @@ class TestJWTRefreshMiddleware:
 
         response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"stale_token"}).get("/auth/login_callback")
 
-        set_cookie_headers = response.headers.get_list("set-cookie")
-        assert len(set_cookie_headers) == 2
-        assert set_cookie_headers[0] == "_token=issued_token; HttpOnly; 
Path=/team-a/; SameSite=lax"
-        assert "Max-Age=0; Path=/;" in set_cookie_headers[1]
+        assert _get_browser_cookies(response) == {
+            (COOKIE_NAME_JWT_TOKEN, "/team-a/"): "issued_token",
+            (COOKIE_NAME_JWT_TOKEN, "/"): None,
+        }
 
     def test_refresh_user_runs_off_the_event_loop(self, mock_resolve_user, 
mock_auth_manager):
         """``refresh_user`` is synchronous and may do blocking I/O, so it must 
not block the loop."""
@@ -317,16 +337,14 @@ class TestJWTRefreshMiddleware:
         )
 
     @pytest.mark.asyncio
-    @pytest.mark.parametrize("new_user", [(MagicMock(),), (None,)])
-    async def test_generate_new_user_token(self, mock_auth_manager, new_user):
+    @pytest.mark.parametrize(
+        ("new_user", "expected_token"), [(MagicMock(spec=BaseUser), 
"new_token"), (None, "")]
+    )
+    async def test_generate_new_user_token(self, mock_auth_manager, new_user, 
expected_token):
         mock_auth_manager.generate_jwt.return_value = "new_token"
         app, seen = _build_app()
         middleware = JWTRefreshMiddleware(app)
-        new_token = await middleware.generate_new_user_token(MagicMock(), 
new_user)
-        if new_user is None:
-            assert new_token == ""
-        else:
-            assert new_token == "new_token"
+        assert await middleware.generate_new_user_token(MagicMock(), new_user) 
== expected_token
 
     @patch(f"{MODULE}.JWTRefreshMiddleware.get_extra_cookies")
     def test_refresh_user_with_extra_cookies(
@@ -375,3 +393,41 @@ class TestJWTRefreshMiddleware:
             response.headers["set-cookie"]
             == '_token=new_token; HttpOnly; Path=/; SameSite=lax, foo=""; 
HttpOnly; Max-Age=0; Path=/; SameSite=lax'
         )
+
+    @patch(f"{MODULE}.JWTRefreshMiddleware.get_extra_cookies")
+    def test_invalid_token_does_not_clear_extra_cookie_set_by_route(
+        self, mock_get_extra_cookies: MagicMock, mock_resolve_user, 
mock_auth_manager
+    ):
+        mock_get_extra_cookies.return_value = {"foo": "", "bar": ""}
+        mock_resolve_user.side_effect = HTTPException(status_code=403, 
detail="Invalid JWT token")
+        app, _ = _build_app()
+
+        response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"stale_token"}).get(
+            "/auth/login_callback_with_extra_cookie"
+        )
+
+        assert _get_browser_cookies(response) == {
+            (COOKIE_NAME_JWT_TOKEN, "/"): "issued_token",
+            ("foo", "/"): "issued_foo",
+            ("bar", "/"): None,
+        }
+
+    @patch(f"{MODULE}.get_cookie_path", autospec=True, return_value="/team-a/")
+    @patch(f"{MODULE}.JWTRefreshMiddleware.get_extra_cookies")
+    def 
test_extra_cookie_cleared_on_cookie_path_when_route_clears_it_on_another_path(
+        self, mock_get_extra_cookies: MagicMock, mock_cookie_path, 
mock_resolve_user, mock_auth_manager
+    ):
+        mock_get_extra_cookies.return_value = {"foo": ""}
+        mock_resolve_user.side_effect = HTTPException(status_code=403, 
detail="Invalid JWT token")
+        app, _ = _build_app()
+
+        response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"stale_token"}).get(
+            "/auth/clear_root_extra_cookie"
+        )
+
+        assert _get_browser_cookies(response) == {
+            (COOKIE_NAME_JWT_TOKEN, "/team-a/"): None,
+            (COOKIE_NAME_JWT_TOKEN, "/"): None,
+            ("foo", "/team-a/"): None,
+            ("foo", "/"): None,
+        }

Reply via email to