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