From e50899d4026430aeb0def5b9c051c5dcbcd5eef4 Mon Sep 17 00:00:00 2001
From: Jarek Potiuk <jarek@potiuk.com>
Date: Thu, 16 Apr 2026 05:02:47 +0200
Subject: [PATCH] Set JWT refresh cookie Secure flag when request is HTTPS

JWTRefreshMiddleware derived the cookie Secure flag from the local
api.ssl_cert config only. Deployments with TLS terminated at a
reverse proxy (no local SSL cert on the Airflow process) therefore
received the JWT refresh cookie without the Secure flag.

Match the pattern already used by every other cookie-setting
location in the codebase (auth.py, simple/routes/login.py, FAB and
Keycloak login routes): treat secure as True when either the
request came in over HTTPS or a local ssl_cert is configured.

Generated-by: Claude Opus 4.6 (1M context) following the guidelines at
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
---
 .../auth/middlewares/refresh_token.py         |  2 +-
 .../auth/middlewares/test_refresh_token.py    | 45 +++++++++++++++++++
 2 files changed, 46 insertions(+), 1 deletion(-)

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 b8a3a268ba855..179bb1ad07e3f 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
@@ -62,7 +62,7 @@ async def dispatch(self, request: Request, call_next):
 
             if new_token is not None:
                 cookie_path = get_cookie_path()
-                secure = bool(conf.get("api", "ssl_cert", fallback=""))
+                secure = request.base_url.scheme == "https" or bool(conf.get("api", "ssl_cert", fallback=""))
                 response.set_cookie(
                     COOKIE_NAME_JWT_TOKEN,
                     new_token,
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 34b30ba1e7e4f..2ebfe2cc71b48 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
@@ -131,6 +131,51 @@ async def test_dispatch_with_refreshed_user(
         set_cookie_headers = response.headers.get("set-cookie", "")
         assert f"{COOKIE_NAME_JWT_TOKEN}=new_token" in set_cookie_headers
 
+    @pytest.mark.parametrize(
+        ("scheme", "ssl_cert", "expected_secure"),
+        [
+            pytest.param("https", "", True, id="https-no-local-ssl-cert"),
+            pytest.param("http", "/etc/ssl/cert.pem", True, id="http-with-local-ssl-cert"),
+            pytest.param("https", "/etc/ssl/cert.pem", True, id="https-with-local-ssl-cert"),
+            pytest.param("http", "", False, id="http-no-local-ssl-cert"),
+        ],
+    )
+    @patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_auth_manager")
+    @patch("airflow.api_fastapi.auth.middlewares.refresh_token.resolve_user_from_token")
+    @patch("airflow.api_fastapi.auth.middlewares.refresh_token.conf")
+    @pytest.mark.asyncio
+    async def test_dispatch_cookie_secure_flag(
+        self,
+        mock_conf,
+        mock_resolve_user_from_token,
+        mock_get_auth_manager,
+        middleware,
+        mock_request,
+        mock_user,
+        scheme,
+        ssl_cert,
+        expected_secure,
+    ):
+        """The cookie Secure flag must follow the request scheme as well as the local ssl_cert."""
+        refreshed_user = MagicMock(spec=BaseUser)
+        mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "valid_token"}
+        mock_request.base_url = MagicMock(scheme=scheme)
+        mock_resolve_user_from_token.return_value = mock_user
+        mock_auth_manager = MagicMock()
+        mock_get_auth_manager.return_value = mock_auth_manager
+        mock_auth_manager.refresh_user.return_value = refreshed_user
+        mock_auth_manager.generate_jwt.return_value = "new_token"
+        mock_conf.get.return_value = ssl_cert
+
+        call_next = AsyncMock(return_value=Response())
+        response = await middleware.dispatch(mock_request, call_next)
+
+        set_cookie_headers = response.headers.get("set-cookie", "")
+        if expected_secure:
+            assert "Secure" in set_cookie_headers
+        else:
+            assert "Secure" not in set_cookie_headers
+
     @patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_cookie_path", return_value="/team-a/")
     @patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_auth_manager")
     @patch("airflow.api_fastapi.auth.middlewares.refresh_token.resolve_user_from_token")
