This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 7a8c7207ecc [v3-3-test] Revoke every credential presented to the Core
API logout endpoint (#72649) (#72933)
7a8c7207ecc is described below
commit 7a8c7207ecca3e8c021a1ee573708bb6be5c4d9b
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Sep 11 16:24:04 2026 +0530
[v3-3-test] Revoke every credential presented to the Core API logout
endpoint (#72649) (#72933)
logout() read only the _token cookie. A client authenticating with an
Authorization: Bearer header -- the documented way to call the Core API --
therefore received a normal logout response while its token was never
revoked,
and that token stayed valid until it expired. The default lifetime is 24
hours.
A copy of the token held by anyone else survived the holder's logout, so
asking
to be logged out did not end the session it was presented for.
Logout uses collect_request_tokens() and revokes all of them, not just the
precedence-selected one. It walks the same order get_user() authenticates
by, so
logout does not hard-code its own separate answer to what a request's
credentials
are. Revoking only the winner would leave any other credential the caller
presented valid after they asked to be logged out, and which credential
wins is a
question about authentication that should not decide what a logout
terminates.
Revocation still happens before any redirect or cookie deletion, so an
external
auth-manager logout URL cannot skip it.
The bearer tests fail against unpatched sources with 'assert False is True'
--
the token is simply not revoked.
(cherry picked from commit 3f0a5d61fa8e6d8c0c5f6f48559ec350c026903a)
Generated-by: Claude Code (Opus 5)
Claude-Session: https://claude.ai/code/session_01XS3bodTDYYGrPmorhtLsjP
Co-authored-by: Jarek Potiuk <[email protected]>
---
.../core_api/openapi/v2-rest-api-generated.yaml | 3 +
.../api_fastapi/core_api/routes/public/auth.py | 29 ++++--
.../src/airflow/api_fastapi/core_api/security.py | 27 ++++++
.../core_api/routes/public/test_auth.py | 107 +++++++++++++++++++++
4 files changed, 160 insertions(+), 6 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 196d1f429e6..ebc0dad8576 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -10795,6 +10795,9 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
+ security:
+ - OAuth2PasswordBearer: []
+ - HTTPBearer: []
components:
schemas:
AppBuilderMenuItemResponse:
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 2237a68c055..890be3849fd 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
@@ -19,14 +19,21 @@ from __future__ import annotations
from urllib.parse import urlencode
import structlog
-from fastapi import HTTPException, Request, status
+from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
+from fastapi.security import HTTPAuthorizationCredentials
from airflow.api_fastapi.app import get_cookie_path
from airflow.api_fastapi.auth.managers.base_auth_manager import
COOKIE_NAME_JWT_TOKEN
from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
-from airflow.api_fastapi.core_api.security import AuthManagerDep, is_safe_url
+from airflow.api_fastapi.core_api.security import (
+ AuthManagerDep,
+ bearer_scheme,
+ collect_request_tokens,
+ is_safe_url,
+ oauth2_scheme,
+)
from airflow.configuration import conf
log = structlog.get_logger(logger_name=__name__)
@@ -57,11 +64,21 @@ def login(request: Request, auth_manager: AuthManagerDep,
next: None | str = Non
"/logout",
responses=create_openapi_http_exception_doc([status.HTTP_307_TEMPORARY_REDIRECT]),
)
-def logout(request: Request, auth_manager: AuthManagerDep) -> RedirectResponse:
+def logout(
+ request: Request,
+ auth_manager: AuthManagerDep,
+ oauth_token: str | None = Depends(oauth2_scheme),
+ bearer_credentials: HTTPAuthorizationCredentials | None =
Depends(bearer_scheme),
+) -> RedirectResponse:
"""Logout the user."""
- # Revoke the current token before any redirect or cookie deletion so the
JWT
- # is invalidated even when the auth manager redirects to an external
logout URL.
- if token_str := request.cookies.get(COOKIE_NAME_JWT_TOKEN):
+ # Revoke every credential presented before any redirect or cookie
deletion, so the
+ # JWT is invalidated even when the auth manager redirects to an external
logout URL.
+ #
+ # This previously read only the `_token` cookie. A client that
authenticates with an
+ # `Authorization: Bearer` header -- the documented way to call the API --
therefore
+ # got a successful logout response while its token was never revoked, and
the token
+ # stayed valid until it expired.
+ for token_str in collect_request_tokens(request, oauth_token,
bearer_credentials):
auth_manager.revoke_token(token_str)
logout_url = auth_manager.get_url_logout()
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/security.py
b/airflow-core/src/airflow/api_fastapi/core_api/security.py
index 5a333caadbf..6a357789757 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py
@@ -166,6 +166,33 @@ async def get_user(
return await
resolve_user_from_token(request.cookies.get(COOKIE_NAME_JWT_TOKEN))
+def collect_request_tokens(
+ request: Request,
+ oauth_token: str | None,
+ bearer_credentials: HTTPAuthorizationCredentials | None,
+) -> list[str]:
+ """
+ Return every distinct credential presented on this request, in precedence
order.
+
+ Logout uses this rather than reproducing the single-credential choice
+ :func:`get_user` makes. Revoking only the precedence-selected credential
would leave
+ any other one the caller presented still valid after they asked to be
logged out,
+ and which credential "wins" is a question about *authentication* that
should not
+ decide what a logout terminates.
+ """
+ candidates: list[str | None] = []
+ if bearer_credentials and bearer_credentials.scheme.lower() == "bearer":
+ candidates.append(bearer_credentials.credentials)
+ candidates.append(oauth_token)
+ candidates.append(request.cookies.get(COOKIE_NAME_JWT_TOKEN))
+
+ tokens: list[str] = []
+ for candidate in candidates:
+ if candidate and candidate not in tokens:
+ tokens.append(candidate)
+ return tokens
+
+
GetUserDep = Annotated[BaseUser, Depends(get_user)]
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 0379640f887..b2d616129e4 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
@@ -221,6 +221,113 @@ class TestLogoutTokenRevocation:
assert response.status_code == 307
assert RevokedToken.is_revoked("nonexistent-jti") is False
+ @staticmethod
+ def _mint(auth_manager, jti: str) -> str:
+ now = int(time.time())
+ signer = auth_manager._get_token_signer()
+ return jwt.encode(
+ {
+ "sub": "admin",
+ "jti": jti,
+ "exp": now + 3600,
+ "iat": now,
+ "nbf": now,
+ "aud": "apache-airflow",
+ "iss": signer.issuer,
+ },
+ signer._secret_key,
+ algorithm=signer.algorithm,
+ )
+
+ def test_logout_revokes_a_bearer_token(self, logout_client):
+ """A bearer-only logout must revoke the token it presented.
+
+ Clients calling the API authenticate with `Authorization: Bearer`, and
there is
+ no cookie on such a request. Logout previously read only the cookie,
so it
+ returned its normal response while revoking nothing and the token
stayed valid
+ until it expired.
+ """
+ auth_manager = logout_client.app.state.auth_manager
+ token_str = self._mint(auth_manager, "test-jti-bearer")
+
+ with patch.object(auth_manager, "get_url_logout", return_value=None):
+ response = logout_client.get(
+ "/auth/logout",
+ headers={"Authorization": f"Bearer {token_str}"},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 307
+ assert RevokedToken.is_revoked("test-jti-bearer") is True
+
+ def test_logout_revokes_every_credential_presented(self, logout_client):
+ """Both a bearer and a cookie presented together must both be revoked.
+
+ Revoking only the precedence-selected one would leave the other valid
after the
+ caller asked to be logged out.
+ """
+ auth_manager = logout_client.app.state.auth_manager
+ bearer_token = self._mint(auth_manager, "test-jti-both-bearer")
+ cookie_token = self._mint(auth_manager, "test-jti-both-cookie")
+
+ logout_client.cookies.set(COOKIE_NAME_JWT_TOKEN, cookie_token)
+ with patch.object(auth_manager, "get_url_logout", return_value=None):
+ response = logout_client.get(
+ "/auth/logout",
+ headers={"Authorization": f"Bearer {bearer_token}"},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 307
+ assert RevokedToken.is_revoked("test-jti-both-bearer") is True
+ assert RevokedToken.is_revoked("test-jti-both-cookie") is True
+
+ def test_logout_revokes_both_even_when_a_trusted_user_is_cached(self,
logout_client):
+ """The trusted-middleware shortcut must not change what logout revokes.
+
+ On protected routes `get_user()` can return a user cached by
JWTRefreshMiddleware
+ without consulting the request's credentials at all. Logout must still
act on the
+ credentials actually presented, so revocation cannot be skipped by
that shortcut.
+ """
+ from airflow.api_fastapi.core_api.security import
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+
+ auth_manager = logout_client.app.state.auth_manager
+ bearer_token = self._mint(auth_manager, "test-jti-trusted-bearer")
+ cookie_token = self._mint(auth_manager, "test-jti-trusted-cookie")
+
+ async def _inject(request, call_next):
+ request.state.user = object()
+ request.state.user_authenticated_via =
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+ return await call_next(request)
+
+ logout_client.app.middleware("http")(_inject)
+ logout_client.cookies.set(COOKIE_NAME_JWT_TOKEN, cookie_token)
+ with patch.object(auth_manager, "get_url_logout", return_value=None):
+ response = logout_client.get(
+ "/auth/logout",
+ headers={"Authorization": f"Bearer {bearer_token}"},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 307
+ assert RevokedToken.is_revoked("test-jti-trusted-bearer") is True
+ assert RevokedToken.is_revoked("test-jti-trusted-cookie") is True
+
+ def test_logout_revokes_a_bearer_token_before_an_external_redirect(self,
logout_client):
+ """The bearer must be revoked even when the auth manager redirects
away."""
+ auth_manager = logout_client.app.state.auth_manager
+ token_str = self._mint(auth_manager, "test-jti-bearer-redirect")
+
+ with patch.object(auth_manager, "get_url_logout",
return_value="https://idp.example/logout"):
+ response = logout_client.get(
+ "/auth/logout",
+ headers={"Authorization": f"Bearer {token_str}"},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 307
+ assert RevokedToken.is_revoked("test-jti-bearer-redirect") is True
+
def test_logout_revokes_token_when_logout_url_redirects(self,
logout_client):
"""Token must be revoked before the redirect when get_url_logout
returns a URL."""
now = int(time.time())