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

rahulvats pushed a commit to branch backport-62771
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 8547ef5298ab56f3ac1e384c04acd0a3aef531a4
Author: Daniel Wolf <[email protected]>
AuthorDate: Tue Mar 3 16:49:41 2026 +0100

    Scope session token in cookie to base_url (#62771)
    
    * Scope session token in cookie to base_url
    
    * Make get_cookie_path import backwards compatible
    
    (cherry picked from commit 43ee8c48c9bf6ec3de382052602a43afc4a0da34)
---
 .../docs/core-concepts/auth-manager/index.rst      |  3 ++-
 airflow-core/src/airflow/api_fastapi/app.py        | 10 +++++++
 .../auth/managers/simple/routes/login.py           |  2 ++
 .../api_fastapi/auth/middlewares/refresh_token.py  |  3 ++-
 .../api_fastapi/core_api/routes/public/auth.py     |  2 ++
 .../auth/middlewares/test_refresh_token.py         | 31 ++++++++++++++++++++++
 .../core_api/routes/public/test_auth.py            | 15 +++++++++++
 airflow-core/tests/unit/api_fastapi/test_app.py    | 22 +++++++++++++++
 .../amazon/aws/auth_manager/routes/login.py        | 12 ++++++---
 .../src/airflow/providers/amazon/version_compat.py |  2 ++
 .../src/airflow/providers/fab/version_compat.py    |  1 +
 providers/fab/src/airflow/providers/fab/www/app.py |  7 +++++
 .../keycloak/auth_manager/routes/login.py          | 22 ++++++++++++---
 .../airflow/providers/keycloak/version_compat.py   |  1 +
 14 files changed, 125 insertions(+), 8 deletions(-)

diff --git a/airflow-core/docs/core-concepts/auth-manager/index.rst 
b/airflow-core/docs/core-concepts/auth-manager/index.rst
index b2bcae33268..75c9244a111 100644
--- a/airflow-core/docs/core-concepts/auth-manager/index.rst
+++ b/airflow-core/docs/core-concepts/auth-manager/index.rst
@@ -160,12 +160,13 @@ cookie named ``_token`` before redirecting to the Airflow 
UI. The Airflow UI wil
 
 .. code-block:: python
 
+    from airflow.api_fastapi.app import get_cookie_path
     from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 
     response = RedirectResponse(url="/")
 
     secure = request.base_url.scheme == "https" or bool(conf.get("api", 
"ssl_cert", fallback=""))
-    response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure, 
httponly=True)
+    response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, path=get_cookie_path(), 
secure=secure, httponly=True)
     return response
 
 .. note::
diff --git a/airflow-core/src/airflow/api_fastapi/app.py 
b/airflow-core/src/airflow/api_fastapi/app.py
index 7c05295807e..1720c9f08ec 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -49,6 +49,16 @@ API_ROOT_PATH = urlsplit(API_BASE_URL).path
 # Define the full path on which the potential auth manager fastapi is mounted
 AUTH_MANAGER_FASTAPI_APP_PREFIX = f"{API_ROOT_PATH}auth"
 
+
+def get_cookie_path() -> str:
+    """
+    Return the path to scope cookies to, derived from ``[api] base_url``.
+
+    Falls back to ``"/"`` when no ``base_url`` is configured.
+    """
+    return API_ROOT_PATH or "/"
+
+
 # Fast API apps mounted under these prefixes are not allowed
 RESERVED_URL_PREFIXES = ["/api/v2", "/ui", "/execution"]
 
diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py
index 372aecf6035..55df83634ec 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 from fastapi import Depends, Request, status
 from starlette.responses import RedirectResponse
 
+from airflow.api_fastapi.app import get_cookie_path
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 from airflow.api_fastapi.auth.managers.simple.datamodels.login import 
LoginBody, LoginResponse
 from airflow.api_fastapi.auth.managers.simple.services.login import 
SimpleAuthManagerLogin
@@ -93,6 +94,7 @@ def login_all_admins(request: Request) -> RedirectResponse:
     response.set_cookie(
         COOKIE_NAME_JWT_TOKEN,
         SimpleAuthManagerLogin.create_token_all_admins(),
+        path=get_cookie_path(),
         secure=secure,
         httponly=True,
     )
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 a64da351d25..ac2a3d0dee5 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
@@ -21,7 +21,7 @@ from fastapi import HTTPException, Request
 from fastapi.responses import JSONResponse
 from starlette.middleware.base import BaseHTTPMiddleware
 
