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 79a7a418178 Pin pyjwt>=2.11.0 in FAB provider and stabilise JWT tests
under PyJWT 2.12 (#66840)
79a7a418178 is described below
commit 79a7a4181786412385d7f8ff0a7134859eed0398
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed May 13 17:12:57 2026 +0200
Pin pyjwt>=2.11.0 in FAB provider and stabilise JWT tests under PyJWT 2.12
(#66840)
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
---
.../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 89677cc1ef0..4dfd186756b 100644
--- a/airflow-core/tests/unit/api_fastapi/auth/test_tokens.py
+++ b/airflow-core/tests/unit/api_fastapi/auth/test_tokens.py
@@ -351,7 +351,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
@@ -366,7 +371,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
@@ -375,7 +385,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
@@ -398,7 +413,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 891542fd78a..6f47d98fd64 100644
--- a/devel-common/src/tests_common/pytest_plugin.py
+++ b/devel-common/src/tests_common/pytest_plugin.py
@@ -225,6 +225,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 fb526f7ccfe..0a25d5d6ac0 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 f7e89733762..d0207459c57 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 03fd7190abf..368ee79022a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4926,6 +4926,7 @@ dependencies = [
{ name = "jmespath" },
{ name = "marshmallow" },
{ name = "msgpack" },
+ { name = "pyjwt" },
{ name = "werkzeug" },
{ name = "wtforms" },
]
@@ -4972,6 +4973,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" },