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

vincbeck 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 7cae03be33f Speed up API server requests by keeping JWT checks off the 
event loop (#74856)
7cae03be33f is described below

commit 7cae03be33f3d3162926f314db32bfe2c05f9a4b
Author: safaehar <[email protected]>
AuthorDate: Mon Oct 19 15:59:10 2026 +0200

    Speed up API server requests by keeping JWT checks off the event loop 
(#74856)
    
    Static UI assets, the health check and the version endpoint never read the
    user, yet every one of these requests carrying the session cookie decoded 
the
    JWT and queried the revoked token table. The middleware also added a
    BaseHTTPMiddleware task and body stream to every request, including the 
large
    streamed UI bundle, so a cold UI load serialized on each API server worker.
    
    Auth managers may block in refresh_user (for example when calling an 
identity
    provider), which stalled every concurrent request on the worker.
---
 .../api_fastapi/auth/managers/base_auth_manager.py |   4 +-
 .../api_fastapi/auth/middlewares/refresh_token.py  | 154 +++++---
 .../src/airflow/api_fastapi/core_api/app.py        |   6 +-
 .../auth/managers/test_base_auth_manager.py        |  28 ++
 .../auth/middlewares/test_refresh_token.py         | 387 +++++++++++----------
 airflow-core/tests/unit/api_fastapi/test_app.py    |  10 +
 6 files changed, 348 insertions(+), 241 deletions(-)

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 77c0ef9a141..94b96ba5af2 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
@@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, 
TypeVar
 
 from jwt import InvalidTokenError
 from sqlalchemy import select
+from starlette.concurrency import run_in_threadpool
 
 from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
 from airflow.api_fastapi.auth.managers.models.resource_details import (
@@ -162,7 +163,8 @@ class BaseAuthManager(Generic[T], LoggingMixin, 
metaclass=ABCMeta):
             log.error("JWT token is not valid: %s", e)
             raise e
 
-        if (jti := payload.get("jti")) and RevokedToken.is_revoked(jti):
+        # A synchronous DB lookup, kept off the event loop
+        if (jti := payload.get("jti")) and await 
run_in_threadpool(RevokedToken.is_revoked, jti):
             raise InvalidTokenError("Token has been revoked")
 
         try:
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 7c9bfdcb10a..2e97e6c25c1 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
@@ -17,9 +17,12 @@
 # under the License.
 from __future__ import annotations
 
-from fastapi import HTTPException, Request
+from typing import TYPE_CHECKING
+
+from fastapi import HTTPException, Request, Response
 from fastapi.responses import JSONResponse
-from starlette.middleware.base import BaseHTTPMiddleware
+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.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
@@ -31,8 +34,15 @@ from airflow.api_fastapi.core_api.security import (
 )
 from airflow.configuration import conf
 
+if TYPE_CHECKING:
+    from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+# 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"])
 
-class JWTRefreshMiddleware(BaseHTTPMiddleware):
+
+class JWTRefreshMiddleware:
     """
     Middleware to handle JWT token refresh.
 
@@ -41,62 +51,100 @@ class JWTRefreshMiddleware(BaseHTTPMiddleware):
     2. Calls ``refresh_user`` method from auth manager with the user
     3. If ``refresh_user`` returns a user, generate a JWT token based upon 
this user and send it in the
        response as cookie
+
+    Requests to ``UNAUTHENTICATED_PATHS`` or under ``skip_path_prefixes`` 
(paths relative to the
+    root path) are passed through untouched.
     """
 
-    async def dispatch(self, request: Request, call_next):
-        new_token = None
+    def __init__(self, app: ASGIApp, skip_path_prefixes: tuple[str, ...] = ()) 
-> None:
+        self.app = app
+        self.skip_path_prefixes = skip_path_prefixes
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> 
None:
+        if scope["type"] != "http" or self._is_skipped(scope):
+            await self.app(scope, receive, send)
+            return
+
+        request = Request(scope)
         current_token = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
+        if current_token is None:
+            await self.app(scope, receive, send)
+            return
+
+        new_token = None
+        try:
+            new_user, current_user = await self._refresh_user(current_token)
+            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 = ""
+
+        cookie_headers = self._build_cookie_headers(request, new_token) if 
new_token is not None else []
+        response_started = False
+
+        async def send_with_cookies(message: Message) -> None:
+            nonlocal response_started
+            if message["type"] == "http.response.start":
+                response_started = True
+                headers = MutableHeaders(scope=message)
+                for value in cookie_headers:
+                    headers.append("set-cookie", value)
+            await send(message)
+
         try:
-            if current_token is not None:
-                try:
-                    new_user, current_user = await 
self._refresh_user(current_token)
-                    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 = ""
-
-            response = await call_next(request)
-
-            if new_token is not None:
-                cookie_path = get_cookie_path()
-                secure = request.base_url.scheme == "https" or 
bool(conf.get("api", "ssl_cert", fallback=""))
-                response.set_cookie(
-                    COOKIE_NAME_JWT_TOKEN,
-                    new_token,
-                    path=cookie_path,
-                    httponly=True,
-                    secure=secure,
-                    samesite="lax",
-                    max_age=0 if new_token == "" else None,
-                )
-                # Clear any stale _token cookie at root path "/".
-                # Older Airflow instances may have set the cookie there;
-                # without this, the root-path cookie keeps being sent on
-                # every request, causing an infinite redirect loop.
-                if cookie_path != "/":
-                    response.delete_cookie(
-                        key=COOKIE_NAME_JWT_TOKEN,
-                        path="/",
-                        httponly=True,
-                        secure=secure,
-                        samesite="lax",
-                    )
+            await self.app(scope, receive, send_with_cookies)
         except HTTPException as exc:
-            # If any HTTPException is raised during user resolution or 
refresh, return it as response
-            return JSONResponse(status_code=exc.status_code, 
content={"detail": exc.detail})
-        return response
+            if response_started:
+                raise
+            # An HTTPException escaping the inner middlewares is returned as a 
response
+            response = JSONResponse(status_code=exc.status_code, 
content={"detail": exc.detail})
+            await response(scope, receive, send)
+
+    def _is_skipped(self, scope: Scope) -> bool:
+        path = scope["path"].removeprefix(scope.get("root_path", ""))
+        return path in UNAUTHENTICATED_PATHS or 
path.startswith(self.skip_path_prefixes)
+
+    @staticmethod
+    def _build_cookie_headers(request: Request, new_token: str) -> list[str]:
+        cookie_path = get_cookie_path()
+        secure = request.base_url.scheme == "https" or bool(conf.get("api", 
"ssl_cert", fallback=""))
+        # Only used to render the Set-Cookie header values
+        response = Response()
+        response.set_cookie(
+            COOKIE_NAME_JWT_TOKEN,
+            new_token,
+            path=cookie_path,
+            httponly=True,
+            secure=secure,
+            samesite="lax",
+            max_age=0 if new_token == "" else None,
+        )
+        # Clear any stale _token cookie at root path "/".
+        # Older Airflow instances may have set the cookie there;
+        # without this, the root-path cookie keeps being sent on
+        # every request, causing an infinite redirect loop.
+        if cookie_path != "/":
+            response.delete_cookie(
+                key=COOKIE_NAME_JWT_TOKEN,
+                path="/",
+                httponly=True,
+                secure=secure,
+                samesite="lax",
+            )
+        return [value.decode("latin-1") for key, value in response.raw_headers 
if key == b"set-cookie"]
 
     @staticmethod
     async def _refresh_user(current_token: str) -> tuple[BaseUser | None, 
BaseUser | None]:
         user = await resolve_user_from_token(current_token)
-        return get_auth_manager().refresh_user(user=user), user
+        # 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 d3a66a5b68d..82dfa727ad2 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -38,6 +38,8 @@ log = logging.getLogger(__name__)
 
 _AIRFLOW_PATH = Path(__file__).parents[3]
 
+_STATIC_URL_PATH = "/static"
+
 # Language codes ("en", "zh-CN") and namespaces ("common") of the UI 
translation files. Anchored
 # so a path parameter cannot smuggle in path separators or ``..`` and escape 
the locales directory.
 _I18N_SEGMENT = re.compile(r"^[A-Za-z0-9_-]+$")
@@ -168,7 +170,7 @@ def init_views(app: FastAPI) -> None:
         )
 
     app.mount(
-        "/static",
+        _STATIC_URL_PATH,
         StaticFiles(
             directory=directory,
             html=True,
@@ -282,7 +284,7 @@ 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)
+    app.add_middleware(JWTRefreshMiddleware, 
skip_path_prefixes=(f"{_STATIC_URL_PATH}/",))
 
     for middleware_cls, middleware_kwargs in 
get_auth_manager().get_fastapi_middlewares():
         app.add_middleware(middleware_cls, **middleware_kwargs)
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 49e3699f203..18a9ceeecd2 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
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import threading
 import warnings
 from typing import TYPE_CHECKING, Any
 from unittest.mock import AsyncMock, MagicMock, Mock, create_autospec, patch
@@ -335,6 +336,33 @@ class TestBaseAuthManager:
 
         mock_is_revoked.assert_called_once_with("some-jti")
 
+    @patch(
+        
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager._get_token_validator",
+        autospec=True,
+    )
+    @patch.object(EmptyAuthManager, "deserialize_user")
+    @pytest.mark.asyncio
+    async def test_get_user_from_token_checks_revocation_off_the_event_loop(
+        self, mock_deserialize_user, mock__get_token_validator, auth_manager
+    ):
+        """The revocation lookup is a synchronous DB query, so it must not 
block the event loop."""
+        signer = AsyncMock(spec=JWTValidator)
+        signer.avalidated_claims.return_value = {"jti": "some-jti"}
+        mock__get_token_validator.return_value = signer
+        lookup_threads = []
+
+        def is_revoked(jti):
+            lookup_threads.append(threading.get_ident())
+            return False
+
+        with patch(
+            "airflow.models.revoked_token.RevokedToken.is_revoked", 
autospec=True, side_effect=is_revoked
+        ):
+            await auth_manager.get_user_from_token("token")
+
+        assert lookup_threads
+        assert lookup_threads != [threading.get_ident()]
+
     @patch(
         
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager._get_token_validator",
         autospec=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 9114ce0d938..346c1e8dc61 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
@@ -17,125 +17,199 @@
 # under the License.
 from __future__ import annotations
 
-from unittest.mock import AsyncMock, MagicMock, patch
+import threading
+from unittest.mock import MagicMock, patch
 
 import pytest
-from fastapi import HTTPException, Request, Response
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.responses import PlainTextResponse, StreamingResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.testclient import TestClient
 
-from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
+from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN, BaseAuthManager
 from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
 from airflow.api_fastapi.auth.middlewares.refresh_token import 
JWTRefreshMiddleware
+from airflow.api_fastapi.core_api.security import 
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+
+MODULE = "airflow.api_fastapi.auth.middlewares.refresh_token"
+
+
+def _build_app(static_dir=None, root_path: str = "") -> tuple[FastAPI, dict]:
+    """Return an app wrapped by the middleware and a dict capturing what the 
endpoint saw."""
+    seen: dict = {}
+    app = FastAPI(root_path=root_path)
+
+    @app.get("/api/v2/dags")
+    def dags(request: Request):
+        seen["user"] = getattr(request.state, "user", None)
+        seen["authenticated_via"] = getattr(request.state, 
"user_authenticated_via", None)
+        return PlainTextResponse("ok")
+
+    @app.get("/api/v2/monitor/health")
+    def health():
+        return PlainTextResponse("healthy")
+
+    @app.get("/api/v2/version")
+    def version():
+        return PlainTextResponse("3.x")
+
+    @app.get("/api/v2/stream")
+    def stream():
+        return StreamingResponse(iter([b"chunk1-", b"chunk2-", b"chunk3"]), 
media_type="text/plain")
+
+    if static_dir is not None:
+        app.mount("/static", StaticFiles(directory=static_dir), 
name="webapp_static_folder")
+
+    app.add_middleware(JWTRefreshMiddleware, skip_path_prefixes=("/static/",))
+    return app, seen
+
+
[email protected]
+def mock_auth_manager():
+    with patch(f"{MODULE}.get_auth_manager", autospec=True) as 
mock_get_auth_manager:
+        auth_manager = MagicMock(spec=BaseAuthManager)
+        auth_manager.refresh_user.return_value = None
+        mock_get_auth_manager.return_value = auth_manager
+        yield auth_manager
+
+
[email protected]
+def mock_resolve_user():
+    with patch(f"{MODULE}.resolve_user_from_token", autospec=True) as 
mock_resolve:
+        mock_resolve.return_value = MagicMock(spec=BaseUser)
+        yield mock_resolve
+
+
[email protected](autouse=True)
+def no_ssl_cert():
+    with patch(f"{MODULE}.conf", autospec=True) as mock_conf:
+        mock_conf.get.return_value = ""
+        yield mock_conf
 
 
 class TestJWTRefreshMiddleware:
-    @pytest.fixture
-    def middleware(self):
-        return JWTRefreshMiddleware(app=MagicMock())
-
-    @pytest.fixture
-    def mock_request(self):
-        request = MagicMock(spec=Request)
-        request.cookies = {}
-        request.state = MagicMock()
-        return request
-
-    @pytest.fixture
-    def mock_user(self):
-        return MagicMock(spec=BaseUser)
-
-    @patch.object(JWTRefreshMiddleware, "_refresh_user")
-    @pytest.mark.asyncio
-    async def test_dispatch_no_token(self, mock_refresh_user, middleware, 
mock_request):
-        call_next = AsyncMock(return_value=Response())
-
-        await middleware.dispatch(mock_request, call_next)
-
-        call_next.assert_called_once_with(mock_request)
-        mock_refresh_user.assert_not_called()
-
-    @patch.object(
-        JWTRefreshMiddleware,
-        "_refresh_user",
-        side_effect=HTTPException(status_code=403, detail="Invalid JWT token"),
-    )
-    @pytest.mark.asyncio
-    async def test_dispatch_invalid_token(self, mock_refresh_user, middleware, 
mock_request):
-        mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "valid_token"}
-        call_next = AsyncMock(return_value=Response(status_code=401))
-
-        response = await middleware.dispatch(mock_request, call_next)
-        assert response.status_code == 401
-        assert '_token=""; HttpOnly; Max-Age=0; Path=/; SameSite=lax' in 
response.headers.get("set-cookie")
-
-    
@patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_auth_manager")
-    
@patch("airflow.api_fastapi.auth.middlewares.refresh_token.resolve_user_from_token")
-    @pytest.mark.asyncio
-    async def test_dispatch_no_refreshed_token(
-        self, mock_resolve_user_from_token, mock_get_auth_manager, middleware, 
mock_request, mock_user
-    ):
-        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 = None
+    def test_no_token(self, mock_resolve_user, mock_auth_manager):
+        app, _ = _build_app()
 
-        call_next = AsyncMock(return_value=Response())
-        await middleware.dispatch(mock_request, call_next)
+        response = TestClient(app).get("/api/v2/dags")
 
-        call_next.assert_called_once_with(mock_request)
-        mock_resolve_user_from_token.assert_called_once_with("valid_token")
+        assert response.status_code == 200
+        assert "set-cookie" not in response.headers
+        mock_resolve_user.assert_not_called()
+
+    def test_invalid_token_clears_cookie(self, mock_resolve_user, 
mock_auth_manager):
+        mock_resolve_user.side_effect = HTTPException(status_code=403, 
detail="Invalid JWT token")
+        app, seen = _build_app()
+
+        response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"invalid_token"}).get("/api/v2/dags")
+
+        assert response.status_code == 200
+        assert seen["user"] is None
+        assert '_token=""; HttpOnly; Max-Age=0; Path=/; SameSite=lax' in 
response.headers["set-cookie"]
+        mock_auth_manager.refresh_user.assert_not_called()
+
+    def test_no_refreshed_user(self, mock_resolve_user, mock_auth_manager):
+        app, seen = _build_app()
+
+        response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"valid_token"}).get("/api/v2/dags")
+
+        assert "set-cookie" not in response.headers
+        assert seen["user"] is mock_resolve_user.return_value
+        assert seen["authenticated_via"] is USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+        mock_resolve_user.assert_called_once_with("valid_token")
         mock_auth_manager.generate_jwt.assert_not_called()
 