-from airflow.api_fastapi.app import get_auth_manager
+from airflow.api_fastapi.app import get_auth_manager, get_cookie_path
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 from airflow.api_fastapi.auth.managers.exceptions import 
AuthManagerRefreshTokenExpiredException
 from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
@@ -65,6 +65,7 @@ class JWTRefreshMiddleware(BaseHTTPMiddleware):
                 response.set_cookie(
                     COOKIE_NAME_JWT_TOKEN,
                     new_token,
+                    path=get_cookie_path(),
                     httponly=True,
                     secure=secure,
                     samesite="lax",
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 a97b7fd9972..8f4ed3d74b2 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
@@ -19,6 +19,7 @@ from __future__ import annotations
 from fastapi import HTTPException, Request, status
 from fastapi.responses import RedirectResponse
 
+from airflow.api_fastapi.app import get_cookie_path
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
@@ -60,6 +61,7 @@ def logout(request: Request) -> RedirectResponse:
     response = RedirectResponse(auth_manager.get_url_login())
     response.delete_cookie(
         key=COOKIE_NAME_JWT_TOKEN,
+        path=get_cookie_path(),
         secure=secure,
         httponly=True,
     )
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 b8f0d7c7726..09943c2f6cf 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
@@ -130,3 +130,34 @@ class TestJWTRefreshMiddleware:
         mock_auth_manager.generate_jwt.assert_called_once_with(refreshed_user)
         set_cookie_headers = response.headers.get("set-cookie", "")
         assert f"{COOKIE_NAME_JWT_TOKEN}=new_token" 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")
+    @patch("airflow.api_fastapi.auth.middlewares.refresh_token.conf")
+    @pytest.mark.asyncio
+    async def test_dispatch_cookie_uses_subpath(
+        self,
+        mock_conf,
+        mock_resolve_user_from_token,
+        mock_get_auth_manager,
+        mock_cookie_path,
+        middleware,
+        mock_request,
+        mock_user,
+    ):
+        """When a subpath is configured, set_cookie must include it as 
path=."""
+        refreshed_user = MagicMock(spec=BaseUser)
+        mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "valid_token"}
+        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 = ""
+
+        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
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 d4a5e5869e3..14b30845a4a 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
@@ -20,10 +20,13 @@ from unittest.mock import MagicMock, patch
 
 import pytest
 
+from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
+
 from tests_common.test_utils.config import conf_vars
 
 AUTH_MANAGER_LOGIN_URL = "http://some_login_url";
 AUTH_MANAGER_LOGOUT_URL = "http://some_logout_url";
+SUBPATH = "/team-a/"
 
 pytestmark = pytest.mark.db_test
 
@@ -94,3 +97,15 @@ class TestLogout(TestAuthEndpoint):
 
         assert response.status_code == 307
         assert response.headers["location"] == expected_redirection
