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

vincbeck 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 99eff4608f9 Keep the token cookie set by a route in 
JWTRefreshMiddleware (#75217)
99eff4608f9 is described below

commit 99eff4608f9a63874bf6f0efda845abeaa91d2d1
Author: Mathieu Monet <[email protected]>
AuthorDate: Tue Oct 20 17:40:30 2026 +0200

    Keep the token cookie set by a route in JWTRefreshMiddleware (#75217)
    
    JWTRefreshMiddleware appends its own _token Set-Cookie after the
    response headers. When the route itself sets _token, the browser
    applies the middleware cookie last, so it overrides the route:
    
    - A login route, e.g. an auth manager callback mounted under /auth,
      sets a fresh token while the request still carries an expired or
      invalid one. The middleware appends a deletion and the user is
      logged out right after logging in.
    - /api/v2/auth/logout deletes the cookie, but if refresh_user returned
      a refreshed user for that request the middleware appends a newly
      minted, unrevoked token and the user stays logged in.
    
    Skip the middleware token cookie when the response already sets
    _token. Stale root-path cookies are still cleared.
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../api_fastapi/auth/middlewares/refresh_token.py  | 23 +++++++---
 .../auth/middlewares/test_refresh_token.py         | 51 ++++++++++++++++++++++
 2 files changed, 69 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 2e97e6c25c1..37186d9be53 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
@@ -50,7 +50,8 @@ class JWTRefreshMiddleware:
     1. Extracts JWT token from cookies and build the user from the token
     2. Calls ``refresh_user`` method from auth manager with the user
     3. If ``refresh_user`` returns a user, generate a JWT token based upon 
this user and send it in the
-       response as cookie
+       response as cookie, unless the response already sets that cookie itself 
(e.g. a login or logout
+       route)
 
     Requests to ``UNAUTHENTICATED_PATHS`` or under ``skip_path_prefixes`` 
(paths relative to the
     root path) are passed through untouched.
@@ -89,7 +90,9 @@ class JWTRefreshMiddleware:
             # token used by the auth manager is expired
             new_token = ""
 
-        cookie_headers = self._build_cookie_headers(request, new_token) if 
new_token is not None else []
+        token_cookie, cleanup_cookies = (
+            self._build_cookie_headers(request, new_token) if new_token is not 
None else (None, [])
+        )
         response_started = False
 
         async def send_with_cookies(message: Message) -> None:
@@ -97,7 +100,13 @@ class JWTRefreshMiddleware:
             if message["type"] == "http.response.start":
                 response_started = True
                 headers = MutableHeaders(scope=message)
-                for value in cookie_headers:
+                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:
                     headers.append("set-cookie", value)
             await send(message)
 
@@ -115,7 +124,8 @@ class JWTRefreshMiddleware:
         return path in UNAUTHENTICATED_PATHS or 
path.startswith(self.skip_path_prefixes)
 
     @staticmethod
-    def _build_cookie_headers(request: Request, new_token: str) -> list[str]:
+    def _build_cookie_headers(request: Request, new_token: str) -> tuple[str, 
list[str]]:
+        """Return the ``Set-Cookie`` value of the token and those clearing 
stale token cookies."""
         cookie_path = get_cookie_path()
         secure = request.base_url.scheme == "https" or bool(conf.get("api", 
"ssl_cert", fallback=""))
         # Only used to render the Set-Cookie header values
@@ -141,7 +151,10 @@ class JWTRefreshMiddleware:
                 secure=secure,
                 samesite="lax",
             )
-        return [value.decode("latin-1") for key, value in response.raw_headers 
if key == b"set-cookie"]
+        token_cookie, *cleanup_cookies = (
+            value.decode("latin-1") for key, value in response.raw_headers if 
key == b"set-cookie"
+        )
+        return token_cookie, cleanup_cookies
 
     @staticmethod
     async def _refresh_user(current_token: str) -> tuple[BaseUser | None, 
BaseUser | None]:
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 346c1e8dc61..a601a0417d1 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
@@ -28,6 +28,7 @@ from fastapi.testclient import TestClient
 
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN, BaseAuthManager
 from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
+from airflow.api_fastapi.auth.middlewares import refresh_token
 from airflow.api_fastapi.auth.middlewares.refresh_token import 
JWTRefreshMiddleware
 from airflow.api_fastapi.core_api.security import 
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
 
@@ -53,6 +54,20 @@ def _build_app(static_dir=None, root_path: str = "") -> 
tuple[FastAPI, dict]:
     def version():
         return PlainTextResponse("3.x")
 
+    @app.get("/auth/login_callback")
+    def login_callback():
+        response = PlainTextResponse("logged in")
+        response.set_cookie(
+            COOKIE_NAME_JWT_TOKEN, "issued_token", 
path=refresh_token.get_cookie_path(), httponly=True
+        )
+        return response
+
+    @app.get("/api/v2/auth/logout")
+    def logout():
+        response = PlainTextResponse("logged out")
+        response.delete_cookie(COOKIE_NAME_JWT_TOKEN, 
path=refresh_token.get_cookie_path(), httponly=True)
+        return response
+
     @app.get("/api/v2/stream")
     def stream():
         return StreamingResponse(iter([b"chunk1-", b"chunk2-", b"chunk3"]), 
media_type="text/plain")
@@ -136,6 +151,42 @@ class TestJWTRefreshMiddleware:
         mock_auth_manager.generate_jwt.assert_called_once_with(refreshed_user)
         assert response.headers["set-cookie"] == "_token=new_token; HttpOnly; 
Path=/; SameSite=lax"
 
+    def test_invalid_token_does_not_clear_token_set_by_route(self, 
mock_resolve_user, mock_auth_manager):
+        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")
+
+        assert response.headers.get_list("set-cookie") == [
+            "_token=issued_token; HttpOnly; Path=/; SameSite=lax"
+        ]
+
+    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)
+        mock_auth_manager.generate_jwt.return_value = "new_token"
+        app, _ = _build_app()
+
+        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]
+
+    @patch(f"{MODULE}.get_cookie_path", autospec=True, return_value="/team-a/")
+    def test_root_cookie_cleared_when_route_sets_token(
+        self, mock_cookie_path, mock_resolve_user, mock_auth_manager
+    ):
+        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")
+
+        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]
+
     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."""
         refresh_threads = []

Reply via email to