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)