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

commit d6b45d8e2f4202fbc9cada2c6783539e29987a1f
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Sep 8 17:49:49 2026 +0200

    Resolve the caller from the explicit credential, not the session cookie 
(#72225)
    
    * 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 04472a8d303..cc485f70c52 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 168a8edafdd..69df6edfb23 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
@@ -199,6 +199,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