This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 54d3fbc26e4 [v3-4-test] Make `JWTRefreshMiddleware` extensible via
`BaseAuthManager.get_jwt_refresh_middleware()` (#75868)
54d3fbc26e4 is described below
commit 54d3fbc26e42c8ba7a27e9bed871d7db9154bbe8
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Nov 6 09:28:45 2026 +0100
[v3-4-test] Make `JWTRefreshMiddleware` extensible via
`BaseAuthManager.get_jwt_refresh_middleware()` (#75868)
* [v3-4-test] Keep release-candidate provider pins until each is released
or yanked (#75919) (#75924)
Constraints for an Airflow release candidate pin the provider wave being
voted on at its rc versions, and commit them onto the shared
constraints-X-Y branch. The next regular generation for the vX-Y-test
branch resolves without pre-releases, so it saw the older finals as a
provider downgrade and failed - the v3-4-test canary has been red since
3.4.0rc1 for exactly that reason, although nothing was yanked.
The final release has the opposite gap: it ignored the candidates, so a
provider rc that was neither released nor yanked was silently passed
over and the released constraints never recorded that decision. It now
has to be made, and the run says which providers it is still waiting on.
The alert that should have reported the canary failure never reached
Slack because the action was not told which API method to call, and the
twice-daily CI notification announced "All passing" because it read a
run that was still in progress, whose missing conclusion looked like no
failures.
(cherry picked from commit eeae004bd25e02c696f619f61ab67a3fb671c81c)
Generated-by: Claude Opus 5
Co-authored-by: Jarek Potiuk <[email protected]>
* Make JWTRefreshMiddleware extensible via
BaseAuthManager.get_jwt_refresh_middleware() (#70783)
Co-authored-by: Stephen Bracken <email-protected>
(cherry picked from commit e5d8cbbe67931dd13a027ed3386ddcc1877800cd)
---------
Co-authored-by: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jarek Potiuk <[email protected]>
Co-authored-by: stephen-bracken
<[email protected]>
---
airflow-core/src/airflow/api_fastapi/app.py | 18 +++++
.../api_fastapi/auth/managers/base_auth_manager.py | 14 ++++
.../api_fastapi/auth/middlewares/refresh_token.py | 77 +++++++++++++++-----
.../src/airflow/api_fastapi/core_api/app.py | 8 ++-
.../auth/managers/test_base_auth_manager.py | 10 +++
.../auth/middlewares/test_refresh_token.py | 81 +++++++++++++++++++---
.../core_api/routes/public/test_auth.py | 4 +-
.../tests/unit/api_fastapi/core_api/test_app.py | 29 ++++++++
airflow-core/tests/unit/api_fastapi/test_app.py | 16 +++++
9 files changed, 226 insertions(+), 31 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/app.py
b/airflow-core/src/airflow/api_fastapi/app.py
index bf8f28b4b5d..f52d34a4fde 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -44,6 +44,8 @@ from airflow.exceptions import AirflowConfigException
from airflow.utils.providers_configuration_loader import
providers_configuration_loaded
if TYPE_CHECKING:
+ from fastapi import Request
+
from airflow.api_fastapi.auth.managers.base_auth_manager import
BaseAuthManager
API_BASE_URL = conf.get("api", "base_url", fallback="")
@@ -64,6 +66,22 @@ def get_cookie_path() -> str:
return API_ROOT_PATH or "/"
+def request_cookie_is_secure(request: Request) -> bool:
+ """
+ Return whether a FastAPI request cookie should be tagged as HTTP secure.
+
+ :param request: FastAPI Request object
+
+ usage:
+ ```python
+ secure = request_cookie_is_secure(request)
+
+ response.set_cookie("mycookie", "myvalue", path=get_cookie_path(),
secure=secure, httponly=True)
+ ```
+ """
+ return request.base_url.scheme == "https" or bool(conf.get("api",
"ssl_cert", fallback=""))
+
+
# Fast API apps mounted under these prefixes are not allowed
RESERVED_URL_PREFIXES = ["/api/v2", "/ui", "/execution", "/auth", "/pluginsv2"]
diff --git
a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
index 94b96ba5af2..8b2302393a0 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
@@ -185,6 +185,20 @@ class BaseAuthManager(Generic[T], LoggingMixin,
metaclass=ABCMeta):
"""
return []
+ def get_jwt_refresh_middleware(self) ->
list[tuple[_MiddlewareFactory[Any], dict[str, Any]]]:
+ """
+ Return the JWTRefreshMiddleware to refresh the Airflow JWT token.
+
+ Middlewares added by this method will be initialised before middleware
from
+ ``get_fastapi_middlewares()``. To disable JWT refresh return an empty
list.
+ To add more middleware, simply add more tuples here in the same format
as
+ ``get_fastapi_middlewares()``.
+ """
+ from airflow.api_fastapi.auth.middlewares.refresh_token import
JWTRefreshMiddleware
+ from airflow.api_fastapi.core_api.app import _STATIC_URL_PATH
+
+ return [(JWTRefreshMiddleware, {"skip_path_prefixes":
(f"{_STATIC_URL_PATH}/",)})]
+
def generate_jwt(
self, user: T, *, expiration_time_in_seconds: int =
conf.getint("api_auth", "jwt_expiration_time")
) -> str:
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 37186d9be53..01aa96ffe79 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
@@ -24,19 +24,19 @@ from fastapi.responses import JSONResponse
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import MutableHeaders
-from airflow.api_fastapi.app import get_auth_manager, get_cookie_path
+from airflow.api_fastapi.app import get_auth_manager, get_cookie_path,
request_cookie_is_secure
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
from airflow.api_fastapi.core_api.security import (
USER_INJECTED_BY_TRUSTED_MIDDLEWARE,
resolve_user_from_token,
)
-from airflow.configuration import conf
if TYPE_CHECKING:
from starlette.types import ASGIApp, Message, Receive, Scope, Send
+ from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
+
# Unauthenticated endpoints that never read the user, so resolving it there
would only cost a
# JWT decode and a revocation lookup on every health check and UI boot.
UNAUTHENTICATED_PATHS = frozenset(["/api/v2/monitor/health",
"/api/v2/version"])
@@ -72,27 +72,29 @@ class JWTRefreshMiddleware:
await self.app(scope, receive, send)
return
- new_token = None
+ new_user = None
+ session_invalidated = False
try:
- new_user, current_user = await self._refresh_user(current_token)
+ new_user, current_user = await self.refresh_user(request)
if user := (new_user or current_user):
# Stamp the trust sentinel alongside the user so `get_user()`
# can distinguish this trusted assignment from a stray write
# by unrelated middleware.
request.state.user = user
request.state.user_authenticated_via =
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
- if new_user:
- # If we created a new user, serialize it and set it as a cookie
- new_token = get_auth_manager().generate_jwt(new_user)
except (HTTPException, AuthManagerRefreshTokenExpiredException):
# Receive a HTTPException when the Airflow token is expired
# Receive a AuthManagerRefreshTokenExpiredException when the
potential underlying refresh
# token used by the auth manager is expired
- new_token = ""
+ session_invalidated = True
- token_cookie, cleanup_cookies = (
- self._build_cookie_headers(request, new_token) if new_token is not
None else (None, [])
- )
+ if session_invalidated or new_user is not None:
+ extra_cookies = self.get_extra_cookies(request, new_user)
+ token_cookie, cleanup_cookies = self.build_cookie_headers(
+ request, await self.generate_new_user_token(request,
new_user), extra_cookies
+ )
+ else:
+ token_cookie, cleanup_cookies = (None, [])
response_started = False
async def send_with_cookies(message: Message) -> None:
@@ -124,10 +126,34 @@ class JWTRefreshMiddleware:
return path in UNAUTHENTICATED_PATHS or
path.startswith(self.skip_path_prefixes)
@staticmethod
- def _build_cookie_headers(request: Request, new_token: str) -> tuple[str,
list[str]]:
- """Return the ``Set-Cookie`` value of the token and those clearing
stale token cookies."""
+ def get_extra_cookies(request: Request, new_user: BaseUser | None) ->
dict[str, str]:
+ """
+ Get extra cookies from the request context to set in a new token
response.
+
+ Setting a cookie to "" will cause it to be cleared by
build_cookie_headers()
+ """
+ return {}
+
+ @staticmethod
+ async def generate_new_user_token(request: Request, new_user: BaseUser |
None) -> str:
+ """Generate a new Airflow JWT token from the user model."""
+ return get_auth_manager().generate_jwt(new_user) if new_user else ""
+
+ @staticmethod
+ def build_cookie_headers(
+ request: Request, new_token: str, extra_cookies: dict[str, str]
+ ) -> tuple[str, list[str]]:
+ """
+ Return the ``Set-Cookie`` value of the token and those clearing stale
token cookies.
+
+ Any cookies with a nil value ("") will be cleared with Max-Age=0
+
+ :param request: FastAPI Request
+ :param new_token: New Airflow JWT token
+ :param extra_cookies: Additional cookie values to set
+ """
cookie_path = get_cookie_path()
- secure = request.base_url.scheme == "https" or bool(conf.get("api",
"ssl_cert", fallback=""))
+ secure = request_cookie_is_secure(request)
# Only used to render the Set-Cookie header values
response = Response()
response.set_cookie(
@@ -151,13 +177,32 @@ class JWTRefreshMiddleware:
secure=secure,
samesite="lax",
)
+ # Add any additional cookies required
+ for cookie, value in extra_cookies.items():
+ response.set_cookie(
+ cookie,
+ value,
+ path=cookie_path,
+ httponly=True,
+ secure=secure,
+ samesite="lax",
+ max_age=0 if value == "" else None,
+ )
token_cookie, *cleanup_cookies = (
value.decode("latin-1") for key, value in response.raw_headers if
key == b"set-cookie"
)
return token_cookie, cleanup_cookies
@staticmethod
- async def _refresh_user(current_token: str) -> tuple[BaseUser | None,
BaseUser | None]:
+ async def refresh_user(request: Request) -> tuple[BaseUser | None,
BaseUser | None]:
+ """
+ Refresh the logged in user using the current JWT Token.
+
+ If the user is not authenticated, return ``None, None``
+
+ :param request: FastAPI Request
+ """
+ current_token = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
user = await resolve_user_from_token(current_token)
# Auth managers may do blocking I/O here (e.g. calling an identity
provider)
return await run_in_threadpool(get_auth_manager().refresh_user,
user=user), user
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py
b/airflow-core/src/airflow/api_fastapi/core_api/app.py
index 02f7151ab55..ccd1c6745dd 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -304,11 +304,13 @@ def init_config(app: FastAPI) -> None:
def init_middlewares(app: FastAPI) -> None:
from airflow.api_fastapi.app import get_auth_manager
- from airflow.api_fastapi.auth.middlewares.refresh_token import
JWTRefreshMiddleware
- app.add_middleware(JWTRefreshMiddleware,
skip_path_prefixes=(f"{_STATIC_URL_PATH}/",))
+ auth_manager = get_auth_manager()
- for middleware_cls, middleware_kwargs in
get_auth_manager().get_fastapi_middlewares():
+ for middleware_cls, middleware_kwargs in (
+ *auth_manager.get_jwt_refresh_middleware(),
+ *auth_manager.get_fastapi_middlewares(),
+ ):
app.add_middleware(middleware_cls, **middleware_kwargs)
# GZipMiddleware must be inside HttpAccessLogMiddleware so that access
logs capture
diff --git
a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
index 18a9ceeecd2..e433c1223da 100644
---
a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
+++
b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
@@ -36,8 +36,10 @@ from
airflow.api_fastapi.auth.managers.models.resource_details import (
TeamDetails,
VariableDetails,
)
+from airflow.api_fastapi.auth.middlewares.refresh_token import
JWTRefreshMiddleware
from airflow.api_fastapi.auth.tokens import JWTGenerator, JWTValidator
from airflow.api_fastapi.common.types import MenuItem
+from airflow.api_fastapi.core_api.app import _STATIC_URL_PATH
from airflow.exceptions import RemovedInAirflow4Warning
from airflow.models.pool import Pool
from airflow.models.team import Team
@@ -932,3 +934,11 @@ class TestBaseAuthManager:
user = BaseAuthManagerUserTest(name=user_id)
result =
auth_manager.is_authorized_hitl_task(assigned_users=assigned_users, user=user)
assert result == expected
+
+ def test_get_jwt_refresh_middleware(self, auth_manager):
+ assert auth_manager.get_jwt_refresh_middleware() == [
+ (JWTRefreshMiddleware, {"skip_path_prefixes":
(f"{_STATIC_URL_PATH}/",)})
+ ]
+
+ def test_get_fastapi_middlewares(self, auth_manager):
+ assert auth_manager.get_fastapi_middlewares() == []
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 a601a0417d1..39aa7216b9d 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
@@ -32,6 +32,8 @@ from airflow.api_fastapi.auth.middlewares import refresh_token
from airflow.api_fastapi.auth.middlewares.refresh_token import
JWTRefreshMiddleware
from airflow.api_fastapi.core_api.security import
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+from tests_common.test_utils.config import conf_vars
+
MODULE = "airflow.api_fastapi.auth.middlewares.refresh_token"
@@ -97,9 +99,8 @@ def mock_resolve_user():
@pytest.fixture(autouse=True)
def no_ssl_cert():
- with patch(f"{MODULE}.conf", autospec=True) as mock_conf:
- mock_conf.get.return_value = ""
- yield mock_conf
+ with conf_vars({("api", "ssl_cert"): ""}):
+ yield
class TestJWTRefreshMiddleware:
@@ -272,18 +273,18 @@ class TestJWTRefreshMiddleware:
],
)
def test_cookie_secure_flag(
- self, mock_resolve_user, mock_auth_manager, no_ssl_cert, scheme,
ssl_cert, expected_secure
+ self, mock_resolve_user, mock_auth_manager, scheme, ssl_cert,
expected_secure
):
"""The cookie Secure flag must follow the request scheme as well as
the local ssl_cert."""
mock_auth_manager.refresh_user.return_value = MagicMock(spec=BaseUser)
mock_auth_manager.generate_jwt.return_value = "new_token"
- no_ssl_cert.get.return_value = ssl_cert
- app, _ = _build_app()
- client = TestClient(
- app, base_url=f"{scheme}://testserver",
cookies={COOKIE_NAME_JWT_TOKEN: "valid_token"}
- )
+ with conf_vars({("api", "ssl_cert"): ssl_cert}):
+ app, _ = _build_app()
+ client = TestClient(
+ app, base_url=f"{scheme}://testserver",
cookies={COOKIE_NAME_JWT_TOKEN: "valid_token"}
+ )
- response = client.get("/api/v2/dags")
+ response = client.get("/api/v2/dags")
assert ("Secure" in response.headers["set-cookie"]) is expected_secure
@@ -314,3 +315,63 @@ class TestJWTRefreshMiddleware:
assert any(
"Path=/" in h and "Path=/team-a/" not in h and "Max-Age=0" in h
for h in set_cookie_headers
)
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("new_user", [(MagicMock(),), (None,)])
+ async def test_generate_new_user_token(self, mock_auth_manager, new_user):
+ mock_auth_manager.generate_jwt.return_value = "new_token"
+ app, seen = _build_app()
+ middleware = JWTRefreshMiddleware(app)
+ new_token = await middleware.generate_new_user_token(MagicMock(),
new_user)
+ if new_user is None:
+ assert new_token == ""
+ else:
+ assert new_token == "new_token"
+
+ @patch(f"{MODULE}.JWTRefreshMiddleware.get_extra_cookies")
+ def test_refresh_user_with_extra_cookies(
+ self, mock_get_extra_cookies: MagicMock, mock_resolve_user,
mock_auth_manager
+ ):
+ mock_get_extra_cookies.return_value = {"foo": "bar"}
+ refreshed_user = MagicMock(spec=BaseUser)
+ mock_auth_manager.refresh_user.return_value = refreshed_user
+ mock_auth_manager.generate_jwt.return_value = "new_token"
+ app, seen = _build_app()
+
+ response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN:
"valid_token"}).get("/api/v2/dags")
+
+ # JWTRefreshMiddleware must stamp the trust sentinel so `get_user()`
accepts
+ # the cached user. Without the marker, the auth dependency falls
through
+ # to a fresh JWT validation, defeating the refresh.
+ assert seen["user"] is refreshed_user
+ assert seen["authenticated_via"] is USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+
mock_auth_manager.refresh_user.assert_called_once_with(user=mock_resolve_user.return_value)
+ mock_auth_manager.generate_jwt.assert_called_once_with(refreshed_user)
+ assert (
+ response.headers["set-cookie"]
+ == "_token=new_token; HttpOnly; Path=/; SameSite=lax, foo=bar;
HttpOnly; Path=/; SameSite=lax"
+ )
+
+ @patch(f"{MODULE}.JWTRefreshMiddleware.get_extra_cookies")
+ def test_refresh_user_clear_extra_cookies(
+ self, mock_get_extra_cookies: MagicMock, mock_resolve_user,
mock_auth_manager
+ ):
+ mock_get_extra_cookies.return_value = {"foo": ""}
+ refreshed_user = MagicMock(spec=BaseUser)
+ mock_auth_manager.refresh_user.return_value = refreshed_user
+ mock_auth_manager.generate_jwt.return_value = "new_token"
+ app, seen = _build_app()
+
+ response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN:
"valid_token"}).get("/api/v2/dags")
+
+ # JWTRefreshMiddleware must stamp the trust sentinel so `get_user()`
accepts
+ # the cached user. Without the marker, the auth dependency falls
through
+ # to a fresh JWT validation, defeating the refresh.
+ assert seen["user"] is refreshed_user
+ assert seen["authenticated_via"] is USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+
mock_auth_manager.refresh_user.assert_called_once_with(user=mock_resolve_user.return_value)
+ mock_auth_manager.generate_jwt.assert_called_once_with(refreshed_user)
+ assert (
+ response.headers["set-cookie"]
+ == '_token=new_token; HttpOnly; Path=/; SameSite=lax, foo="";
HttpOnly; Max-Age=0; Path=/; SameSite=lax'
+ )
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 fc4e84621e4..6fd0143388f 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
@@ -367,7 +367,7 @@ class TestLogoutTokenRevocation:
# The refresh middleware is stubbed out so that only the logout route
can clear the cookie.
with (
patch(
-
"airflow.api_fastapi.auth.middlewares.refresh_token.JWTRefreshMiddleware._refresh_user",
+
"airflow.api_fastapi.auth.middlewares.refresh_token.JWTRefreshMiddleware.refresh_user",
new=AsyncMock(return_value=(None, None)),
),
patch.object(auth_manager, "get_url_logout",
return_value="http://external/logout"),
@@ -389,7 +389,7 @@ class TestLogoutTokenRevocation:
logout_client.cookies.set(COOKIE_NAME_JWT_TOKEN, token_str)
with (
patch(
-
"airflow.api_fastapi.auth.middlewares.refresh_token.JWTRefreshMiddleware._refresh_user",
+
"airflow.api_fastapi.auth.middlewares.refresh_token.JWTRefreshMiddleware.refresh_user",
new=AsyncMock(return_value=(None, None)),
),
patch.object(auth_manager, "get_url_logout",
return_value="http://external/logout"),
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
index 3d26d632639..ae765987760 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
@@ -20,10 +20,12 @@ import contextlib
import inspect
import json
import typing
+from unittest.mock import MagicMock, Mock, patch
import pytest
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.middleware.gzip import GZipMiddleware
from fastapi.params import Depends as DependsClass
from fastapi.responses import StreamingResponse
from fastapi.templating import Jinja2Templates
@@ -31,9 +33,11 @@ from starlette.routing import Mount
from starlette.testclient import TestClient
from airflow.api_fastapi.app import create_app
+from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager
from airflow.api_fastapi.core_api.app import (
base_react_app_bundle_urls,
init_config,
+ init_middlewares,
init_ui_translation_views,
)
from airflow.api_fastapi.core_api.routes.public import authenticated_router
@@ -384,3 +388,28 @@ class TestBaseReactAppPreload:
html = self._render("index.html", ['/x.js" onload="alert(1)'])
assert 'onload="alert(1)"' not in html
+
+
+class TestMiddlewareInitialisation:
+ @patch("airflow.api_fastapi.app.get_auth_manager")
+ def test_jwt_refresh_middleware_init(self, mock_get_auth_manager):
+ app = FastAPI()
+ auth_manager = MagicMock(spec=BaseAuthManager)
+ mock_jwt_middleware = Mock()
+ auth_manager.get_jwt_refresh_middleware.return_value =
[(mock_jwt_middleware, {})]
+ auth_manager.get_fastapi_middlewares.return_value = []
+ mock_get_auth_manager.return_value = auth_manager
+ init_middlewares(app)
+ auth_manager.get_jwt_refresh_middleware.assert_called_once()
+ assert [m.cls for m in app.user_middleware] == [GZipMiddleware,
mock_jwt_middleware]
+
+ @patch("airflow.api_fastapi.app.get_auth_manager")
+ def test_no_jwt_refresh_middleware_init(self, mock_get_auth_manager):
+ app = FastAPI()
+ auth_manager = MagicMock(spec=BaseAuthManager)
+ auth_manager.get_jwt_refresh_middleware.return_value = []
+ auth_manager.get_fastapi_middlewares.return_value = []
+ mock_get_auth_manager.return_value = auth_manager
+ init_middlewares(app)
+ auth_manager.get_jwt_refresh_middleware.assert_called_once()
+ assert [m.cls for m in app.user_middleware] == [GZipMiddleware]
diff --git a/airflow-core/tests/unit/api_fastapi/test_app.py
b/airflow-core/tests/unit/api_fastapi/test_app.py
index 82e0674bafe..e0fb816c8c5 100644
--- a/airflow-core/tests/unit/api_fastapi/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/test_app.py
@@ -347,6 +347,22 @@ class TestGetCookiePath:
assert app_module.get_cookie_path() == "/org/team-a/airflow/"
[email protected](
+ ("scheme", "ssl_cert", "exp"),
+ [
+ ("http", "", False),
+ ("https", "", True),
+ ("http", "/foo/bar/cert.crt", True),
+ ("https", "/foo/bar/cert/crt", True),
+ ],
+)
+def test_request_cookie_is_secure(scheme, ssl_cert, exp):
+ request = mock.Mock()
+ request.base_url.scheme = scheme
+ with conf_vars({("api", "ssl_cert"): ssl_cert}):
+ assert app_module.request_cookie_is_secure(request) == exp
+
+
def test_create_auth_manager_thread_safety():
"""Concurrent calls to create_auth_manager must return the same singleton
instance."""
call_count = 0