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"),
[