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 22f6bb06664 Stop Keycloak middleware from reissuing a JWT revoked 
during the request (#73695)
22f6bb06664 is described below

commit 22f6bb06664a7e7c85a00e7ee5908e017e4ec818
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Sep 27 16:25:23 2026 +0200

    Stop Keycloak middleware from reissuing a JWT revoked during the request 
(#73695)
    
    When the Keycloak access token had expired, KeycloakJWTMiddleware refreshed
    the user before the endpoint ran and, after it returned, always set a newly
    signed Airflow JWT on the response. On /auth/logout the endpoint revokes the
    JWT the request came with, so the middleware replaced the revoked token with
    a fresh, unrevoked one and the session stayed usable after logout.
    
    After the endpoint returns, the middleware now checks that the JWT sent with
    the request is still accepted before issuing a replacement. If it has been
    revoked, no new JWT is issued and the Airflow and Keycloak token cookies are
    cleared instead. A JWT that merely expired while the request was handled is
    still replaced as before.
    
    Generated-by: Claude Opus 5 following the guidelines at
    
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
---
 .../providers/keycloak/auth_manager/middleware.py  | 35 +++++++-
 .../unit/keycloak/auth_manager/test_middleware.py  | 97 +++++++++++++++++++++-
 2 files changed, 128 insertions(+), 4 deletions(-)

diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/middleware.py 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/middleware.py
index f60dd63d2cb..abe0db1e0fb 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/middleware.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/middleware.py
@@ -72,6 +72,7 @@ class KeycloakJWTMiddleware(BaseHTTPMiddleware):
         user = None
         new_token = None
         new_user = None
+        session_invalidated = False
         try:
             try:
                 new_user, current_user = await self._refresh_user(request)
@@ -98,7 +99,14 @@ class KeycloakJWTMiddleware(BaseHTTPMiddleware):
             if new_token == "" and getattr(request.state, "jwt_token_issued", 
False):
                 new_token = None
 
-            if new_user or new_token is not None:
+            if new_user and not await self._is_jwt_still_valid(request):
+                # The Airflow JWT sent with the request was invalidated while 
the request was
+                # handled (e.g. by the logout route). Do not replace it with a 
freshly signed
+                # one -- that would keep the session alive -- and clear the 
session cookies.
+                new_user = None
+                session_invalidated = True
+
+            if new_user or new_token is not None or session_invalidated:
                 secure = request.base_url.scheme == "https" or 
bool(conf.get("api", "ssl_cert", fallback=""))
                 cookie_path = get_cookie_path()
                 if new_token == "":
@@ -227,6 +235,31 @@ class KeycloakJWTMiddleware(BaseHTTPMiddleware):
             )
         return response
 
+    @staticmethod
+    async def _is_jwt_still_valid(request: Request) -> bool:
+        """
+        Check whether the Airflow JWT sent with the request is still accepted.
+
+        It was valid when the request came in, so it is rejected now only if 
it was revoked
+        while the request was handled. Expiry in the meantime is not treated 
as revocation:
+        the refreshed session replaces the token anyway.
+        """
+        jwt_token = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
+        if not jwt_token:
+            return True
+        auth_manager = cast("KeycloakAuthManager", get_auth_manager())
+        try:
+            await auth_manager.get_user_from_token(
+                jwt_token,
+                request.cookies.get(COOKIE_NAME_ACCESS_TOKEN),
+                request.cookies.get(COOKIE_NAME_REFRESH_TOKEN),
+            )
+        except ExpiredSignatureError:
+            return True
+        except InvalidTokenError:
+            return False
+        return True
+
     @staticmethod
     async def _refresh_user(
         request: Request,
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/test_middleware.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/test_middleware.py
index 52199dd1fd7..31fa4ae456a 100644
--- a/providers/keycloak/tests/unit/keycloak/auth_manager/test_middleware.py
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_middleware.py
@@ -16,11 +16,11 @@
 # under the License.
 from __future__ import annotations
 
-from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
 
 import pytest
 from fastapi import Request
-from jwt import InvalidTokenError
+from jwt import ExpiredSignatureError, InvalidTokenError
 
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 from airflow.api_fastapi.core_api import security as core_api_security
@@ -185,11 +185,102 @@ class TestKeycloakJWTMiddleware:
         else:
             assert not hasattr(mock_request.state, "user_authenticated_via")
 
-        auth_manager.get_user_from_token.assert_called_once_with("token", 
"access_token", "refresh_token")
+        # Once to resolve the user, once more after the endpoint ran to 
confirm the
+        # token is still accepted before a replacement is issued.
+        assert auth_manager.get_user_from_token.await_args_list == [
+            call("token", "access_token", "refresh_token"),
+            call("token", "access_token", "refresh_token"),
+        ]
         auth_manager.refresh_user.assert_called_once_with(user=mock_user)
         auth_manager.generate_jwt.assert_called_once_with(new_user)
         call_next.assert_awaited_once_with(mock_request)
 
+    
@patch("airflow.providers.keycloak.auth_manager.middleware.get_auth_manager")
+    async def test_no_new_token_when_jwt_revoked_during_request(
+        self,
+        mock_get_auth_manager,
+        auth_manager,
+        call_next,
+        mock_request,
+        middleware,
+        mock_user,
+        secure,
+    ):
+        """
+        When the endpoint revokes the token the request came with (the logout 
route does),
+        a refreshed session must not be turned into a new JWT; the session 
cookies are cleared.
+        """
+        new_user = Mock(name="user", spec=KeycloakAuthManagerUser)
+        new_user.access_token = "new_access_token"
+        new_user.refresh_token = "new_refresh_token"
+        auth_manager.get_user_from_token = AsyncMock(return_value=mock_user)
+        auth_manager.refresh_user = Mock(return_value=new_user)
+        auth_manager.generate_jwt.return_value = "new_token"
+        mock_get_auth_manager.return_value = auth_manager
+
+        mock_request.cookies = {
+            COOKIE_NAME_JWT_TOKEN: "token",
+            COOKIE_NAME_ACCESS_TOKEN: "access_token",
+            COOKIE_NAME_REFRESH_TOKEN: "refresh_token",
+        }
+
+        async def logout_endpoint(request):
+            # The endpoint revokes the presented token, so it is rejected from 
now on.
+            auth_manager.get_user_from_token.side_effect = 
InvalidTokenError("Token has been revoked")
+            return Mock(name="response")
+
+        call_next.side_effect = logout_endpoint
+
+        response = await middleware.dispatch(mock_request, call_next)
+
+        auth_manager.generate_jwt.assert_not_called()
+        for cookie_name in (COOKIE_NAME_JWT_TOKEN, COOKIE_NAME_ACCESS_TOKEN, 
COOKIE_NAME_REFRESH_TOKEN):
+            assert any(
+                c.args[0] == cookie_name and c.args[1] == "" and 
c.kwargs.get("max_age") == 0
+                for c in response.set_cookie.call_args_list
+            ), f"{cookie_name} cookie was not cleared"
+        issued_values = {c.args[1] for c in response.set_cookie.call_args_list 
if len(c.args) > 1}
+        assert issued_values.isdisjoint({"new_token", "new_access_token", 
"new_refresh_token"})
+
+    
@patch("airflow.providers.keycloak.auth_manager.middleware.get_auth_manager")
+    async def test_new_token_when_jwt_expired_during_request(
+        self,
+        mock_get_auth_manager,
+        auth_manager,
+        call_next,
+        mock_request,
+        middleware,
+        mock_user,
+        secure,
+    ):
+        """A token that merely expired while the request was handled is still 
replaced."""
+        new_user = Mock(name="user", spec=KeycloakAuthManagerUser)
+        new_user.access_token = "new_access_token"
+        new_user.refresh_token = "new_refresh_token"
+        auth_manager.get_user_from_token = AsyncMock(side_effect=[mock_user, 
ExpiredSignatureError()])
+        auth_manager.refresh_user = Mock(return_value=new_user)
+        auth_manager.generate_jwt.return_value = "new_token"
+        mock_get_auth_manager.return_value = auth_manager
+
+        mock_request.cookies = {
+            COOKIE_NAME_JWT_TOKEN: "token",
+            COOKIE_NAME_ACCESS_TOKEN: "access_token",
+            COOKIE_NAME_REFRESH_TOKEN: "refresh_token",
+        }
+
+        response = await middleware.dispatch(mock_request, call_next)
+
+        auth_manager.generate_jwt.assert_called_once_with(new_user)
+        response.set_cookie.assert_any_call(
+            COOKIE_NAME_JWT_TOKEN,
+            "new_token",
+            path="/",
+            secure=secure,
+            samesite="lax",
+            httponly=True,
+            max_age=None,
+        )
+
     
@patch("airflow.providers.keycloak.auth_manager.middleware.get_auth_manager")
     async def test_no_keycloak_token(
         self, mock_get_auth_manager, auth_manager, call_next, middleware, 
mock_request, secure

Reply via email to