-    
@patch("airflow.api_fastapi.auth.middlewares.refresh_token.resolve_user_from_token")
-    @pytest.mark.asyncio
-    async def test_dispatch_expired_token(self, mock_resolve_user_from_token, 
middleware, mock_request):
-        mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "invalid_token"}
-        mock_resolve_user_from_token.side_effect = 
HTTPException(status_code=403)
-
-        call_next = AsyncMock(return_value=Response())
-        await middleware.dispatch(mock_request, call_next)
-
-        call_next.assert_called_once_with(mock_request)
-        mock_resolve_user_from_token.assert_called_once_with("invalid_token")
-
-    @pytest.mark.asyncio
-    
@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")
-    async def test_dispatch_with_refreshed_user(
-        self,
-        mock_conf,
-        mock_resolve_user_from_token,
-        mock_get_auth_manager,
-        middleware,
-        mock_request,
-        mock_user,
-    ):
+    def test_refreshed_user(self, mock_resolve_user, mock_auth_manager):
         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)
+        app, seen = _build_app()
 
-        from airflow.api_fastapi.core_api.security import 
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+        response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"valid_token"}).get("/api/v2/dags")
 
-        assert mock_request.state.user == refreshed_user
         # 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 mock_request.state.user_authenticated_via is 
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
-        call_next.assert_called_once_with(mock_request)
-        mock_resolve_user_from_token.assert_called_once_with("valid_token")
-        mock_auth_manager.refresh_user.assert_called_once_with(user=mock_user)
+        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)
-        set_cookie_headers = response.headers.get("set-cookie", "")
-        assert f"{COOKIE_NAME_JWT_TOKEN}=new_token" in set_cookie_headers
+        assert response.headers["set-cookie"] == "_token=new_token; HttpOnly; 
Path=/; SameSite=lax"
+
+    def test_refresh_user_runs_off_the_event_loop(self, mock_resolve_user, 
mock_auth_manager):
+        """``refresh_user`` is synchronous and may do blocking I/O, so it must 
not block the loop."""
+        refresh_threads = []
+        mock_auth_manager.refresh_user.side_effect = lambda user: 
refresh_threads.append(
+            threading.get_ident()
+        )
+        loop_threads = []
+        mock_resolve_user.side_effect = lambda token: 
loop_threads.append(threading.get_ident())
+        app, _ = _build_app()
+
+        TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"valid_token"}).get("/api/v2/dags")
+
+        assert len(refresh_threads) == 1
+        assert refresh_threads != loop_threads
+
+    def test_streaming_response_passthrough(self, mock_resolve_user, 
mock_auth_manager):
+        mock_auth_manager.refresh_user.return_value = MagicMock(spec=BaseUser)
+        mock_auth_manager.generate_jwt.return_value = "new_token"
+        app, _ = _build_app()
+
+        response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"valid_token"}).get("/api/v2/stream")
+
+        assert response.content == b"chunk1-chunk2-chunk3"
+        assert response.headers["content-type"].startswith("text/plain")
+        assert "_token=new_token" in response.headers["set-cookie"]
+
+    def test_http_exception_from_inner_app_returned_as_response(self, 
mock_resolve_user, mock_auth_manager):
+        async def raising_app(scope, receive, send):
+            raise HTTPException(status_code=418, detail="teapot")
+
+        app = JWTRefreshMiddleware(raising_app)
+
+        response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"valid_token"}).get("/api/v2/dags")
+
+        assert response.status_code == 418
+        assert response.json() == {"detail": "teapot"}
+
+    @pytest.mark.parametrize("root_path", ["", "/team-a"])
+    @pytest.mark.parametrize("path", ["/api/v2/monitor/health", 
"/api/v2/version", "/static/main.js"])
+    def test_skipped_paths(self, mock_resolve_user, mock_auth_manager, 
tmp_path, path, root_path):
+        """Static assets and unauthenticated endpoints never decode the JWT or 
hit the DB."""
+        (tmp_path / "main.js").write_text("console.log('hi');")
+        app, _ = _build_app(static_dir=tmp_path, root_path=root_path)
+        client = TestClient(app, root_path=root_path, 
cookies={COOKIE_NAME_JWT_TOKEN: "valid_token"})
+
+        response = client.get(f"{root_path}{path}")
+
+        assert response.status_code == 200
+        assert "set-cookie" not in response.headers
+        mock_resolve_user.assert_not_called()
+        mock_auth_manager.refresh_user.assert_not_called()
+
+    @pytest.mark.parametrize(
+        ("headers", "expected_status", "expected_body"),
+        [
+            pytest.param({}, 200, b"0123456789", id="full"),
+            pytest.param({"range": "bytes=2-4"}, 206, b"234", id="range"),
+        ],
+    )
+    def test_static_files_served_unchanged(
+        self, mock_resolve_user, tmp_path, headers, expected_status, 
expected_body
+    ):
+        (tmp_path / "main.js").write_bytes(b"0123456789")
+        app, _ = _build_app(static_dir=tmp_path)
+        client = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"valid_token"})
+
+        response = client.get("/static/main.js", headers=headers)
+
+        assert response.status_code == expected_status
+        assert response.content == expected_body
+        assert "etag" in response.headers
+        assert "last-modified" in response.headers
+        revalidated = client.get("/static/main.js", headers={"if-none-match": 
response.headers["etag"]})
+        assert revalidated.status_code == 304
 
     @pytest.mark.parametrize(
         ("scheme", "ssl_cert", "expected_secure"),
@@ -146,103 +220,46 @@ class TestJWTRefreshMiddleware:
             pytest.param("http", "", False, id="http-no-local-ssl-cert"),
         ],
     )