+
+    @patch("airflow.api_fastapi.core_api.routes.public.auth.get_cookie_path", 
return_value=SUBPATH)
+    def test_logout_cookie_uses_subpath(self, mock_cookie_path, test_client):
+        """Cookies must use the subpath so they are scoped to the correct 
instance."""
+        test_client.app.state.auth_manager.get_url_logout.return_value = None
+
+        response = test_client.get("/auth/logout", follow_redirects=False)
+
+        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)
+        assert f"Path={SUBPATH}" in token_cookie
diff --git a/airflow-core/tests/unit/api_fastapi/test_app.py 
b/airflow-core/tests/unit/api_fastapi/test_app.py
index 448d527ab6b..cd43260211b 100644
--- a/airflow-core/tests/unit/api_fastapi/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/test_app.py
@@ -118,3 +118,25 @@ def test_plugin_with_invalid_url_prefix(caplog, 
fastapi_apps, expected_message,
 
     assert any(expected_message in rec.message for rec in caplog.records)
     assert not any(r.path == invalid_path for r in app.routes)
+
+
+class TestGetCookiePath:
+    def test_default_returns_slash(self):
+        """When no base_url is configured, get_cookie_path() should return 
'/'."""
+        with mock.patch.object(app_module, "API_ROOT_PATH", "/"):
+            assert app_module.get_cookie_path() == "/"
+
+    def test_empty_returns_slash(self):
+        """When API_ROOT_PATH is empty, get_cookie_path() should return '/'."""
+        with mock.patch.object(app_module, "API_ROOT_PATH", ""):
+            assert app_module.get_cookie_path() == "/"
+
+    def test_subpath(self):
+        """When base_url contains a subpath, get_cookie_path() should return 
it."""
+        with mock.patch.object(app_module, "API_ROOT_PATH", "/team-a/"):
+            assert app_module.get_cookie_path() == "/team-a/"
+
+    def test_nested_subpath(self):
+        """When base_url contains a nested subpath, get_cookie_path() should 
return it."""
+        with mock.patch.object(app_module, "API_ROOT_PATH", 
"/org/team-a/airflow/"):
+            assert app_module.get_cookie_path() == "/org/team-a/airflow/"
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/routes/login.py
 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/routes/login.py
index c3ca59a28c1..1ff2c59f81a 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/routes/login.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/routes/login.py
@@ -35,7 +35,12 @@ from airflow.configuration import conf
 from airflow.providers.amazon.aws.auth_manager.constants import 
CONF_SAML_METADATA_URL_KEY, CONF_SECTION_NAME
 from airflow.providers.amazon.aws.auth_manager.datamodels.login import 
LoginResponse
 from airflow.providers.amazon.aws.auth_manager.user import AwsAuthManagerUser
-from airflow.providers.amazon.version_compat import AIRFLOW_V_3_1_1_PLUS
+from airflow.providers.amazon.version_compat import AIRFLOW_V_3_1_1_PLUS, 
AIRFLOW_V_3_1_8_PLUS
+
+if AIRFLOW_V_3_1_8_PLUS:
+    from airflow.api_fastapi.app import get_cookie_path
+else:
+    get_cookie_path = lambda: "/"
 
 try:
     from onelogin.saml2.auth import OneLogin_Saml2_Auth
@@ -104,10 +109,11 @@ def login_callback(request: Request):
         secure = bool(conf.get("api", "ssl_cert", fallback=""))
         # In Airflow 3.1.1 authentication changes, front-end no longer handle 
the token
         # See https://github.com/apache/airflow/pull/55506
+        cookie_path = get_cookie_path()
         if AIRFLOW_V_3_1_1_PLUS:
-            response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure, 
httponly=True)
+            response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, 
path=cookie_path, secure=secure, httponly=True)
         else:
-            response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure)
+            response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, 
path=cookie_path, secure=secure)
         return response
     if relay_state == "login-token":
         return LoginResponse(access_token=token)
diff --git a/providers/amazon/src/airflow/providers/amazon/version_compat.py 
b/providers/amazon/src/airflow/providers/amazon/version_compat.py
index f7b680bd10d..581b2adb09c 100644
--- a/providers/amazon/src/airflow/providers/amazon/version_compat.py
+++ b/providers/amazon/src/airflow/providers/amazon/version_compat.py
@@ -35,6 +35,7 @@ def get_base_airflow_version_tuple() -> tuple[int, int, int]:
 AIRFLOW_V_3_0_PLUS = get_base_airflow_version_tuple() >= (3, 0, 0)
 AIRFLOW_V_3_1_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 0)
 AIRFLOW_V_3_1_1_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 1)
+AIRFLOW_V_3_1_8_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 8)
 
 if AIRFLOW_V_3_1_PLUS:
     from airflow.sdk import BaseHook
