This is an automated email from the ASF dual-hosted git repository.

potiuk 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 aa620e082cc Resolve the caller from the explicit credential, not the 
session cookie (#72225) (#72723)
aa620e082cc is described below

commit aa620e082cceb031d31acbdddc814b376747268c
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Sep 8 17:59:43 2026 +0200

    Resolve the caller from the explicit credential, not the session cookie 
(#72225) (#72723)
    
    * Resolve the caller from the explicit credential, not the session cookie
    
    `JWTRefreshMiddleware` resolves a user from the `_token` cookie alone and
    stamps it on `request.state` together with the trust sentinel. `get_user()`
    returned that cached user before it looked at `bearer_credentials` or
    `oauth_token`, so on every core-API route the effective precedence was
    cookie over bearer -- the inverse of the order the function itself codes.
    
    A request carrying both a session cookie and an explicit
    `Authorization: Bearer` token therefore executed, and was audit-logged, as
    the cookie's principal rather than the identity the client asked to act as.
    
    The cached user is now honoured only when the request carries no explicit
    credential, which is the case it exists for: a browser session whose token
    the middleware has just refreshed. When a bearer or OAuth2 token is
    present it is resolved instead.
    
    * Add newsfragment for the credential precedence change
    
    * Simplify credential resolution in get_user to early returns
    
    Review feedback: the intermediate `token_str = None` sentinel obscured the
    precedence the change is about. Returning at each credential source states
    the order directly, and the newsfragment now records that an invalid 
explicit
    credential fails loudly instead of silently falling back to the cookie.
    
    Generated-by: Claude Code (Opus 5)
    Claude-Session: https://claude.ai/code/session_01XS3bodTDYYGrPmorhtLsjP
---
 airflow-core/newsfragments/72225.significant.rst   | 27 ++++++++++++++
 .../src/airflow/api_fastapi/core_api/security.py   | 18 +++++-----
 .../unit/api_fastapi/core_api/test_security.py     | 42 ++++++++++++++++++++++
 3 files changed, 77 insertions(+), 10 deletions(-)

diff --git a/airflow-core/newsfragments/72225.significant.rst 
b/airflow-core/newsfragments/72225.significant.rst
new file mode 100644
index 00000000000..f515ad0c703
--- /dev/null
+++ b/airflow-core/newsfragments/72225.significant.rst
@@ -0,0 +1,27 @@
+An explicit credential now takes precedence over the session cookie
+
+``get_user()`` codes the precedence bearer, then OAuth2, then the session 
cookie, but
+that block was unreachable whenever a cookie was present. 
``JWTRefreshMiddleware`` runs
+first, resolves a user from the ``_token`` cookie alone and stamps it on
+``request.state``, and ``get_user()`` returned that cached user before looking 
at either
+explicit credential. The effective order on every core-API route was cookie 
over bearer.
+
+A request carrying both a session cookie and an explicit credential therefore 
executed,
+and was recorded in the audit log, as the cookie's principal rather than the 
identity the
+client presented. The cached user is now honoured only when the request 
carries no
+explicit credential.
+
+**Behaviour changes:**
+
+- A request carrying **both** a ``_token`` cookie and an ``Authorization: 
Bearer`` header
+  is now resolved as the bearer token's principal, where it was previously 
resolved as the
+  cookie's. The same applies to a cookie combined with an OAuth2 token.
+- An **invalid or expired** explicit credential is now rejected with 
``401``/``403`` even
+  when a valid ``_token`` cookie accompanies it. Previously the cookie 
silently took over
+  and the request succeeded as the cookie's principal; the failure is now loud.
+- Requests carrying a single credential are unaffected. Cookie-only browser 
sessions keep
+  the token-refresh behaviour of ``JWTRefreshMiddleware`` unchanged.
+- Clients that relied on the cookie winning -- for example a browser-based 
tool that sent a
+  service account's bearer token while a user session cookie was present, and 
expected the
+  user's identity to apply -- will now act as the bearer token's principal. 
Remove the
+  header, or the cookie, to select the intended identity explicitly.
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 4a6bcc84d70..5a333caadbf 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py
@@ -147,6 +147,13 @@ async def get_user(
     oauth_token: str | None = Depends(oauth2_scheme),
     bearer_credentials: HTTPAuthorizationCredentials | None = 
Depends(bearer_scheme),
 ) -> BaseUser:
+    # An explicitly supplied credential always wins over the ambient session 
cookie.
+    if bearer_credentials and bearer_credentials.scheme.lower() == "bearer":
+        return await resolve_user_from_token(bearer_credentials.credentials)
+    if oauth_token:
+        return await resolve_user_from_token(oauth_token)
+
+    # No explicit credential on this request, so the cookie is the caller's 
identity.
     # A user might have been already built by a trusted in-tree middleware 
(currently
     # only `JWTRefreshMiddleware`); if so, it is stored in 
`request.state.user` AND
     # `request.state.user_authenticated_via` is set to the trust sentinel 
above.
@@ -156,16 +163,7 @@ async def get_user(
     trust_marker = getattr(request.state, "user_authenticated_via", None)
     if user and trust_marker is USER_INJECTED_BY_TRUSTED_MIDDLEWARE:
         return user
-
-    token_str: str | None
-    if bearer_credentials and bearer_credentials.scheme.lower() == "bearer":
-        token_str = bearer_credentials.credentials
-    elif oauth_token:
-        token_str = oauth_token
-    else:
-        token_str = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
-
-    return await resolve_user_from_token(token_str)
+    return await 
resolve_user_from_token(request.cookies.get(COOKIE_NAME_JWT_TOKEN))
 
 
 GetUserDep = Annotated[BaseUser, Depends(get_user)]
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_security.py 
b/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
index ec61f429d25..83e8b6d3c33 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
@@ -198,6 +198,48 @@ class TestFastApiSecurity:
         assert result == resolved_user
         mock_resolve_user_from_token.assert_called_once_with("cookie_token")
 
+    @pytest.mark.parametrize(
+        ("oauth_token", "bearer_credentials_creds", "expected"),
+        [
+            pytest.param(None, "bearer_token", "bearer_token", id="bearer"),
+            pytest.param("oauth_token", None, "oauth_token", id="oauth"),
+        ],
+    )
+    @patch("airflow.api_fastapi.core_api.security.resolve_user_from_token")
+    async def test_get_user_explicit_credential_beats_cookie_user(
+        self, mock_resolve_user_from_token, oauth_token, 
bearer_credentials_creds, expected
+    ):
+        """An explicitly supplied credential wins over the cookie-derived 
session user.
+
+        `JWTRefreshMiddleware` resolves a user from the `_token` cookie alone 
and stamps
+        it on `request.state`. When the client *also* presents an explicit 
credential,
+        that credential is the identity the caller asked to act as, so it must 
be the one
+        that is resolved — otherwise the request executes, and is 
audit-logged, as the
+        cookie's principal instead.
+        """
+        from airflow.api_fastapi.core_api.security import 
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+
+        cookie_user = Mock(name="cookie_user")
+        token_user = Mock(name="token_user")
+        mock_resolve_user_from_token.return_value = token_user
+
+        request = Mock()
+        request.state.user = cookie_user
+        request.state.user_authenticated_via = 
USER_INJECTED_BY_TRUSTED_MIDDLEWARE
+        request.cookies = {COOKIE_NAME_JWT_TOKEN: "cookie_token"}
+
+        bearer_credentials = None
+        if bearer_credentials_creds:
+            bearer_credentials = Mock()
+            bearer_credentials.scheme = "bearer"
+            bearer_credentials.credentials = bearer_credentials_creds
+
+        result = await get_user(request, oauth_token, bearer_credentials)
+
+        assert result == token_user
+        assert result != cookie_user
+        mock_resolve_user_from_token.assert_called_once_with(expected)
+
     @pytest.mark.parametrize(
         ("oauth_token", "bearer_credentials_creds", "cookies", "expected"),
         [

Reply via email to