-    
@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_secure_flag(
-        self,
-        mock_conf,
-        mock_resolve_user_from_token,
-        mock_get_auth_manager,
-        middleware,
-        mock_request,
-        mock_user,
-        scheme,
-        ssl_cert,
-        expected_secure,
+    def test_cookie_secure_flag(
+        self, mock_resolve_user, mock_auth_manager, no_ssl_cert, scheme, 
ssl_cert, expected_secure
     ):
         """The cookie Secure flag must follow the request scheme as well as 
the local ssl_cert."""
-        refreshed_user = MagicMock(spec=BaseUser)
-        mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "valid_token"}
-        mock_request.base_url = MagicMock(scheme=scheme)
-        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.refresh_user.return_value = MagicMock(spec=BaseUser)
         mock_auth_manager.generate_jwt.return_value = "new_token"
-        mock_conf.get.return_value = ssl_cert
-
-        call_next = AsyncMock(return_value=Response())
-        response = await middleware.dispatch(mock_request, call_next)
-
-        set_cookie_headers = response.headers.get("set-cookie", "")
-        if expected_secure:
-            assert "Secure" in set_cookie_headers
-        else:
-            assert "Secure" not 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 = ""
+        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"}
+        )
 
-        call_next = AsyncMock(return_value=Response())
-        response = await middleware.dispatch(mock_request, call_next)
+        response = client.get("/api/v2/dags")
 
