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)
