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

potiuk pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new d3bd36c5e8a Pin pyjwt>=2.11.0 in FAB provider and stabilise JWT tests 
under PyJWT 2.12 (#66840) (#66885)
d3bd36c5e8a is described below

commit d3bd36c5e8a6d551d646eb2c0544c6943eb7def4
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat May 16 02:00:44 2026 +0200

    Pin pyjwt>=2.11.0 in FAB provider and stabilise JWT tests under PyJWT 2.12 
(#66840) (#66885)
    
    PyJWT 2.12.0 (2026-03-12) tightened type validation: jwt.encode now
    rejects iss=None with TypeError: Issuer (iss) must be a string.
    
    Two independent symptoms surfaced:
    
    1. flask_jwt_extended.tokens.py does 'from jwt.types import Options',
       and jwt.types.Options was first added in PyJWT 2.11.0. The providers
       Compat 3.0.6 matrix job resolves pyjwt to 2.10.x because airflow-core
       3.0.6's pyjwt floor is permissive, breaking collection of every
       providers/fab/tests/unit/fab/** test with:
    
         ImportError: cannot import name 'Options' from 'jwt.types'
    
       Pin pyjwt>=2.11.0 directly in providers/fab/pyproject.toml so the
       FAB provider keeps installing cleanly regardless of which airflow-core
       release it is paired with. This is the original fix.
    
    2. Once pyjwt resolves to 2.12+, every test path that constructs a JWT
       without setting [api_auth] jwt_issuer fails with
       TypeError: Issuer (iss) must be a string. Current main is robust to
       this (commit a440d1db93, 2026-01-31, deletes iss from the claims
       when the configured issuer is falsy), but airflow-core 3.0.6
       (released 2025-08-25) predates that fix. Under the Compat 3.0.6
       matrix this manifested as 41 test failures across edge3, keycloak,
       and FAB.
    
       This is a test-only issue — in production users either configure
       jwt_issuer themselves or the runtime error surfaces immediately;
       the unique hot path is tests that exercise JWT generation under
       default config.
    
       Fix by setting AIRFLOW__API_AUTH__JWT_ISSUER to a non-empty default
       in the shared test pytest_plugin, so every JWT-generating test path
       is invariant to which airflow-core version is installed. The four
       TestRevokeToken tests in test_tokens.py construct synthetic tokens
       without an iss claim on purpose and now pass issuer=None to the
       validator explicitly so they remain invariant to that default.
    
    Reproduced in:
    - https://github.com/apache/airflow/actions/runs/25760423290/job/75664049129
    - https://github.com/apache/airflow/actions/runs/25777763902/job/75715167807
    
    (cherry picked from commit 79a7a4181786412385d7f8ff0a7134859eed0398)
---
 .../tests/unit/api_fastapi/auth/test_tokens.py     | 28 ++++++++++++++++++----
 .../core_api/routes/public/test_auth.py            |  8 +++++--
 devel-common/src/tests_common/pytest_plugin.py     |  9 +++++++
 providers/fab/docs/index.rst                       |  1 +
 providers/fab/pyproject.toml                       |  6 +++++
 uv.lock                                            |  2 ++
 6 files changed, 48 insertions(+), 6 deletions(-)

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 6b848f723a0..8b76dff6217 100644
--- a/airflow-core/tests/unit/api_fastapi/auth/test_tokens.py
+++ b/airflow-core/tests/unit/api_fastapi/auth/test_tokens.py
@@ -323,7 +323,12 @@ class TestRevokeToken:
         }
         token = jwt.encode(payload, "secret", algorithm="HS256")
 
-        validator = JWTValidator(secret_key="secret", audience="test", 
algorithm=["HS256"], leeway=0)
+        # Pass issuer=None explicitly so the validator does not pick up the
+        # process-wide test-env default `[api_auth] jwt_issuer` and demand an
+        # `iss` claim that the synthetic tokens below intentionally omit.
+        validator = JWTValidator(
+            secret_key="secret", audience="test", algorithm=["HS256"], 
leeway=0, issuer=None
+        )
         validator.revoke_token(token)
 
         assert RevokedToken.is_revoked("revoke-test-jti") is True
@@ -338,7 +343,12 @@ class TestRevokeToken:
         payload = {"sub": "user", "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)
+        # Pass issuer=None explicitly so the validator does not pick up the
+        # process-wide test-env default `[api_auth] jwt_issuer` and demand an
+        # `iss` claim that the synthetic tokens below intentionally omit.
+        validator = JWTValidator(
+            secret_key="secret", audience="test", algorithm=["HS256"], 
leeway=0, issuer=None
+        )
         validator.revoke_token(token)
 
         assert RevokedToken.is_revoked("any-jti") is False
@@ -347,7 +357,12 @@ class TestRevokeToken:
         """Test that revoke_token logs a warning instead of raising for an 
invalid token."""
         from airflow.models.revoked_token import RevokedToken
 
-        validator = JWTValidator(secret_key="secret", audience="test", 
algorithm=["HS256"], leeway=0)
+        # Pass issuer=None explicitly so the validator does not pick up the
+        # process-wide test-env default `[api_auth] jwt_issuer` and demand an
+        # `iss` claim that the synthetic tokens below intentionally omit.
+        validator = JWTValidator(
+            secret_key="secret", audience="test", algorithm=["HS256"], 
leeway=0, issuer=None
+        )
         validator.revoke_token("invalid-token")
 
         assert RevokedToken.is_revoked("any-jti") is False
@@ -370,7 +385,12 @@ class TestRevokeToken:
         }
         token = jwt.encode(payload, "secret", algorithm="HS256")
 
-        validator = JWTValidator(secret_key="secret", audience="test", 
algorithm=["HS256"], leeway=0)
+        # Pass issuer=None explicitly so the validator does not pick up the
+        # process-wide test-env default `[api_auth] jwt_issuer` and demand an
+        # `iss` claim that the synthetic tokens below intentionally omit.
+        validator = JWTValidator(
+            secret_key="secret", audience="test", algorithm=["HS256"], 
leeway=0, issuer=None
+        )
         with patch(
             "airflow.models.revoked_token.RevokedToken.revoke", 
side_effect=SQLAlchemyError("db down")
         ):
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 f0e170daf60..ca0c87acd86 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
@@ -189,6 +189,8 @@ class TestLogoutTokenRevocation:
     def test_logout_revokes_token(self, logout_client):
         """Test that logout revokes the JWT token and persists it in the 
database."""
         now = int(time.time())
+        auth_manager = logout_client.app.state.auth_manager
+        signer = auth_manager._get_token_signer()
         token_payload = {
             "sub": "admin",
             "jti": "test-jti-123",
@@ -196,9 +198,11 @@ class TestLogoutTokenRevocation:
             "iat": now,
             "nbf": now,
             "aud": "apache-airflow",
+            # Include the signer's configured issuer so the validator (which
+            # reads `[api_auth] jwt_issuer` from the same config) does not
+            # reject the synthetic token for missing iss.
+            "iss": signer.issuer,
         }
-        auth_manager = logout_client.app.state.auth_manager
-        signer = auth_manager._get_token_signer()
         token_str = jwt.encode(token_payload, signer._secret_key, 
algorithm=signer.algorithm)
 
         logout_client.cookies.set(COOKIE_NAME_JWT_TOKEN, token_str)
diff --git a/devel-common/src/tests_common/pytest_plugin.py 
b/devel-common/src/tests_common/pytest_plugin.py
index b93a7a44e8c..ea28f13faba 100644
--- a/devel-common/src/tests_common/pytest_plugin.py
+++ b/devel-common/src/tests_common/pytest_plugin.py
@@ -224,6 +224,15 @@ os.environ["AIRFLOW__CORE__DAGS_FOLDER"] = 
os.fspath(AIRFLOW_CORE_TESTS_PATH / "
 os.environ["AIRFLOW__CORE__UNIT_TEST_MODE"] = "True"
 os.environ["AWS_DEFAULT_REGION"] = os.environ.get("AWS_DEFAULT_REGION") or 
"us-east-1"
 os.environ["CREDENTIALS_DIR"] = os.environ.get("CREDENTIALS_DIR") or 
"/files/airflow-breeze-config/keys"
+# PyJWT 2.12.0 (2026-03-12) added strict type validation that rejects iss=None.
+# Current main's airflow-core deletes iss from the claims when the configured
+# `[api_auth] jwt_issuer` is falsy (commit a440d1db93, 2026-01-31), but the
+# `Compat 3.0.x` matrix tests install older airflow-core releases (e.g. 3.0.6,
+# 2025-08-25) that predate that fix. Setting a default test issuer here keeps
+# every JWT-generating test path safe across all supported airflow-core 
versions
+# without leaking the upper bound to user-facing dependencies. Tests that need
+# to override this still can via `conf_vars(...)`.
+os.environ.setdefault("AIRFLOW__API_AUTH__JWT_ISSUER", "test-airflow-issuer")
 
 
 @pytest.fixture
diff --git a/providers/fab/docs/index.rst b/providers/fab/docs/index.rst
index 64686d356c6..8f269026cac 100644
--- a/providers/fab/docs/index.rst
+++ b/providers/fab/docs/index.rst
@@ -112,6 +112,7 @@ PIP package                                 Version required
 ``blinker``                                 ``>=1.6.2``
 ``flask``                                   ``>=2.2.1``
 ``flask-appbuilder``                        ``==5.2.0``
+``pyjwt``                                   ``>=2.11.0``
 ``flask-login``                             ``>=0.6.2; python_version < 
"3.14"``
 ``flask-login``                             ``>=0.6.3; python_version >= 
"3.14"``
 ``flask-session``                           ``>=0.8.0``
diff --git a/providers/fab/pyproject.toml b/providers/fab/pyproject.toml
index f7eab7d11ef..b03012b1fee 100644
--- a/providers/fab/pyproject.toml
+++ b/providers/fab/pyproject.toml
@@ -78,6 +78,12 @@ dependencies = [
     # `airflow/providers/fab/auth_manager/security_manager/override.py` with 
their upstream counterparts.
     # In particular, make sure any breaking changes, for example any new 
methods, are accounted for.
     "flask-appbuilder==5.2.0",  # Whenever updating the version, run 
test_fab_alignment.py to verify.
+    # Transitive via flask-appbuilder -> flask-jwt-extended; pinned here so 
the FAB
+    # provider keeps installing cleanly when paired with older airflow-core 
releases
+    # (the compat-3.0.6 matrix job) whose own pyjwt floor predates 
`jwt.types.Options`
+    # (added in PyJWT 2.11.0). Without this, `from jwt.types import Options` in
+    # `flask_jwt_extended.tokens` raises ImportError at module import time.
+    "pyjwt>=2.11.0",
     "flask-login>=0.6.2; python_version < '3.14'",
     "flask-login>=0.6.3; python_version >= '3.14'",
     "flask-session>=0.8.0",
diff --git a/uv.lock b/uv.lock
index 63eacbbc5d9..1c88f63598a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4812,6 +4812,7 @@ dependencies = [
     { name = "jmespath" },
     { name = "marshmallow" },
     { name = "msgpack" },
+    { name = "pyjwt" },
     { name = "werkzeug" },
     { name = "wtforms" },
 ]
@@ -4853,6 +4854,7 @@ requires-dist = [
     { name = "kerberos", marker = "extra == 'kerberos'", specifier = ">=1.3.0" 
},
     { name = "marshmallow", specifier = ">=3" },
     { name = "msgpack", specifier = ">=1.0.0" },
+    { name = "pyjwt", specifier = ">=2.11.0" },
     { name = "werkzeug", marker = "python_full_version < '3.14'", specifier = 
">=2.2" },
     { name = "werkzeug", marker = "python_full_version >= '3.14'", specifier = 
">=3.1.6" },
     { name = "wtforms", specifier = ">=3.0" },

Reply via email to