This is an automated email from the ASF dual-hosted git repository.
shahar1 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 79bc6439a9c Always clear the session cookie on logout (#73788)
79bc6439a9c is described below
commit 79bc6439a9c12f00aa9569a1493ac37f7d5f78aa
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat Oct 3 22:09:02 2026 +0200
Always clear the session cookie on logout (#73788)
When the auth manager supplies an external logout URL (FAB, Keycloak),
``/auth/logout`` revoked the token and redirected without deleting the
``_token`` cookie, so the revocation was the only thing ending the local
session. ``revoke_token`` also caught every failure -- including a
failure to record the revocation -- and logged it as a warning, so a
revocation that did not happen looked the same as one that did.
Delete the cookie on every logout path, and log a failure to record the
revocation of a valid token as an error, separately from a token that
does not validate and needs no revocation.
Generated-by: Claude Opus 5
---
.../src/airflow/api_fastapi/auth/tokens.py | 24 +++++++++---
.../api_fastapi/core_api/routes/public/auth.py | 9 ++---
.../tests/unit/api_fastapi/auth/test_tokens.py | 43 +++++++++++++++++++++
.../core_api/routes/public/test_auth.py | 45 +++++++++++++++++++++-
4 files changed, 110 insertions(+), 11 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/auth/tokens.py
b/airflow-core/src/airflow/api_fastapi/auth/tokens.py
index 91518d5c72a..29681eaefa0 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/tokens.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/tokens.py
@@ -353,13 +353,27 @@ class JWTValidator:
return claims
def revoke_token(self, token: str) -> None:
- """Validate the token, extract jti and exp, and revoke it in the
database."""
+ """
+ Validate the token, extract jti and exp, and revoke it in the database.
+
+ A token that does not validate is not revoked: it is already unusable,
so this is logged as a
+ warning. A valid token whose revocation cannot be recorded is still
usable until it expires,
+ so that failure is logged as an error rather than being reported the
same way.
+ """
try:
claims = self.validated_claims(token)
- if (jti := claims.get("jti")) and (exp := claims.get("exp")):
- RevokedToken.revoke(jti, datetime.fromtimestamp(exp,
tz=timezone.utc))
- except (jwt.InvalidTokenError, Exception):
- log.warning("Failed to revoke token", exc_info=True)
+ except Exception:
+ log.warning("Not revoking a token that does not validate",
exc_info=True)
+ return
+ if not ((jti := claims.get("jti")) and (exp := claims.get("exp"))):
+ log.warning("Not revoking a token that carries no jti or exp
claim")
+ return
+ try:
+ RevokedToken.revoke(jti, datetime.fromtimestamp(exp,
tz=timezone.utc))
+ except Exception:
+ log.exception(
+ "Failed to record the revocation of token %s; it remains valid
until it expires", jti
+ )
def status(self):
if self.jwks:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
index c10fb71d9c8..486ccb7c451 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
@@ -78,13 +78,12 @@ def logout(
for token_str in collect_request_tokens(request, bearer_credentials):
auth_manager.revoke_token(token_str)
- logout_url = auth_manager.get_url_logout()
- if logout_url:
- return RedirectResponse(logout_url)
-
+ # The local session cookie is cleared on every path, including the
redirect to an external
+ # logout URL: revocation above is recorded server side and can fail, so it
must not be the
+ # only thing that ends the browser's session.
secure = request.base_url.scheme == "https" or bool(conf.get("api",
"ssl_cert", fallback=""))
cookie_path = get_cookie_path()
- response = RedirectResponse(auth_manager.get_url_login())
+ response = RedirectResponse(auth_manager.get_url_logout() or
auth_manager.get_url_login())
response.delete_cookie(
key=COOKIE_NAME_JWT_TOKEN,
path=cookie_path,
diff --git a/airflow-core/tests/unit/api_fastapi/auth/test_tokens.py
b/airflow-core/tests/unit/api_fastapi/auth/test_tokens.py
index 4dfd186756b..394d96a72e8 100644
--- a/airflow-core/tests/unit/api_fastapi/auth/test_tokens.py
+++ b/airflow-core/tests/unit/api_fastapi/auth/test_tokens.py
@@ -424,6 +424,49 @@ class TestRevokeToken:
):
validator.revoke_token(token)
+ def test_revoke_token_db_error_is_logged_as_error(self):
+ """A valid token that cannot be revoked stays usable, so the failure
is logged as an error."""
+ import time
+ from unittest.mock import patch
+
+ from sqlalchemy.exc import SQLAlchemyError
+
+ now = int(time.time())
+ payload = {
+ "sub": "user",
+ "jti": "db-error-jti",
+ "exp": now + 3600,
+ "iat": now,
+ "nbf": now,
+ "aud": "test",
+ }
+ token = jwt.encode(payload, "secret", algorithm="HS256")
+ validator = JWTValidator(
+ secret_key="secret", audience="test", algorithm=["HS256"],
leeway=0, issuer=None
+ )
+ with (
+ patch("airflow.api_fastapi.auth.tokens.log") as mock_log,
+ patch("airflow.models.revoked_token.RevokedToken.revoke",
side_effect=SQLAlchemyError("db down")),
+ ):
+ validator.revoke_token(token)
+
+ mock_log.exception.assert_called_once()
+ assert "db-error-jti" in mock_log.exception.call_args.args
+
+ def test_revoke_token_invalid_token_is_not_logged_as_error(self):
+ """A token that does not validate is already unusable, so it is only a
warning."""
+ from unittest.mock import patch
+
+ validator = JWTValidator(
+ secret_key="secret", audience="test", algorithm=["HS256"],
leeway=0, issuer=None
+ )
+ with patch("airflow.api_fastapi.auth.tokens.log") as mock_log:
+ validator.revoke_token("invalid-token")
+
+ mock_log.exception.assert_not_called()
+ mock_log.error.assert_not_called()
+ mock_log.warning.assert_called_once()
+
@pytest.fixture(scope="session")
def rsa_private_key():
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 a36ab808701..fc4e84621e4 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
@@ -17,7 +17,7 @@
from __future__ import annotations
import time
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlencode
import jwt
@@ -357,3 +357,46 @@ class TestLogoutTokenRevocation:
assert response.status_code == 307
assert response.headers["location"] == "http://external/logout"
assert RevokedToken.is_revoked("test-jti-redirect-456") is True
+
+ def test_logout_clears_cookie_when_logout_url_redirects(self,
logout_client):
+ """The local session cookie is cleared on the external-logout redirect
too, not only revoked."""
+ auth_manager = logout_client.app.state.auth_manager
+ token_str = self._mint(auth_manager, "test-jti-redirect-cookie")
+
+ logout_client.cookies.set(COOKIE_NAME_JWT_TOKEN, token_str)
+ # 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",
+ new=AsyncMock(return_value=(None, None)),
+ ),
+ patch.object(auth_manager, "get_url_logout",
return_value="http://external/logout"),
+ ):
+ response = logout_client.get("/auth/logout",
follow_redirects=False)
+
+ assert response.status_code == 307
+ assert response.headers["location"] == "http://external/logout"
+ cookies = response.headers.get_list("set-cookie")
+ assert any(c.startswith(f"{COOKIE_NAME_JWT_TOKEN}=") and "Max-Age=0"
in c for c in cookies)
+
+ def test_logout_clears_cookie_when_revocation_cannot_be_recorded(self,
logout_client):
+ """A failed revocation write does not leave the browser session in
place."""
+ from sqlalchemy.exc import SQLAlchemyError
+
+ auth_manager = logout_client.app.state.auth_manager
+ token_str = self._mint(auth_manager, "test-jti-revoke-fails")
+
+ logout_client.cookies.set(COOKIE_NAME_JWT_TOKEN, token_str)
+ with (
+ patch(
+
"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"),
+ patch("airflow.models.revoked_token.RevokedToken.revoke",
side_effect=SQLAlchemyError("db down")),
+ ):
+ response = logout_client.get("/auth/logout",
follow_redirects=False)
+
+ assert response.status_code == 307
+ cookies = response.headers.get_list("set-cookie")
+ assert any(c.startswith(f"{COOKIE_NAME_JWT_TOKEN}=") and "Max-Age=0"
in c for c in cookies)