-        set_cookie_headers = response.headers.getlist("set-cookie")
-        assert any("Path=/team-a/" in h for h in set_cookie_headers)
-        # Stale root-path cookie must also be cleared
-        assert any(
-            "Path=/" in h and "Path=/team-a/" not in h and "Max-Age=0" in h 
for h in set_cookie_headers
-        )
+        assert ("Secure" in response.headers["set-cookie"]) is expected_secure
 
-    
@patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_cookie_path", 
return_value="/team-a/")
-    @patch.object(
-        JWTRefreshMiddleware,
-        "_refresh_user",
-        side_effect=HTTPException(status_code=403, detail="Invalid JWT token"),
+    @pytest.mark.parametrize(
+        "refresh_outcome",
+        [
+            pytest.param("refreshed", id="refreshed-token"),
+            pytest.param("invalid", id="invalid-token"),
+        ],
     )
-    @patch("airflow.api_fastapi.auth.middlewares.refresh_token.conf")
-    @pytest.mark.asyncio
-    async def test_dispatch_invalid_token_clears_root_cookie(
-        self,
-        mock_conf,
-        mock_refresh_user,
-        mock_cookie_path,
-        middleware,
-        mock_request,
+    @patch(f"{MODULE}.get_cookie_path", autospec=True, return_value="/team-a/")
+    def test_cookie_uses_subpath_and_clears_root_cookie(
+        self, mock_cookie_path, mock_resolve_user, mock_auth_manager, 
refresh_outcome
     ):
-        """When a stale _token exists at root path, clearing must target both 
the subpath and root."""
-        mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "stale_root_token"}
-        mock_conf.get.return_value = ""
+        """With a subpath, the cookie is scoped to it and a stale root-path 
cookie is cleared."""
+        if refresh_outcome == "refreshed":
+            mock_auth_manager.refresh_user.return_value = 
MagicMock(spec=BaseUser)
+            mock_auth_manager.generate_jwt.return_value = "new_token"
+        else:
+            mock_resolve_user.side_effect = HTTPException(status_code=403, 
detail="Invalid JWT token")
+        app, _ = _build_app()
 
-        call_next = AsyncMock(return_value=Response(status_code=401))
-        response = await middleware.dispatch(mock_request, call_next)
+        response = TestClient(app, cookies={COOKIE_NAME_JWT_TOKEN: 
"token"}).get("/api/v2/dags")
 
-        set_cookie_headers = response.headers.getlist("set-cookie")
-        # Expect two delete cookies: one at the subpath and one at root "/"
-        assert any("Path=/team-a/" in h and "Max-Age=0" in h for h in 
set_cookie_headers)
+        set_cookie_headers = response.headers.get_list("set-cookie")
+        assert len(set_cookie_headers) == 2
+        assert any("Path=/team-a/" in h for h in set_cookie_headers)
         assert any(
             "Path=/" in h and "Path=/team-a/" not in h and "Max-Age=0" in h 
for h in set_cookie_headers
         )
diff --git a/airflow-core/tests/unit/api_fastapi/test_app.py 
b/airflow-core/tests/unit/api_fastapi/test_app.py
index c994f90a0d5..c5d5e33222f 100644
--- a/airflow-core/tests/unit/api_fastapi/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/test_app.py
@@ -31,6 +31,7 @@ from starlette.middleware.gzip import GZipMiddleware
 import airflow.api_fastapi.app as app_module
 import airflow.plugins_manager as plugins_manager
 from airflow import settings
+from airflow.api_fastapi.auth.middlewares.refresh_token import 
JWTRefreshMiddleware
 from airflow.api_fastapi.common.http_access_log import HttpAccessLogMiddleware
 from airflow.api_fastapi.common.http_metrics import HttpMetricsMiddleware
 from airflow.utils.session import create_session_async
@@ -212,6 +213,15 @@ def 
test_api_metrics_middleware_skipped_without_a_metrics_backend(apps):
     assert HttpMetricsMiddleware not in installed
 
 
+def test_jwt_refresh_middleware_skips_static_mount():
+    """Static UI assets are served without resolving the user from the JWT 
cookie."""
+    [middleware] = [
+        m for m in app_module.create_app(apps="core").user_middleware if m.cls 
is JWTRefreshMiddleware
+    ]
+
+    assert middleware.kwargs == {"skip_path_prefixes": ("/static/",)}
+
+
 def test_catch_all_route_last():
     """
     Ensure the catch all route that returns the initial html is the last route 
in the fastapi app.

Reply via email to