This is an automated email from the ASF dual-hosted git repository.
hussein-awala 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 d4aa2093b80 Support Azure national clouds in FAB Azure AD id_token
validation (#72010)
d4aa2093b80 is described below
commit d4aa2093b80b5ce3007ab1a545ac687de86a65dc
Author: Hussein Awala <[email protected]>
AuthorDate: Mon Aug 24 23:26:19 2026 +0200
Support Azure national clouds in FAB Azure AD id_token validation (#72010)
---
docs/spelling_wordlist.txt | 1 +
providers/fab/docs/auth-manager/sso.rst | 13 ++
.../fab/auth_manager/security_manager/override.py | 183 +++++++++++++++++++--
.../auth_manager/security_manager/test_override.py | 181 ++++++++++++++++++++
4 files changed, 365 insertions(+), 13 deletions(-)
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index d67f839976b..c46acc096fb 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -1897,6 +1897,7 @@ Vertica
vertica
Vespa
vespa
+Vianet
videointelligence
views
virtualenv
diff --git a/providers/fab/docs/auth-manager/sso.rst
b/providers/fab/docs/auth-manager/sso.rst
index 05b0e3e5e77..61fda6e0b19 100644
--- a/providers/fab/docs/auth-manager/sso.rst
+++ b/providers/fab/docs/auth-manager/sso.rst
@@ -189,6 +189,19 @@ Provider Examples
authorities (``common``, ``organizations``, ``consumers``) are not
accepted; configure a
specific tenant GUID or domain instead.
+.. note::
+ National clouds are supported by pointing the endpoints at their authority
host:
+ ``login.microsoftonline.us`` (Azure Government) or
``login.partner.microsoftonline.cn``
+ (Azure operated by 21Vianet). The issuer and the signing key set are then
read from that
+ tenant's own OpenID metadata, so no extra configuration is needed. Azure AD
B2C
+ (``<tenant>.b2clogin.com``) is not supported, because its metadata is
addressed by policy
+ rather than by tenant alone.
+
+ Configuring a tenant domain, or any national-cloud tenant, makes an
outbound HTTPS request
+ to the authority's OpenID discovery endpoint the first time a user logs in.
The result is
+ cached for the lifetime of the process. Deployments with restricted egress
must allow that
+ host.
+
.. seealso::
For Azure app registration and OAuth setup, see
:doc:`apache-airflow-providers-microsoft-azure:connections/azure`
and the `Azure OAuth2 documentation
<https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow>`_.
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 ed8d3f9c62f..7e9ced387cf 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
@@ -167,6 +167,21 @@ class AzureTenantResolutionError(FabException):
"""Raised when an Azure AD tenant identifier cannot be resolved to a
canonical GUID."""
+# Microsoft identity platform authority hosts. Each national cloud mints its
own issuer and
+# serves its own signing keys, so the host a deployment is configured against
decides which
+# issuer to pin and which key set to verify against. Azure AD B2C is
deliberately absent: its
+# authority is per-tenant (``<tenant>.b2clogin.com``) and its metadata is
addressed by policy,
+# which does not fit the tenant-only lookup below.
+AZURE_COMMERCIAL_AUTHORITY_HOST = "login.microsoftonline.com"
+AZURE_AUTHORITY_HOSTS = frozenset(
+ {
+ AZURE_COMMERCIAL_AUTHORITY_HOST,
+ "login.microsoftonline.us", # Azure Government
+ "login.partner.microsoftonline.cn", # Azure operated by 21Vianet
(China)
+ }
+)
+
+
class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
"""
This security manager overrides the default AirflowSecurityManager
security manager.
@@ -390,6 +405,7 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
self.appbuilder = appbuilder
self._azure_tenant_guid_cache: dict[str, str] = {}
+ self._azure_tenant_metadata_cache: dict[tuple[str, str],
tuple[tuple[str, ...], str]] = {}
self._init_config()
self._init_auth()
self._init_data_model()
@@ -2426,15 +2442,46 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
if conf.get("fab", "SESSION_BACKEND") == "database":
session.sid = str(uuid.uuid4()) # type: ignore
- def _get_microsoft_jwks(self) -> list[dict[str, Any]]:
- return requests.get(MICROSOFT_KEY_SET_URL, timeout=30).json()
+ def _get_microsoft_jwks(self, jwks_uri: str | None = None) ->
list[dict[str, Any]]:
+ """
+ Fetch the Microsoft signing key set.
+
+ ``jwks_uri`` is supplied for national clouds, which serve their own
keys; the
+ commercial cloud keeps using the key set URL from Flask-AppBuilder so
existing
+ deployments and overrides of this method are unaffected.
+ """
+ return requests.get(jwks_uri or MICROSOFT_KEY_SET_URL,
timeout=30).json()
+
+ def _get_azure_authority_host(self) -> str:
+ """
+ Return the Microsoft identity platform host the deployment is
configured against.
+
+ Falls back to the commercial cloud, which is what Flask-AppBuilder's
Azure defaults
+ point at when no endpoint is configured explicitly.
+ """
+ azure = self.oauth_remotes["azure"]
+ 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
+ parsed = urllib.parse.urlsplit(url)
+ if parsed.scheme.lower() != "https":
+ continue
+ host = (parsed.hostname or "").lower()
+ if host in AZURE_AUTHORITY_HOSTS:
+ return host
+ return AZURE_COMMERCIAL_AUTHORITY_HOST
def _get_azure_tenant_identifier(self) -> str | None:
"""
Extract the configured Azure AD tenant identifier.
Prefers an explicit ``tenant_id`` in ``client_kwargs``; otherwise
derives it from
- the tenant path segment of configured Azure HTTPS endpoints on
``login.microsoftonline.com``.
+ the tenant path segment of configured Azure HTTPS endpoints on any
Microsoft identity
+ platform authority host (see ``AZURE_AUTHORITY_HOSTS``).
Returns ``None`` when an explicit ``tenant_id`` is tenant-agnostic or
when no
tenant-specific identifier can be derived from the configured
endpoints. Tenant-agnostic
@@ -2458,7 +2505,7 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
parsed = urllib.parse.urlsplit(url)
if parsed.scheme.lower() != "https":
continue
- if (parsed.hostname or "").lower() !=
"login.microsoftonline.com":
+ if (parsed.hostname or "").lower() not in
AZURE_AUTHORITY_HOSTS:
continue
path_parts = [segment for segment in parsed.path.split("/") if
segment]
if path_parts:
@@ -2544,6 +2591,108 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
self._azure_tenant_guid_cache[tenant_identifier] = canonical_guid
return canonical_guid
+ def _fetch_azure_openid_configuration(self, discovery_url: str,
tenant_identifier: str) -> dict[str, Any]:
+ """Fetch and sanity-check an OpenID discovery document, failing closed
on any problem."""
+ try:
+ resp = requests.get(discovery_url, timeout=5,
allow_redirects=False)
+ except requests.exceptions.RequestException as ex:
+ raise AzureTenantResolutionError(
+ f"Failed to resolve Azure tenant identifier
'{tenant_identifier}' via OpenID discovery."
+ ) from ex
+
+ if resp.status_code != 200:
+ raise AzureTenantResolutionError(
+ f"Failed to resolve Azure tenant identifier
'{tenant_identifier}': "
+ f"OpenID discovery endpoint returned HTTP {resp.status_code}."
+ )
+
+ try:
+ data = resp.json()
+ except ValueError as ex:
+ raise AzureTenantResolutionError(
+ f"Failed to resolve Azure tenant identifier
'{tenant_identifier}' via OpenID discovery."
+ ) from ex
+
+ if not isinstance(data, dict):
+ raise AzureTenantResolutionError(
+ f"Failed to resolve Azure tenant identifier
'{tenant_identifier}': "
+ "OpenID discovery response is not a JSON object."
+ )
+ return data
+
+ def _resolve_azure_tenant_metadata(
+ self, tenant_identifier: str, authority_host: str
+ ) -> tuple[tuple[str, ...], str]:
+ """
+ Resolve the acceptable issuers and the signing key set URL for a
national cloud tenant.
+
+ National clouds mint their own issuers and serve their own keys, and
the values are not
+ derivable from the commercial ones, so they are read from the tenant's
own OpenID
+ discovery metadata rather than assembled from a template. Both the
v2.0 and v1.0
+ documents are consulted because either issuer form may appear
depending on which
+ endpoints the deployment uses.
+
+ The issuer and ``jwks_uri`` are required to live on Microsoft-operated
hosts for the
+ configured cloud, so a tampered metadata document cannot redirect
verification
+ elsewhere. Results are cached per (authority host, tenant); failures
are not cached.
+ """
+ cache_key = (authority_host, tenant_identifier)
+ if cache_key in self._azure_tenant_metadata_cache:
+ return self._azure_tenant_metadata_cache[cache_key]
+
+ encoded_tenant = urllib.parse.quote(tenant_identifier, safe="")
+ base = f"https://{authority_host}/{encoded_tenant}"
+
+ v2 = self._fetch_azure_openid_configuration(
+ f"{base}/v2.0/.well-known/openid-configuration", tenant_identifier
+ )
+ issuer = v2.get("issuer")
+ jwks_uri = v2.get("jwks_uri")
+ if not isinstance(issuer, str) or not isinstance(jwks_uri, str):
+ raise AzureTenantResolutionError(
+ f"Failed to resolve Azure tenant identifier
'{tenant_identifier}': "
+ "OpenID discovery response missing 'issuer' or 'jwks_uri'."
+ )
+ if (urllib.parse.urlsplit(issuer).hostname or "").lower() !=
authority_host:
+ raise AzureTenantResolutionError(
+ f"Failed to resolve Azure tenant identifier
'{tenant_identifier}': "
+ f"OpenID discovery returned issuer '{issuer}' outside the
configured authority "
+ f"'{authority_host}'."
+ )
+ jwks_host = (urllib.parse.urlsplit(jwks_uri).hostname or "").lower()
+ if urllib.parse.urlsplit(jwks_uri).scheme.lower() != "https" or
jwks_host != authority_host:
+ raise AzureTenantResolutionError(
+ f"Failed to resolve Azure tenant identifier
'{tenant_identifier}': "
+ f"OpenID discovery returned jwks_uri '{jwks_uri}' outside the
configured authority "
+ f"'{authority_host}'."
+ )
+
+ issuers = [issuer]
+
+ # The v1.0 document carries the sts.* style issuer. It is fetched
separately because a
+ # deployment configured against v1.0 endpoints receives tokens with
that issuer. If it
+ # cannot be read, the v1.0 form is simply not accepted -- that denies
a login rather
+ # than widening what is trusted.
+ try:
+ v1 = self._fetch_azure_openid_configuration(
+ f"{base}/.well-known/openid-configuration", tenant_identifier
+ )
+ except AzureTenantResolutionError:
+ log.warning(
+ "Could not read the v1.0 OpenID metadata for Azure tenant %s
on %s; "
+ "only the v2.0 issuer will be accepted.",
+ tenant_identifier,
+ authority_host,
+ )
+ else:
+ v1_issuer = v1.get("issuer")
+ if isinstance(v1_issuer, str) and v1_issuer not in issuers:
+ issuers.append(v1_issuer)
+
+ resolved = (tuple(issuers), jwks_uri)
+ self._azure_tenant_metadata_cache[cache_key] = resolved
+ return resolved
+
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:
@@ -2560,24 +2709,32 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
"in the azure provider's client_kwargs."
)
- tenant_guid = self._resolve_azure_tenant_guid(tenant_identifier)
+ authority_host = self._get_azure_authority_host()
+ jwks_uri: str | None = None
+ if authority_host == AZURE_COMMERCIAL_AUTHORITY_HOST:
+ # The commercial cloud's issuer forms are stable and
documented, so they are
+ # assembled from the tenant GUID. A GUID resolves without any
network call,
+ # which keeps the common deployment off the discovery endpoint
at login time.
+ tenant_guid =
self._resolve_azure_tenant_guid(tenant_identifier)
+ issuers: tuple[str, ...] = (
+ f"https://login.microsoftonline.com/{tenant_guid}/v2.0",
+ f"https://sts.windows.net/{tenant_guid}/",
+ )
+ else:
+ # National clouds use different issuer hosts and serve their
own signing keys,
+ # so both are read from the tenant's own metadata instead of
being guessed.
+ issuers, jwks_uri =
self._resolve_azure_tenant_metadata(tenant_identifier, authority_host)
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_guid}/v2.0",
- f"https://sts.windows.net/{tenant_guid}/",
- ],
- },
+ "iss": {"essential": True, "values": list(issuers)},
# 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
+ keyset =
JsonWebKey.import_key_set(self._get_microsoft_jwks(jwks_uri)) # type: ignore
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 de29ea3ccf0..68f648edddc 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
@@ -76,6 +76,7 @@ class EmptySecurityManager(FabAirflowSecurityManagerOverride):
# super() not called on purpose to avoid the whole chain of init calls
def __init__(self):
self._azure_tenant_guid_cache = {}
+ self._azure_tenant_metadata_cache = {}
class TestFabAirflowSecurityManagerOverride:
@@ -826,6 +827,186 @@ class TestFabAirflowSecurityManagerOverride:
assert claims["iss"] == f"https://sts.windows.net/{TENANT_GUID}/"
+ @pytest.mark.parametrize(
+ "authority_host",
+ [
+ pytest.param("login.microsoftonline.us", id="us-government"),
+ pytest.param("login.partner.microsoftonline.cn",
id="china-21vianet"),
+ ],
+ )
+ def
test_decode_and_validate_azure_jwt_national_cloud_uses_tenant_metadata(self,
authority_host):
+ """National clouds pin the issuer and key set that their own metadata
advertises."""
+ key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)
+ public_key = key.as_dict(is_private=False, kid="test-kid")
+ issuer = f"https://{authority_host}/{TENANT_GUID}/v2.0"
+ jwks_uri =
f"https://{authority_host}/{TENANT_GUID}/discovery/v2.0/keys"
+ id_token = _create_azure_jwt(key=key, iss=issuer)
+
+ sm = EmptySecurityManager()
+ sm.oauth_remotes = {
+ "azure": SimpleNamespace(
+ client_kwargs={},
+ client_id=CLIENT_ID,
+
api_base_url=f"https://{authority_host}/{TENANT_GUID}/oauth2/v2.0/",
+ access_token_url=None,
+ authorize_url=None,
+ )
+ }
+
+ v2 = _create_mock_response(json_data={"issuer": issuer, "jwks_uri":
jwks_uri})
+ v1 = _create_mock_response(json_data={"issuer":
f"https://sts.{authority_host}/{TENANT_GUID}/"})
+
+ with (
+ mock.patch.object(
+ EmptySecurityManager,
+ "_get_microsoft_jwks",
+ autospec=True,
+ return_value={"keys": [public_key]},
+ ) as mock_jwks,
+ mock.patch("requests.get", autospec=True, side_effect=[v2, v1]),
+ ):
+ claims = sm._decode_and_validate_azure_jwt(id_token)
+
+ assert claims["iss"] == issuer
+ # the key set comes from the tenant's own metadata, not the commercial
cloud
+ assert mock_jwks.call_args.args[1] == jwks_uri
+
+ def
test_decode_and_validate_azure_jwt_national_cloud_rejects_commercial_issuer(self):
+ """A token minted by the commercial cloud is not accepted for a
national cloud tenant."""
+ key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)
+ public_key = key.as_dict(is_private=False, kid="test-kid")
+ authority_host = "login.microsoftonline.us"
+ id_token = _create_azure_jwt(key=key,
iss=f"https://login.microsoftonline.com/{TENANT_GUID}/v2.0")
+
+ sm = EmptySecurityManager()
+ sm.oauth_remotes = {
+ "azure": SimpleNamespace(
+ client_kwargs={},
+ client_id=CLIENT_ID,
+
api_base_url=f"https://{authority_host}/{TENANT_GUID}/oauth2/v2.0/",
+ access_token_url=None,
+ authorize_url=None,
+ )
+ }
+
+ v2 = _create_mock_response(
+ json_data={
+ "issuer": f"https://{authority_host}/{TENANT_GUID}/v2.0",
+ "jwks_uri":
f"https://{authority_host}/{TENANT_GUID}/discovery/v2.0/keys",
+ }
+ )
+ v1 = _create_mock_response(json_data={"issuer":
f"https://sts.{authority_host}/{TENANT_GUID}/"})
+
+ with (
+ mock.patch.object(
+ EmptySecurityManager,
+ "_get_microsoft_jwks",
+ autospec=True,
+ return_value={"keys": [public_key]},
+ ),
+ mock.patch("requests.get", autospec=True, side_effect=[v2, v1]),
+ ):
+ with pytest.raises(InvalidClaimError, match="invalid_claim:
Invalid claim 'iss'"):
+ sm._decode_and_validate_azure_jwt(id_token)
+
+ @pytest.mark.parametrize(
+ ("metadata", "expected_message"),
+ [
+ pytest.param(
+ {
+ "issuer": f"https://evil.example.com/{TENANT_GUID}/v2.0",
+ "jwks_uri":
f"https://login.microsoftonline.us/{TENANT_GUID}/discovery/v2.0/keys",
+ },
+ "outside the configured authority",
+ id="issuer-off-authority",
+ ),
+ pytest.param(
+ {
+ "issuer":
f"https://login.microsoftonline.us/{TENANT_GUID}/v2.0",
+ "jwks_uri":
f"https://evil.example.com/{TENANT_GUID}/discovery/v2.0/keys",
+ },
+ "outside the configured authority",
+ id="jwks-off-authority",
+ ),
+ pytest.param(
+ {"issuer":
f"https://login.microsoftonline.us/{TENANT_GUID}/v2.0"},
+ "missing 'issuer' or 'jwks_uri'",
+ id="missing-jwks-uri",
+ ),
+ ],
+ )
+ def test_resolve_azure_tenant_metadata_fails_closed(self, metadata,
expected_message):
+ """Metadata that points verification off the configured authority is
refused."""
+ sm = EmptySecurityManager()
+ with mock.patch(
+ "requests.get", autospec=True,
return_value=_create_mock_response(json_data=metadata)
+ ):
+ with pytest.raises(AzureTenantResolutionError,
match=expected_message):
+ sm._resolve_azure_tenant_metadata(TENANT_GUID,
"login.microsoftonline.us")
+
+ def test_resolve_azure_tenant_metadata_tolerates_missing_v1_document(self):
+ """A v1.0 document that cannot be read narrows the accepted issuers
rather than widening them."""
+ authority_host = "login.microsoftonline.us"
+ issuer = f"https://{authority_host}/{TENANT_GUID}/v2.0"
+ jwks_uri =
f"https://{authority_host}/{TENANT_GUID}/discovery/v2.0/keys"
+
+ sm = EmptySecurityManager()
+ v2 = _create_mock_response(json_data={"issuer": issuer, "jwks_uri":
jwks_uri})
+ v1 = _create_mock_response(status_code=500)
+
+ with mock.patch("requests.get", autospec=True, side_effect=[v2, v1]):
+ issuers, resolved_jwks =
sm._resolve_azure_tenant_metadata(TENANT_GUID, authority_host)
+
+ assert issuers == (issuer,)
+ assert resolved_jwks == jwks_uri
+
+ def test_resolve_azure_tenant_metadata_caches_success(self):
+ """A resolved tenant is not re-fetched on the next login."""
+ authority_host = "login.microsoftonline.us"
+ issuer = f"https://{authority_host}/{TENANT_GUID}/v2.0"
+ jwks_uri =
f"https://{authority_host}/{TENANT_GUID}/discovery/v2.0/keys"
+
+ sm = EmptySecurityManager()
+ v2 = _create_mock_response(json_data={"issuer": issuer, "jwks_uri":
jwks_uri})
+ v1 = _create_mock_response(json_data={"issuer":
f"https://sts.{authority_host}/{TENANT_GUID}/"})
+
+ with mock.patch("requests.get", autospec=True, side_effect=[v2, v1])
as mock_get:
+ first = sm._resolve_azure_tenant_metadata(TENANT_GUID,
authority_host)
+ second = sm._resolve_azure_tenant_metadata(TENANT_GUID,
authority_host)
+
+ assert first == second
+ assert mock_get.call_count == 2
+
+ @pytest.mark.parametrize(
+ ("api_base_url", "expected"),
+ [
+ pytest.param(None, "login.microsoftonline.com",
id="defaults-to-commercial"),
+ pytest.param(
+ f"https://login.microsoftonline.us/{TENANT_GUID}/oauth2/v2.0/",
+ "login.microsoftonline.us",
+ id="us-government",
+ ),
+ pytest.param(
+ f"https://login.microsoftonline.de/{TENANT_GUID}/oauth2/v2.0/",
+ "login.microsoftonline.com",
+ id="unknown-host-falls-back-to-commercial",
+ ),
+ ],
+ )
+ def test_get_azure_authority_host(self, api_base_url, expected):
+ """The authority host is taken from the configured endpoints,
defaulting to commercial."""
+ sm = EmptySecurityManager()
+ sm.oauth_remotes = {
+ "azure": SimpleNamespace(
+ client_kwargs={},
+ client_id=CLIENT_ID,
+ api_base_url=api_base_url,
+ access_token_url=None,
+ authorize_url=None,
+ )
+ }
+ assert sm._get_azure_authority_host() == expected
+
def test_decode_and_validate_azure_jwt_rejects_issuer_mismatch(self):
"""Tokens issued for a different tenant are rejected with
InvalidClaimError."""
key = JsonWebKey.generate_key("RSA", 2048, options={"kid":
"test-kid"}, is_private=True)