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

Reply via email to