This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 d1acf31b442 Validate id_token issuer and audience in the FAB Authentik
provider (#72645)
d1acf31b442 is described below
commit d1acf31b44299bf460954437d446e52f43f9eeb3
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed Sep 9 15:19:48 2026 +0200
Validate id_token issuer and audience in the FAB Authentik provider (#72645)
The authentik OAuth path decoded the id_token with no claims_options, so
authlib validated only the time-based claims. The issuer and audience were
never checked.
A provider signs the tokens of every application registered with it using
one key set, so a valid signature only establishes that the provider minted
the token, not that it was minted for Airflow. A token issued for a
different application registered with the same provider was therefore
accepted, and authenticated its subject as an Airflow user.
Pin the audience to the configured client_id and the issuer to the value
advertised in the provider's OpenID metadata. When no issuer can be
resolved, verification fails closed with an actionable error instead of
falling back to an audience-only check: the key set may sign for more than
one issuer, so an audience-only check would still accept a token from an
untrusted one. Deployments whose metadata omits the issuer can set it
explicitly in the provider's client_kwargs.
This mirrors the claims_options already applied on the azure path in the
same file.
Tests cover a correctly addressed token, a token for another application, a
token from another issuer, the fail-closed path asserted with a correct
audience and a wrong issuer, and the configured-issuer override both
accepting a valid token and rejecting a foreign issuer.
---
.../docs/auth-manager/webserver-authentication.rst | 19 +++
.../fab/auth_manager/security_manager/override.py | 33 ++++-
.../auth_manager/security_manager/test_override.py | 149 +++++++++++++++++++++
3 files changed, 198 insertions(+), 3 deletions(-)
diff --git a/providers/fab/docs/auth-manager/webserver-authentication.rst
b/providers/fab/docs/auth-manager/webserver-authentication.rst
index e950560a94a..dac507c1cc8 100644
--- a/providers/fab/docs/auth-manager/webserver-authentication.rst
+++ b/providers/fab/docs/auth-manager/webserver-authentication.rst
@@ -73,6 +73,25 @@ and Authlib, some OAuth2 providers may not be supported.
Currently supported pro
``linkedin``, ``google``, ``azure``, ``openshift``, ``okta``, ``auth0``,
``keycloak``, ``keycloak_before_17`` and ``authentik``.
If your provider is not on the list, you may need to adjust the ``remote_app``
configuration to match your provider's OAuth2 specification.
+.. note::
+
+ When using the ``authentik`` provider, the ``id_token`` is checked against
the issuer
+ and the audience of the configured application. The issuer is normally
discovered from
+ the provider's OpenID metadata, so configuring ``server_metadata_url`` is
sufficient.
+ If the metadata does not publish an ``issuer``, login fails with a clear
error rather
+ than skipping the check; set ``issuer`` in the provider's
``client_kwargs`` to supply
+ it explicitly:
+
+ .. code-block:: python
+
+ {
+ "name": "authentik",
+ "client_id": "airflow-client-id",
+ "client_kwargs": {
+ "issuer":
"https://authentik.example.com/application/o/airflow/",
+ },
+ }
+
By default, the following entry in the ``$AIRFLOW_HOME/webserver_config.py``
is used.
.. code-block:: ini
diff --git
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
index 2bb4d6ea53c..5823fa19f08 100644
---
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
+++
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
@@ -429,11 +429,11 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
return resp.json()
return {}
- def _validate_jwt(self, id_token, jwks):
+ def _validate_jwt(self, id_token, jwks, claims_options=None):
from authlib.jose import JsonWebKey, jwt as authlib_jwt
keyset = JsonWebKey.import_key_set(jwks)
- claims = authlib_jwt.decode(id_token, keyset)
+ claims = authlib_jwt.decode(id_token, keyset,
claims_options=claims_options)
claims.validate()
log.info("JWT token is validated")
return claims
@@ -446,7 +446,34 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
if jwks_uri:
jwks = self._get_authentik_jwks(jwks_uri)
if jwks:
- return self._validate_jwt(id_token, jwks)
+ # The issuer must be known before the token is trusted.
Verifying only
+ # the audience would still accept a token minted by an
untrusted issuer
+ # whenever the configured key set signs for more than one
of them, so a
+ # missing issuer fails closed rather than downgrading to
an audience-only
+ # check.
+ issuer = self.oauth_remotes["authentik"].client_kwargs.get(
+ "issuer"
+ ) or
self.oauth_remotes["authentik"].server_metadata.get("issuer")
+ if not issuer:
+ raise FabException(
+ "Cannot verify the authentik id_token: no issuer
is available. "
+ "The OpenID metadata for the 'authentik' provider
carries no "
+ "'issuer', so the token's issuer cannot be pinned.
Configure "
+ "'server_metadata_url' so the issuer is
discovered, or set "
+ "'issuer' in the authentik provider's
client_kwargs."
+ )
+ claims_options = {
+ # The token must have been issued by the configured
provider.
+ "iss": {"essential": True, "value": issuer},
+ # The token must have been minted for this
application. One key set
+ # signs for every application registered with the
provider, so a valid
+ # signature does not establish that the token was
addressed to Airflow.
+ "aud": {
+ "essential": True,
+ "value": self.oauth_remotes["authentik"].client_id,
+ },
+ }
+ return self._validate_jwt(id_token, jwks,
claims_options=claims_options)
else:
log.error("jwks_uri not specified in OAuth Providers, could
not verify token signature")
else:
diff --git
a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
index 68f648edddc..a6ccc18eba2 100644
---
a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
+++
b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
@@ -63,6 +63,24 @@ def _create_azure_jwt(
return token.decode("utf-8") if isinstance(token, bytes) else token
+AUTHENTIK_ISSUER = "https://authentik.example.com/application/o/airflow/"
+
+
+def _create_authentik_jwt(
+ key,
+ iss=AUTHENTIK_ISSUER,
+ aud=CLIENT_ID,
+ sub="user-sub",
+ kid="test-kid",
+) -> str:
+ token = authlib_jwt.encode(
+ {"alg": "RS256", "kid": kid},
+ {"iss": iss, "aud": aud, "sub": sub},
+ key,
+ )
+ return token.decode("utf-8") if isinstance(token, bytes) else token
+
+
def _create_mock_response(*, status_code=200, json_data=None,
json_side_effect=None) -> Mock:
response = Mock(spec=requests.Response)
response.status_code = status_code
@@ -1060,6 +1078,137 @@ class TestFabAirflowSecurityManagerOverride:
with pytest.raises(InvalidClaimError, match="invalid_claim:
Invalid claim 'aud'"):
sm._decode_and_validate_azure_jwt(id_token)
+ def _authentik_security_manager(self):
+ sm = EmptySecurityManager()
+ sm.oauth_remotes = {
+ "authentik": SimpleNamespace(
+ client_kwargs={},
+ client_id=CLIENT_ID,
+ server_metadata={
+ "jwks_uri":
"https://authentik.example.com/application/o/airflow/jwks/",
+ "issuer": AUTHENTIK_ISSUER,
+ },
+ )
+ }
+ return sm
+
+ def
test_get_authentik_token_info_accepts_a_token_for_this_application(self):
+ """A correctly-addressed token still authenticates."""
+ key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)
+ public_key = key.as_dict(is_private=False, kid="test-kid")
+ id_token = _create_authentik_jwt(key=key)
+
+ sm = self._authentik_security_manager()
+ with mock.patch.object(
+ EmptySecurityManager,
+ "_get_authentik_jwks",
+ autospec=True,
+ return_value={"keys": [public_key]},
+ ):
+ claims = sm._get_authentik_token_info(id_token)
+
+ assert claims["aud"] == CLIENT_ID
+ assert claims["iss"] == AUTHENTIK_ISSUER
+
+ def test_get_authentik_token_info_rejects_audience_mismatch(self):
+ """A token the same provider minted for another application is
rejected.
+
+ The provider signs every application's tokens with one key set, so a
valid
+ signature does not establish that the token was addressed to Airflow.
+ """
+ key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)
+ public_key = key.as_dict(is_private=False, kid="test-kid")
+ id_token = _create_authentik_jwt(key=key, aud="some-other-application")
+
+ sm = self._authentik_security_manager()
+ with mock.patch.object(
+ EmptySecurityManager,
+ "_get_authentik_jwks",
+ autospec=True,
+ return_value={"keys": [public_key]},
+ ):
+ with pytest.raises(InvalidClaimError, match="invalid_claim:
Invalid claim 'aud'"):
+ sm._get_authentik_token_info(id_token)
+
+ def test_get_authentik_token_info_rejects_issuer_mismatch(self):
+ """A token from a different provider is rejected even if its signature
verifies."""
+ key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)
+ public_key = key.as_dict(is_private=False, kid="test-kid")
+ id_token = _create_authentik_jwt(key=key,
iss="https://evil.example.com/application/o/x/")
+
+ sm = self._authentik_security_manager()
+ with mock.patch.object(
+ EmptySecurityManager,
+ "_get_authentik_jwks",
+ autospec=True,
+ return_value={"keys": [public_key]},
+ ):
+ with pytest.raises(InvalidClaimError, match="invalid_claim:
Invalid claim 'iss'"):
+ sm._get_authentik_token_info(id_token)
+
+ def test_get_authentik_token_info_fails_closed_without_an_issuer(self):
+ """No discoverable issuer is refused outright rather than downgraded.
+
+ Falling back to an audience-only check would still accept a token
minted by an
+ untrusted issuer whenever the configured key set signs for more than
one, so the
+ token this asserts on carries the *correct* audience and a *wrong*
issuer -- the
+ exact shape an audience-only fallback would let through.
+ """
+ key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)
+ public_key = key.as_dict(is_private=False, kid="test-kid")
+ id_token = _create_authentik_jwt(key=key,
iss="https://evil.example.com/application/o/x/")
+
+ sm = self._authentik_security_manager()
+ sm.oauth_remotes["authentik"].server_metadata.pop("issuer")
+
+ with mock.patch.object(
+ EmptySecurityManager,
+ "_get_authentik_jwks",
+ autospec=True,
+ return_value={"keys": [public_key]},
+ ):
+ with pytest.raises(FabException, match="no issuer is available"):
+ sm._get_authentik_token_info(id_token)
+
+ def
test_get_authentik_token_info_accepts_a_configured_issuer_override(self):
+ """A manually configured issuer restores verification when metadata
lacks one."""
+ key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)
+ public_key = key.as_dict(is_private=False, kid="test-kid")
+ id_token = _create_authentik_jwt(key=key)
+
+ sm = self._authentik_security_manager()
+ sm.oauth_remotes["authentik"].server_metadata.pop("issuer")
+ sm.oauth_remotes["authentik"].client_kwargs["issuer"] =
AUTHENTIK_ISSUER
+
+ with mock.patch.object(
+ EmptySecurityManager,
+ "_get_authentik_jwks",
+ autospec=True,
+ return_value={"keys": [public_key]},
+ ):
+ claims = sm._get_authentik_token_info(id_token)
+
+ assert claims["iss"] == AUTHENTIK_ISSUER
+
+ def
test_get_authentik_token_info_override_still_rejects_a_foreign_issuer(self):
+ """The override pins the issuer; it does not merely satisfy the
presence check."""
+ key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)
+ public_key = key.as_dict(is_private=False, kid="test-kid")
+ id_token = _create_authentik_jwt(key=key,
iss="https://evil.example.com/application/o/x/")
+
+ sm = self._authentik_security_manager()
+ sm.oauth_remotes["authentik"].server_metadata.pop("issuer")
+ sm.oauth_remotes["authentik"].client_kwargs["issuer"] =
AUTHENTIK_ISSUER
+
+ with mock.patch.object(
+ EmptySecurityManager,
+ "_get_authentik_jwks",
+ autospec=True,
+ return_value={"keys": [public_key]},
+ ):
+ with pytest.raises(InvalidClaimError, match="invalid_claim:
Invalid claim 'iss'"):
+ sm._get_authentik_token_info(id_token)
+
@pytest.mark.parametrize(
("response_kwargs", "request_side_effect", "error_match"),
[