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

potiuk 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 43ee8c48c9b Scope session token in cookie to base_url (#62771)
43ee8c48c9b is described below

commit 43ee8c48c9bf6ec3de382052602a43afc4a0da34
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
---
 .../docs/core-concepts/auth-manager/index.rst      |  3 ++-
 airflow-core/src/airflow/api_fastapi/app.py        | 10 +++++++
 .../auth/managers/simple/routes/login.py           |  2 ++
 .../auth/managers/simple/ui/src/login/Login.tsx    | 25 ++++++++++++++---
 .../auth/managers/simple/ui/src/queryClient.ts     |  5 ++++
 .../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            | 13 +++++++++
 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 ++
 .../fab/auth_manager/api_fastapi/routes/login.py   |  9 +++++++
 .../src/airflow/providers/fab/version_compat.py    |  1 +
 providers/fab/src/airflow/providers/fab/www/app.py |  7 +++++
 .../fab/src/airflow/providers/fab/www/views.py     | 12 ++++++---
 .../auth_manager/api_fastapi/routes/test_login.py  | 15 +++++++++++
 .../keycloak/auth_manager/routes/login.py          | 19 ++++++++++---
 .../airflow/providers/keycloak/version_compat.py   |  1 +
 19 files changed, 178 insertions(+), 16 deletions(-)

diff --git a/airflow-core/docs/core-concepts/auth-manager/index.rst 
b/airflow-core/docs/core-concepts/auth-manager/index.rst
index 2e787c9e7fb..49d761b174b 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 30d151c0db7..5d1a5f46568 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 2233de8e0b5..f65aa71f223 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 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.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/managers/simple/ui/src/login/Login.tsx
 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/login/Login.tsx
index 7d3bb4733e0..31e36b0564a 100644
--- 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/login/Login.tsx
+++ 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/login/Login.tsx
@@ -27,6 +27,13 @@ import { ErrorAlert } from "src/alert/ErrorAlert";
 import { LoginForm } from "src/login/LoginForm";
 import { useCreateToken } from "src/queries/useCreateToken";
 
+// Derive the cookie path from the <base> tag so the _token cookie is scoped
+// to the Airflow subpath (e.g. "/team-a/") instead of "/".
+const cookiePath = new URL(
+  document.querySelector("head>base")?.getAttribute("href") ?? "/",
+  globalThis.location.origin,
+).pathname;
+
 export type LoginBody = {
   password: string;
   username: string;
@@ -47,20 +54,30 @@ const LOCAL_STORAGE_DISABLE_BANNER_KEY = 
"disable-sam-banner";
 
 export const Login = () => {
   const [searchParams] = useSearchParams();
-  const [, setCookie] = useCookies(["_token"]);
+  const [, setCookie, removeCookie] = useCookies(["_token"]);
   const [isBannerDisabled, setIsBannerDisabled] = useState(
     localStorage.getItem(LOCAL_STORAGE_DISABLE_BANNER_KEY),
   );
 
   const onSuccess = (data: LoginResponse) => {
-    // Fallback similar to FabAuthManager, strip off the next
-    const fallback = "/";
+    // Fall back to the Airflow base path (e.g. "/team-a/") so that
+    // logins without a "next" parameter (e.g. after logout) redirect
+    // to the correct subpath instead of the server root "/".
+    const fallback = cookiePath;
 
     // Redirect to appropriate page with the token
     const next = searchParams.get("next") ?? fallback;
 
+    // Remove any stale _token cookie at root path to prevent duplicate
+    // cookies.  When two _token cookies exist (one at "/" and one at the
+    // subpath), the server's SimpleCookie parser picks the last one which
+    // may be the stale value, causing authentication failures.
+    if (cookiePath !== "/") {
+      removeCookie("_token", { path: "/" });
+    }
+
     setCookie("_token", data.access_token, {
-      path: "/",
+      path: cookiePath,
       secure: globalThis.location.protocol !== "http:",
     });
 
diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/queryClient.ts
 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/queryClient.ts
index 0bb6a0c0643..801b09ad62c 100644
--- 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/queryClient.ts
+++ 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/src/queryClient.ts
@@ -19,6 +19,7 @@
 import { QueryClient } from "@tanstack/react-query";
 
 import { OpenAPI } from "openapi/requests/core/OpenAPI";
+import { client } from "openapi/requests/services.gen";
 
 // Dynamically set the base URL for XHR requests based on the meta tag.
 OpenAPI.BASE = document.querySelector("head>base")?.getAttribute("href") ?? "";
@@ -26,6 +27,10 @@ if (OpenAPI.BASE.endsWith("/")) {
   OpenAPI.BASE = OpenAPI.BASE.slice(0, -1);
 }
 
+// Configure the generated API client so requests include the subpath prefix
+// when Airflow runs behind a reverse proxy (e.g. /team-a/auth/token instead 
of /auth/token).
+client.setConfig({ baseURL: OpenAPI.BASE });
+
 export const queryClient = new QueryClient({
   defaultOptions: {
     mutations: {
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 dcadd5123e6..a20bb21720a 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
@@ -20,6 +20,7 @@ import structlog
 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
@@ -66,6 +67,7 @@ def logout(request: Request, auth_manager: AuthManagerDep) -> 
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 db04f91e34c..07b6876fa3b 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
@@ -30,6 +30,7 @@ from tests_common.test_utils.db import clear_db_revoked_tokens
 
 AUTH_MANAGER_LOGIN_URL = "http://some_login_url";
 AUTH_MANAGER_LOGOUT_URL = "http://some_logout_url";
+SUBPATH = "/team-a/"
 
 pytestmark = pytest.mark.db_test
 
@@ -121,6 +122,18 @@ class TestLogout(TestAuthEndpoint):
 
         assert response.status_code == 307
 
+    @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
+
 
 class TestLogoutTokenRevocation:
     """Tests for token revocation on logout, using real DB queries without 
mocks."""
diff --git a/airflow-core/tests/unit/api_fastapi/test_app.py 
b/airflow-core/tests/unit/api_fastapi/test_app.py
index 1eb692e1864..1879720d100 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 6c70786bd03..3012301b1bb 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
@@ -34,9 +34,14 @@ from airflow.api_fastapi.common.router import AirflowRouter
 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
 from airflow.providers.common.compat.sdk import conf
 
+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
     from onelogin.saml2.errors import OneLogin_Saml2_Error
@@ -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 dc76a025ccf..d56eb0ea339 100644
--- a/providers/amazon/src/airflow/providers/amazon/version_compat.py
+++ b/providers/amazon/src/airflow/providers/amazon/version_compat.py
@@ -39,6 +39,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)
 
 try:
     from airflow.sdk.definitions._internal.types import NOTSET, ArgNotSet
@@ -56,6 +57,7 @@ __all__ = [
     "AIRFLOW_V_3_0_PLUS",
     "AIRFLOW_V_3_1_PLUS",
     "AIRFLOW_V_3_1_1_PLUS",
+    "AIRFLOW_V_3_1_8_PLUS",
     "NOTSET",
     "ArgNotSet",
     "is_arg_set",
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py
index d2bbd50276f..39d31b408c5 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py
@@ -29,6 +29,12 @@ from 
airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import Logi
 from airflow.providers.fab.auth_manager.api_fastapi.routes.router import 
auth_router
 from airflow.providers.fab.auth_manager.api_fastapi.services.login import 
FABAuthManagerLogin
 from airflow.providers.fab.auth_manager.cli_commands.utils import 
get_application_builder
+from airflow.providers.fab.version_compat import 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: "/"
 
 
 @auth_router.post(
@@ -68,14 +74,17 @@ def logout(request: Request) -> RedirectResponse:
     with get_application_builder():
         login_url = get_auth_manager().get_url_login()
         secure = request.base_url.scheme == "https" or bool(conf.get("api", 
"ssl_cert", fallback=""))
+        cookie_path = get_cookie_path()
         response = RedirectResponse(login_url)
         response.delete_cookie(
             key="session",
+            path=cookie_path,
             secure=secure,
             httponly=True,
         )
         response.delete_cookie(
             key=COOKIE_NAME_JWT_TOKEN,
+            path=cookie_path,
             secure=secure,
             httponly=True,
         )
diff --git a/providers/fab/src/airflow/providers/fab/version_compat.py 
b/providers/fab/src/airflow/providers/fab/version_compat.py
index 350e25ce81b..92feca19762 100644
--- a/providers/fab/src/airflow/providers/fab/version_compat.py
+++ b/providers/fab/src/airflow/providers/fab/version_compat.py
@@ -34,4 +34,5 @@ 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)
 AIRFLOW_V_3_2_PLUS = get_base_airflow_version_tuple() >= (3, 2, 0)
diff --git a/providers/fab/src/airflow/providers/fab/www/app.py 
b/providers/fab/src/airflow/providers/fab/www/app.py
index 5846ad49a15..f9c34726d1c 100644
--- a/providers/fab/src/airflow/providers/fab/www/app.py
+++ b/providers/fab/src/airflow/providers/fab/www/app.py
@@ -31,6 +31,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
@@ -45,6 +46,11 @@ from airflow.providers.fab.www.extensions.init_views import (
 from airflow.providers.fab.www.extensions.init_wsgi_middlewares import 
init_wsgi_middleware
 from airflow.providers.fab.www.utils import get_session_lifetime_config
 
+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()
@@ -61,6 +67,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/fab/src/airflow/providers/fab/www/views.py 
b/providers/fab/src/airflow/providers/fab/www/views.py
index 01669d45981..3d75f29c672 100644
--- a/providers/fab/src/airflow/providers/fab/www/views.py
+++ b/providers/fab/src/airflow/providers/fab/www/views.py
@@ -32,7 +32,12 @@ from flask_appbuilder import IndexView, expose
 from airflow.api_fastapi.app import get_auth_manager
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 from airflow.configuration import conf
-from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_1_PLUS
+from airflow.providers.fab.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: "/"
 
 # Following the release of https://github.com/python/cpython/issues/102153 in 
Python 3.9.17 on
 # June 6, 2023, we are adding extra sanitization of the urls passed to 
get_safe_url method to make it works
@@ -117,10 +122,11 @@ def redirect(*args, **kwargs):
         secure = request.scheme == "https" or 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
     return flask_redirect(*args, **kwargs)
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_login.py 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_login.py
index 727b377a97d..984221b41cd 100644
--- a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_login.py
+++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_login.py
@@ -24,6 +24,8 @@ import pytest
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import 
LoginResponse
 
+SUBPATH = "/team-a/"
+
 
 @pytest.mark.db_test
 class TestLogin:
@@ -59,3 +61,16 @@ class TestLogin:
         cookies = response.headers.get_list("set-cookie")
         assert any("session=" in c for c in cookies)
         assert any(f"{COOKIE_NAME_JWT_TOKEN}=" in c for c in cookies)
+
+    @patch(
+        
"airflow.providers.fab.auth_manager.api_fastapi.routes.login.get_cookie_path", 
return_value=SUBPATH
+    )
+    def test_logout_cookie_uses_subpath(self, mock_cookie_path, test_client):
+        """Cookies on logout must be scoped to the configured subpath."""
+        response = test_client.get("/logout", follow_redirects=False)
+        assert response.status_code == 307
+        cookies = response.headers.get_list("set-cookie")
+        session_cookie = next(c for c in cookies if "session=" in c)
+        token_cookie = next(c for c in cookies if f"{COOKIE_NAME_JWT_TOKEN}=" 
in c)
+        assert f"Path={SUBPATH}" in session_cookie
+        assert f"Path={SUBPATH}" in token_cookie
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 54c94002120..304d1683617 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
@@ -27,7 +27,12 @@ from fastapi.responses import HTMLResponse, RedirectResponse
 
 from airflow.api_fastapi.app import get_auth_manager
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
-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
@@ -90,14 +95,17 @@ 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)
 
     # Save id token as separate cookie.
     # Cookies have a size limit (usually 4k), saving all the tokens in a same 
cookie goes beyond this limit
-    response.set_cookie(COOKIE_NAME_ID_TOKEN, tokens["id_token"], 
secure=secure, httponly=True)
+    response.set_cookie(
+        COOKIE_NAME_ID_TOKEN, tokens["id_token"], path=cookie_path, 
secure=secure, httponly=True
+    )
 
     return response
 
@@ -133,14 +141,17 @@ def logout_callback(request: Request):
     """
     login_url = get_auth_manager().get_url_login()
     secure = request.base_url.scheme == "https" or bool(conf.get("api", 
"ssl_cert", fallback=""))
+    cookie_path = get_cookie_path()
     response = RedirectResponse(login_url)
     response.delete_cookie(
         key=COOKIE_NAME_JWT_TOKEN,
+        path=cookie_path,
         secure=secure,
         httponly=True,
     )
     response.delete_cookie(
         key=COOKIE_NAME_ID_TOKEN,
+        path=cookie_path,
         secure=secure,
         httponly=True,
     )
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