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

eladkal 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 7b94de7d4d3 Validate issuer and audience of Azure AD id_tokens in FAB 
auth manager (#71735)
7b94de7d4d3 is described below

commit 7b94de7d4d3bd768bd9b56f7800e6304e9110207
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Aug 18 19:43:19 2026 +0200

    Validate issuer and audience of Azure AD id_tokens in FAB auth manager 
(#71735)
    
    The Azure id_token signature is verified against Microsoft's key set, but
    the decode call passed no claims_options, so authlib's claims.validate()
    enforced neither the issuer nor the audience.
    
    The key set in use is the multi-tenant one
    (login.microsoftonline.com/common/discovery/keys), which serves signing
    keys for every Azure tenant. A correctly-signed token from any tenant
    therefore satisfied the signature check, and get_oauth_user_info() then
    read the login identity (oid, email, roles) straight out of it.
    
    Pin both claims:
    
    * iss must be the configured tenant, accepting the v1.0
      (sts.windows.net/<tenant>/) and v2.0
      (login.microsoftonline.com/<tenant>/v2.0) issuer forms.
    * aud must be this application's client_id.
    
    The tenant is taken from an explicit tenant_id in client_kwargs when set,
    and otherwise from the tenant segment of the configured endpoints, which
    is where the documented configuration already puts it. Deployments that
    follow the documented setup therefore need no configuration change.
    
    A configuration that identifies no single tenant - the common,
    organizations or consumers endpoints - now raises rather than accepting
    tokens it cannot attribute to an issuer. That is a behaviour change for
    those deployments: they need to set tenant_id explicitly.
    
    The existing test that asserted the verification branch is reached by
    default now supplies a tenant-bearing endpoint, since tenant resolution
    happens before the key set is fetched.
    
    Generated-by: Claude Opus 5 (1M context) following the guidelines at
    
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
---
 .../fab/auth_manager/security_manager/override.py  | 60 +++++++++++++-
 .../auth_manager/security_manager/test_override.py | 93 +++++++++++++++++++++-
 2 files changed, 150 insertions(+), 3 deletions(-)

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 2871399cd1c..ec08332f735 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
@@ -24,6 +24,7 @@ import importlib
 import itertools
 import json
 import logging
+import re
 import uuid
 from collections.abc import Collection, Iterable, Mapping
 from typing import TYPE_CHECKING, Any
@@ -69,6 +70,7 @@ from sqlalchemy.exc import IntegrityError, 
MultipleResultsFound
 from sqlalchemy.orm import joinedload
 from werkzeug.security import check_password_hash, generate_password_hash
 
+from airflow.exceptions import AirflowConfigException
 from airflow.providers.common.compat.sdk import conf
 from airflow.providers.fab.auth_manager.models import (
     Action,
@@ -2425,13 +2427,69 @@ class 
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
 
         return requests.get(MICROSOFT_KEY_SET_URL, timeout=30).json()
 
+    def _get_azure_tenant_id(self) -> str | None:
+        """
+        Resolve the Azure AD tenant the deployment is configured against.
+
+        Prefers an explicit ``tenant_id`` in ``client_kwargs``; otherwise 
derives it from
+        the tenant segment of the configured Azure endpoints, which is where 
the documented
+        configuration puts it 
(``https://login.microsoftonline.com/<tenant-id>/...``).
+
+        Returns ``None`` when the configuration is tenant-agnostic (the 
``common`` or
+        ``organizations`` endpoints), because there is then no single issuer 
to pin to.
+        """
+        azure = self.oauth_remotes["azure"]
+
+        tenant_id = azure.client_kwargs.get("tenant_id")
+        if tenant_id:
+            return tenant_id
+
+        for url in (
+            getattr(azure, "api_base_url", None),
+            getattr(azure, "access_token_url", None),
+            getattr(azure, "authorize_url", None),
+        ):
+            if not isinstance(url, str):
+                continue
+            match = re.search(r"login\.microsoftonline\.com/([^/]+)/", url)
+            if match and match.group(1) not in ("common", "organizations", 
"consumers"):
+                return match.group(1)
+
+        return None
+
     def _decode_and_validate_azure_jwt(self, id_token: str) -> dict[str, str]:
         verify_signature = 
self.oauth_remotes["azure"].client_kwargs.get("verify_signature", True)
         if verify_signature:
             from authlib.jose import JsonWebKey, jwt as authlib_jwt
 
+            tenant_id = self._get_azure_tenant_id()
+            if not tenant_id:
+                raise AirflowConfigException(
+                    "Azure AD tenant could not be determined from the OAuth 
configuration. "
+                    "The Microsoft key set used to verify id_token signatures 
serves keys for "
+                    "every tenant, so without a tenant the issuer of the token 
cannot be "
+                    "checked. Configure the tenant-specific endpoints "
+                    "(https://login.microsoftonline.com/<tenant-id>/...) or 
set 'tenant_id' "
+                    "in the azure provider's client_kwargs."
+                )
+
+            claims_options = {
+                # The token must have been issued by the configured tenant. 
Both the v1.0 and
+                # v2.0 issuer forms are accepted because either may be 
returned depending on
+                # which endpoints the deployment is configured against.
+                "iss": {
+                    "essential": True,
+                    "values": [
+                        f"https://login.microsoftonline.com/{tenant_id}/v2.0";,
+                        f"https://sts.windows.net/{tenant_id}/";,
+                    ],
+                },
+                # The token must have been minted for this application.
+                "aud": {"essential": True, "value": 
self.oauth_remotes["azure"].client_id},
+            }
+
             keyset = JsonWebKey.import_key_set(self._get_microsoft_jwks())  # 
type: ignore
-            claims = authlib_jwt.decode(id_token, keyset)
+            claims = authlib_jwt.decode(id_token, keyset, 
claims_options=claims_options)
             claims.validate()
             return claims
 
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 924ec22f6ab..7fd859555ba 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
@@ -475,8 +475,15 @@ class TestFabAirflowSecurityManagerOverride:
     def test_decode_and_validate_azure_jwt_verifies_signature_by_default(self):
         """Azure AD id_token signatures are verified by default 
(verify_signature defaults to True)."""
         sm = EmptySecurityManager()
-        # client_kwargs does not set verify_signature -> it must default to 
verifying
-        sm.oauth_remotes = {"azure": Mock(client_kwargs={})}
+        # client_kwargs does not set verify_signature -> it must default to 
verifying.
+        # A resolvable tenant is required before the key set is fetched, so 
the mock
+        # carries the tenant-specific endpoint the documented configuration 
uses.
+        sm.oauth_remotes = {
+            "azure": Mock(
+                client_kwargs={},
+                
api_base_url="https://login.microsoftonline.com/tenant-abc/oauth2/v2.0/";,
+            )
+        }
 
         with mock.patch.object(
             EmptySecurityManager, "_get_microsoft_jwks", 
side_effect=RuntimeError("verify-branch-reached")
@@ -504,6 +511,88 @@ class TestFabAirflowSecurityManagerOverride:
         mock_jwks.assert_not_called()
         assert result == {"oid": "user-1"}
 
+    @pytest.mark.parametrize(
+        ("remote_kwargs", "expected"),
+        [
+            pytest.param(
+                {"client_kwargs": {"tenant_id": "explicit-tenant"}}, 
"explicit-tenant", id="explicit"
+            ),
+            pytest.param(
+                {
+                    "client_kwargs": {},
+                    "api_base_url": 
"https://login.microsoftonline.com/tenant-from-url/oauth2/v2.0/";,
+                },
+                "tenant-from-url",
+                id="from-api-base-url",
+            ),
+            pytest.param(
+                {
+                    "client_kwargs": {},
+                    "api_base_url": None,
+                    "access_token_url": 
"https://login.microsoftonline.com/tenant-from-token-url/oauth2/v2.0/token";,
+                },
+                "tenant-from-token-url",
+                id="from-access-token-url",
+            ),
+        ],
+    )
+    def test_get_azure_tenant_id_resolves_configured_tenant(self, 
remote_kwargs, expected):
+        """The tenant is taken from client_kwargs when set, otherwise from the 
configured endpoints."""
+        sm = EmptySecurityManager()
+        sm.oauth_remotes = {"azure": Mock(**remote_kwargs)}
+
+        assert sm._get_azure_tenant_id() == expected
+
+    @pytest.mark.parametrize("multi_tenant_segment", ["common", 
"organizations", "consumers"])
+    def 
test_get_azure_tenant_id_returns_none_for_tenant_agnostic_endpoints(self, 
multi_tenant_segment):
+        """The shared endpoints identify no single tenant, so no issuer can be 
pinned."""
+        sm = EmptySecurityManager()
+        sm.oauth_remotes = {
+            "azure": Mock(
+                client_kwargs={},
+                
api_base_url=f"https://login.microsoftonline.com/{multi_tenant_segment}/oauth2/v2.0/";,
+                access_token_url=None,
+                authorize_url=None,
+            )
+        }
+
+        assert sm._get_azure_tenant_id() is None
+
+    def test_decode_and_validate_azure_jwt_requires_a_resolvable_tenant(self):
+        """Without a tenant there is no issuer to check, so the token is not 
accepted."""
+        from airflow.exceptions import AirflowConfigException
+
+        sm = EmptySecurityManager()
+        sm.oauth_remotes = {
+            "azure": Mock(
+                client_kwargs={},
+                
api_base_url="https://login.microsoftonline.com/common/oauth2/v2.0/";,
+                access_token_url=None,
+                authorize_url=None,
+            )
+        }
+
+        with pytest.raises(AirflowConfigException, match="tenant could not be 
determined"):
+            sm._decode_and_validate_azure_jwt("header.payload.signature")
+
+    def test_decode_and_validate_azure_jwt_pins_issuer_and_audience(self):
+        """The decode call constrains both the issuer and the audience of the 
token."""
+        sm = EmptySecurityManager()
+        sm.oauth_remotes = {"azure": Mock(client_kwargs={"tenant_id": 
"tenant-abc"}, client_id="app-xyz")}
+
+        with mock.patch.object(EmptySecurityManager, "_get_microsoft_jwks", 
return_value={"keys": []}):
+            with mock.patch("authlib.jose.JsonWebKey.import_key_set"):
+                with mock.patch("authlib.jose.jwt.decode") as mock_decode:
+                    
sm._decode_and_validate_azure_jwt("header.payload.signature")
+
+        claims_options = mock_decode.call_args.kwargs["claims_options"]
+        assert claims_options["aud"] == {"essential": True, "value": "app-xyz"}
+        assert claims_options["iss"]["essential"] is True
+        assert claims_options["iss"]["values"] == [
+            "https://login.microsoftonline.com/tenant-abc/v2.0";,
+            "https://sts.windows.net/tenant-abc/";,
+        ]
+
 
 def test_ldap_search_escapes_username_and_validates_filter():
     """Test that LDAP search properly escapes username and validates search 
filter."""

Reply via email to