This is an automated email from the ASF dual-hosted git repository.
rahulvats pushed a commit to branch v3-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-1-test by this push:
new 9b9dc13e87a [v3-1- test] Scope session token in cookie to base_url
(#62771) (#62851)
9b9dc13e87a is described below
commit 9b9dc13e87a8847c49da229457d6de6c209b67ca
Author: Rahul Vats <[email protected]>
AuthorDate: Thu Mar 5 09:56:11 2026 +0530
[v3-1- test] Scope session token in cookie to base_url (#62771) (#62851)
* 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)
* remove provider changes
---------
Co-authored-by: Daniel Wolf <[email protected]>
---
.../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 +++++++++++++++
8 files changed, 86 insertions(+), 2 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/"