@@ -52,6 +53,7 @@ else:
 __all__ = [
     "AIRFLOW_V_3_0_PLUS",
     "AIRFLOW_V_3_1_PLUS",
+    "AIRFLOW_V_3_1_8_PLUS",
     "BaseHook",
     "BaseOperator",
     "BaseOperatorLink",
diff --git a/providers/fab/src/airflow/providers/fab/version_compat.py 
b/providers/fab/src/airflow/providers/fab/version_compat.py
index e1d9559cc31..2910f1eab24 100644
--- a/providers/fab/src/airflow/providers/fab/version_compat.py
+++ b/providers/fab/src/airflow/providers/fab/version_compat.py
@@ -34,3 +34,4 @@ def get_base_airflow_version_tuple() -> tuple[int, int, int]:
 
 AIRFLOW_V_3_1_PLUS = get_base_airflow_version_tuple() >= (3, 1, 0)
 AIRFLOW_V_3_1_1_PLUS = get_base_airflow_version_tuple() >= (3, 1, 1)
+AIRFLOW_V_3_1_8_PLUS = get_base_airflow_version_tuple() >= (3, 1, 8)
diff --git a/providers/fab/src/airflow/providers/fab/www/app.py 
b/providers/fab/src/airflow/providers/fab/www/app.py
index ee6541d2dbb..5ec28d429fd 100644
--- a/providers/fab/src/airflow/providers/fab/www/app.py
+++ b/providers/fab/src/airflow/providers/fab/www/app.py
@@ -30,6 +30,7 @@ from airflow.api_fastapi.app import get_auth_manager
 from airflow.configuration import conf
 from airflow.exceptions import AirflowConfigException
 from airflow.logging_config import configure_logging
+from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_8_PLUS
 from airflow.providers.fab.www.extensions.init_appbuilder import 
init_appbuilder
 from airflow.providers.fab.www.extensions.init_jinja_globals import 
init_jinja_globals
 from airflow.providers.fab.www.extensions.init_manifest_files import 
configure_manifest_files
@@ -46,6 +47,11 @@ from airflow.providers.fab.www.utils import 
get_session_lifetime_config
 
 app: Flask | None = None
 
+if AIRFLOW_V_3_1_8_PLUS:
+    from airflow.api_fastapi.app import get_cookie_path
+else:
+    get_cookie_path = lambda: "/"
+
 # Initializes at the module level, so plugins can access it.
 # See: /docs/plugins.rst
 csrf = CSRFProtect()
@@ -62,6 +68,7 @@ def create_app(enable_plugins: bool):
     flask_app.config["PERMANENT_SESSION_LIFETIME"] = 
timedelta(minutes=get_session_lifetime_config())
 
     flask_app.config["SESSION_COOKIE_HTTPONLY"] = True
+    flask_app.config["SESSION_COOKIE_PATH"] = get_cookie_path()
     if conf.has_option("fab", "COOKIE_SECURE"):
         flask_app.config["SESSION_COOKIE_SECURE"] = conf.getboolean("fab", 
"COOKIE_SECURE")
     if conf.has_option("fab", "COOKIE_SAMESITE"):
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py
index e8681188b36..6de16ebfdd2 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py
@@ -30,7 +30,22 @@ from airflow.api_fastapi.core_api.security import get_user
 from airflow.configuration import conf
 from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import 
KeycloakAuthManager
 from airflow.providers.keycloak.auth_manager.user import 
KeycloakAuthManagerUser
-from airflow.providers.keycloak.version_compat import AIRFLOW_V_3_1_1_PLUS
+from airflow.providers.keycloak.version_compat import AIRFLOW_V_3_1_1_PLUS, 
AIRFLOW_V_3_1_8_PLUS
+
+if AIRFLOW_V_3_1_8_PLUS:
+    from airflow.api_fastapi.app import get_cookie_path
+else:
+    get_cookie_path = lambda: "/"
+
+try:
+    from airflow.api_fastapi.auth.managers.exceptions import 
AuthManagerRefreshTokenExpiredException
+except ImportError:
+
+    class AuthManagerRefreshTokenExpiredException(Exception):  # type: 
ignore[no-redef]
+        """In case it is using a version of Airflow without 
``AuthManagerRefreshTokenExpiredException``."""
+
+        pass
+
 
 log = logging.getLogger(__name__)
 login_router = AirflowRouter(tags=["KeycloakAuthManagerLogin"])
@@ -73,10 +88,11 @@ def login_callback(request: Request):
     secure = bool(conf.get("api", "ssl_cert", fallback=""))
     # In Airflow 3.1.1 authentication changes, front-end no longer handle the 
token
     # See https://github.com/apache/airflow/pull/55506
+    cookie_path = get_cookie_path()
     if AIRFLOW_V_3_1_1_PLUS:
-        response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure, 
httponly=True)
+        response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, path=cookie_path, 
secure=secure, httponly=True)
     else:
-        response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure)
+        response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, path=cookie_path, 
secure=secure)
     return response
 
 
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/version_compat.py 
b/providers/keycloak/src/airflow/providers/keycloak/version_compat.py
index 384af03bd1e..917adca937c 100644
--- a/providers/keycloak/src/airflow/providers/keycloak/version_compat.py
+++ b/providers/keycloak/src/airflow/providers/keycloak/version_compat.py
@@ -33,3 +33,4 @@ def get_base_airflow_version_tuple() -> tuple[int, int, int]:
 
 
 AIRFLOW_V_3_1_1_PLUS = get_base_airflow_version_tuple() >= (3, 1, 1)
+AIRFLOW_V_3_1_8_PLUS = get_base_airflow_version_tuple() >= (3, 1, 8)

Reply via email to