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 85a4da7eb4e Fix login loop when the login callback carries an expired
Airflow JWT (#71506)
85a4da7eb4e is described below
commit 85a4da7eb4e06f4604c102017b6bfa72566c1a1c
Author: Mathieu Monet <[email protected]>
AuthorDate: Thu Aug 13 15:11:37 2026 +0200
Fix login loop when the login callback carries an expired Airflow JWT
(#71506)
KeycloakJWTMiddleware clears the JWT cookie when token validation fails.
When the failing token arrives on the login callback request itself (a
re-login after the previous JWT expired, with the stale cookie still in
the browser jar), the clearing Set-Cookie is appended after the fresh
token the callback just set. The browser applies the deletion last, the
new session dies in the response that created it, and the user is sent
back to login indefinitely.
#71077 fixed the same clobber for requests carrying no token at all;
this covers the remaining path where an expired token is present.
The login callback now signals via request.state.jwt_token_issued that
the response carries a freshly issued JWT, and the middleware skips the
cookie clear in that case.
---
.../providers/keycloak/auth_manager/middleware.py | 3 ++
.../keycloak/auth_manager/routes/login.py | 1 +
.../unit/keycloak/auth_manager/test_middleware.py | 34 ++++++++++++++++++++++
3 files changed, 38 insertions(+)
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 1ad6f1eafa9..f60dd63d2cb 100644
---
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/middleware.py
+++
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/middleware.py
@@ -95,6 +95,9 @@ class KeycloakJWTMiddleware(BaseHTTPMiddleware):
response = await call_next(request)
+ if new_token == "" and getattr(request.state, "jwt_token_issued",
False):
+ new_token = None
+
if new_user or new_token is not None:
secure = request.base_url.scheme == "https" or
bool(conf.get("api", "ssl_cert", fallback=""))
cookie_path = get_cookie_path()
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 0728518ab3c..fdb73e6c966 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
@@ -126,6 +126,7 @@ def login_callback(request: Request):
refresh_token=tokens["refresh_token"],
)
token = get_auth_manager().generate_jwt(user)
+ request.state.jwt_token_issued = True
response = RedirectResponse(url=conf.get("api", "base_url", fallback="/"),
status_code=303)
secure = request.base_url.scheme == "https" or bool(conf.get("api",
"ssl_cert", fallback=""))
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 66f610ae308..52199dd1fd7 100644
--- a/providers/keycloak/tests/unit/keycloak/auth_manager/test_middleware.py
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_middleware.py
@@ -340,6 +340,40 @@ class TestKeycloakJWTMiddleware:
)
auth_manager.generate_jwt.assert_not_called()
+
@patch("airflow.providers.keycloak.auth_manager.middleware.get_auth_manager")
+ async def test_dispatch_does_not_clear_fresh_token_set_by_endpoint(
+ self,
+ mock_get_auth_manager,
+ auth_manager,
+ call_next,
+ middleware,
+ mock_request,
+ mock_user,
+ ):
+ """
+ An expired token on the request must not clear the cookie when the
endpoint
+ set a fresh one on the response (e.g. the login callback exchanging the
+ authorization code while an expired token is still in the cookie jar).
+ """
+ mock_get_auth_manager.return_value = auth_manager
+ mock_request.cookies = {
+ COOKIE_NAME_JWT_TOKEN: "expired",
+ COOKIE_NAME_ACCESS_TOKEN: "expired_token",
+ COOKIE_NAME_REFRESH_TOKEN: "refresh_token",
+ }
+ auth_manager.get_user_from_token =
AsyncMock(side_effect=InvalidTokenError())
+
+ async def call_endpoint(request):
+ # The endpoint (login callback) mints a fresh JWT and signals it
+ request.state.jwt_token_issued = True
+ return Mock(name="response")
+
+ call_next.side_effect = call_endpoint
+
+ response = await middleware.dispatch(mock_request, call_next)
+
+ response.set_cookie.assert_not_called()
+
@patch("airflow.providers.keycloak.auth_manager.middleware.get_cookie_path")
@patch("airflow.providers.keycloak.auth_manager.middleware.get_auth_manager")
@pytest.mark.asyncio