From f053dff6caa429a9cf2f563737e019b82bd95f61 Mon Sep 17 00:00:00 2001 From: Prasanna Sankaran Date: Mon, 14 Sep 2026 15:39:25 -0700 Subject: [PATCH 1/3] Fix Key Vault challenge tenant ID parsing for DSTSv2 authorities HttpChallenge assumed the tenant ID is always the first path segment of the challenge's authorization URI. DSTSv2 authorities use the form https:///dstsv2/, so the literal string "dstsv2" was passed to the credential as tenant_id and token acquisition targeted the wrong tenant. Detect the "dstsv2" path segment and read the tenant ID from the segment that follows it, matching the behavior of the .NET Key Vault libraries. Applied to azure-keyvault-keys, -secrets, -certificates, -administration and -securitydomain. Added parsing tests covering Microsoft Entra ID and DSTSv2 authorization URIs plus sync and async 401 -> 200 pipeline tests asserting the tenant ID passed to the credential. Fixes #45326 --- .../CHANGELOG.md | 1 + .../_internal/http_challenge.py | 29 +++- .../tests/test_challenge_auth.py | 125 +++++++++++++++++- .../azure-keyvault-certificates/CHANGELOG.md | 1 + .../certificates/_shared/http_challenge.py | 29 +++- .../tests/test_challenge_auth.py | 77 ++++++++++- .../tests/test_challenge_auth_async.py | 52 ++++++++ sdk/keyvault/azure-keyvault-keys/CHANGELOG.md | 1 + .../keyvault/keys/_shared/http_challenge.py | 29 +++- .../tests/test_challenge_auth.py | 75 +++++++++++ .../tests/test_challenge_auth_async.py | 52 ++++++++ .../azure-keyvault-secrets/CHANGELOG.md | 12 ++ .../secrets/_shared/http_challenge.py | 29 +++- .../azure/keyvault/secrets/_version.py | 2 +- .../tests/test_challenge_auth.py | 77 ++++++++++- .../tests/test_challenge_auth_async.py | 52 ++++++++ .../CHANGELOG.md | 1 + .../_internal/http_challenge.py | 29 +++- .../tests/test_challenge_auth.py | 77 ++++++++++- .../tests/test_challenge_auth_async.py | 52 ++++++++ 20 files changed, 772 insertions(+), 30 deletions(-) diff --git a/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md b/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md index 4c861274a400..7f08982550e7 100644 --- a/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md @@ -8,6 +8,7 @@ ### Bugs Fixed +- Fixed challenge-based authentication to correctly parse the tenant ID from DSTSv2 authority URIs ([#45326](https://github.com/Azure/azure-sdk-for-python/issues/45326)). - Fixed a bug in the challenge authentication policy where the authentication challenge was cached before the challenge resource was verified. The challenge is now cached only after resource verification succeeds [#48710](https://github.com/Azure/azure-sdk-for-python/pull/48710). ### Other Changes diff --git a/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/http_challenge.py b/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/http_challenge.py index 8b14b999de78..5055981bda1a 100644 --- a/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/http_challenge.py +++ b/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/http_challenge.py @@ -6,6 +6,8 @@ from typing import Dict, MutableMapping, Optional from urllib import parse +_DSTS_V2_PATH_SEGMENT = "dstsv2" + class HttpChallenge(object): """An object representing the content of a Key Vault authentication challenge. @@ -66,11 +68,7 @@ def __init__( if "authorization" not in self._parameters and "authorization_uri" not in self._parameters: raise ValueError("Invalid challenge parameters") - authorization_uri = self.get_authorization_server() - # the authorization server URI should look something like https://login.windows.net/tenant-id - raw_uri_path = str(parse.urlparse(authorization_uri).path) - uri_path = raw_uri_path.lstrip("/") - self.tenant_id = uri_path.split("/", maxsplit=1)[0] or None + self.tenant_id = self._parse_tenant_id(self.get_authorization_server()) # if the response headers were supplied if response_headers: @@ -78,6 +76,27 @@ def __init__( self.server_signature_key = response_headers.get("x-ms-message-signing-key", None) self.server_encryption_key = response_headers.get("x-ms-message-encryption-key", None) + @staticmethod + def _parse_tenant_id(authorization_uri: str) -> "Optional[str]": + """Extracts the tenant ID from the authorization server URI of a challenge. + + For Microsoft Entra ID authorities the tenant ID is the first path segment, for example + https://login.microsoftonline.com/. For DSTSv2 authorities the first path segment is the literal + "dstsv2" and the tenant ID is the second path segment, for example + https://uswest2-passive-dsts.dsts.core.windows.net/dstsv2/. + + :param str authorization_uri: The authorization server URI from the challenge. + + :returns: The tenant ID, or None if the URI does not contain one. + :rtype: str or None + """ + raw_uri_path = str(parse.urlparse(authorization_uri).path) + path_segments = raw_uri_path.lstrip("/").split("/") + tenant_id = path_segments[0] + if tenant_id.lower() == _DSTS_V2_PATH_SEGMENT and len(path_segments) > 1 and path_segments[1]: + tenant_id = path_segments[1] + return tenant_id or None + def is_bearer_challenge(self) -> bool: """Tests whether the HttpChallenge is a Bearer challenge. diff --git a/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py index 7cc3716d5c6f..1ef91622a7ff 100644 --- a/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py @@ -15,7 +15,7 @@ from azure.core.credentials import AccessToken, AccessTokenInfo from azure.core.pipeline import AsyncPipeline, Pipeline from azure.core.rest import HttpRequest -from azure.keyvault.administration._internal import ChallengeAuthPolicy, HttpChallengeCache +from azure.keyvault.administration._internal import ChallengeAuthPolicy, HttpChallenge, HttpChallengeCache from azure.keyvault.administration._internal.async_challenge_auth_policy import AsyncChallengeAuthPolicy TOKEN_TYPES = [AccessToken, AccessTokenInfo] @@ -253,3 +253,126 @@ async def get_token(*_, **__): await pipeline.run(first_request) await pipeline.run(HttpRequest("GET", second_url)) + + +ENTRA_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd022db57" +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.parametrize( + "authority,expected_tenant", + [ + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}", ENTRA_TENANT_ID), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/oauth2/authorize", ENTRA_TENANT_ID), + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}", DSTS_TENANT_ID), + (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), + # a DSTSv2 authority without a tenant segment keeps the previous behavior + (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + ("https://login.microsoftonline.com/", None), + ], +) +def test_challenge_parsing_tenant_id(authority, expected_tenant): + """The tenant ID should be parsed from both Microsoft Entra ID and DSTSv2 authorization URIs""" + + challenge = HttpChallenge( + "https://request.uri", challenge=f'Bearer authorization="{authority}", resource=https://vault.azure.net' + ) + + assert challenge.get_authorization_server() == authority + assert challenge.tenant_id == expected_tenant + + +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 + + +@pytest.mark.asyncio +@async_empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +async def test_tenant_dstsv2_async(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + async def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + async def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + await pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 diff --git a/sdk/keyvault/azure-keyvault-certificates/CHANGELOG.md b/sdk/keyvault/azure-keyvault-certificates/CHANGELOG.md index d15e3e1ad5cb..d45cb5f9c30a 100644 --- a/sdk/keyvault/azure-keyvault-certificates/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-certificates/CHANGELOG.md @@ -8,6 +8,7 @@ ### Bugs Fixed +- Fixed challenge-based authentication to correctly parse the tenant ID from DSTSv2 authority URIs ([#45326](https://github.com/Azure/azure-sdk-for-python/issues/45326)). - Fixed a bug in the challenge authentication policy where the authentication challenge was cached before the challenge resource was verified. The challenge is now cached only after resource verification succeeds [#48710](https://github.com/Azure/azure-sdk-for-python/pull/48710). ### Other Changes diff --git a/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_shared/http_challenge.py b/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_shared/http_challenge.py index 8b14b999de78..5055981bda1a 100644 --- a/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_shared/http_challenge.py +++ b/sdk/keyvault/azure-keyvault-certificates/azure/keyvault/certificates/_shared/http_challenge.py @@ -6,6 +6,8 @@ from typing import Dict, MutableMapping, Optional from urllib import parse +_DSTS_V2_PATH_SEGMENT = "dstsv2" + class HttpChallenge(object): """An object representing the content of a Key Vault authentication challenge. @@ -66,11 +68,7 @@ def __init__( if "authorization" not in self._parameters and "authorization_uri" not in self._parameters: raise ValueError("Invalid challenge parameters") - authorization_uri = self.get_authorization_server() - # the authorization server URI should look something like https://login.windows.net/tenant-id - raw_uri_path = str(parse.urlparse(authorization_uri).path) - uri_path = raw_uri_path.lstrip("/") - self.tenant_id = uri_path.split("/", maxsplit=1)[0] or None + self.tenant_id = self._parse_tenant_id(self.get_authorization_server()) # if the response headers were supplied if response_headers: @@ -78,6 +76,27 @@ def __init__( self.server_signature_key = response_headers.get("x-ms-message-signing-key", None) self.server_encryption_key = response_headers.get("x-ms-message-encryption-key", None) + @staticmethod + def _parse_tenant_id(authorization_uri: str) -> "Optional[str]": + """Extracts the tenant ID from the authorization server URI of a challenge. + + For Microsoft Entra ID authorities the tenant ID is the first path segment, for example + https://login.microsoftonline.com/. For DSTSv2 authorities the first path segment is the literal + "dstsv2" and the tenant ID is the second path segment, for example + https://uswest2-passive-dsts.dsts.core.windows.net/dstsv2/. + + :param str authorization_uri: The authorization server URI from the challenge. + + :returns: The tenant ID, or None if the URI does not contain one. + :rtype: str or None + """ + raw_uri_path = str(parse.urlparse(authorization_uri).path) + path_segments = raw_uri_path.lstrip("/").split("/") + tenant_id = path_segments[0] + if tenant_id.lower() == _DSTS_V2_PATH_SEGMENT and len(path_segments) > 1 and path_segments[1]: + tenant_id = path_segments[1] + return tenant_id or None + def is_bearer_challenge(self) -> bool: """Tests whether the HttpChallenge is a Bearer challenge. diff --git a/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth.py index 0b6345c254cf..6482d7023c71 100644 --- a/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth.py @@ -16,7 +16,7 @@ from azure.core.credentials import AccessToken, AccessTokenInfo from azure.core.pipeline import Pipeline from azure.core.rest import HttpRequest -from azure.keyvault.certificates._shared import ChallengeAuthPolicy, HttpChallengeCache +from azure.keyvault.certificates._shared import ChallengeAuthPolicy, HttpChallenge, HttpChallengeCache TOKEN_TYPES = [AccessToken, AccessTokenInfo] @@ -136,3 +136,78 @@ def get_token(*_, **__): pipeline.run(first_request) pipeline.run(HttpRequest("GET", second_url)) + + +ENTRA_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd022db57" +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.parametrize( + "authority,expected_tenant", + [ + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}", ENTRA_TENANT_ID), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/oauth2/authorize", ENTRA_TENANT_ID), + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}", DSTS_TENANT_ID), + (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), + # a DSTSv2 authority without a tenant segment keeps the previous behavior + (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + ("https://login.microsoftonline.com/", None), + ], +) +def test_challenge_parsing_tenant_id(authority, expected_tenant): + """The tenant ID should be parsed from both Microsoft Entra ID and DSTSv2 authorization URIs""" + + challenge = HttpChallenge( + "https://request.uri", challenge=f'Bearer authorization="{authority}", resource=https://vault.azure.net' + ) + + assert challenge.get_authorization_server() == authority + assert challenge.tenant_id == expected_tenant + + +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 diff --git a/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth_async.py b/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth_async.py index 72286f02bb07..acd1ebfa0c5e 100644 --- a/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth_async.py +++ b/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth_async.py @@ -131,3 +131,55 @@ async def get_token(*_, **__): await pipeline.run(first_request) await pipeline.run(HttpRequest("GET", second_url)) + + +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +async def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + async def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + async def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + await pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 diff --git a/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md b/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md index 67893ebd6f9d..416ab89cac6b 100644 --- a/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md @@ -8,6 +8,7 @@ ### Bugs Fixed +- Fixed challenge-based authentication to correctly parse the tenant ID from DSTSv2 authority URIs ([#45326](https://github.com/Azure/azure-sdk-for-python/issues/45326)). - Fixed a bug in the challenge authentication policy where the authentication challenge was cached before the challenge resource was verified. The challenge is now cached only after resource verification succeeds [#48710](https://github.com/Azure/azure-sdk-for-python/pull/48710). ### Other Changes diff --git a/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/http_challenge.py b/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/http_challenge.py index 8b14b999de78..5055981bda1a 100644 --- a/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/http_challenge.py +++ b/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/http_challenge.py @@ -6,6 +6,8 @@ from typing import Dict, MutableMapping, Optional from urllib import parse +_DSTS_V2_PATH_SEGMENT = "dstsv2" + class HttpChallenge(object): """An object representing the content of a Key Vault authentication challenge. @@ -66,11 +68,7 @@ def __init__( if "authorization" not in self._parameters and "authorization_uri" not in self._parameters: raise ValueError("Invalid challenge parameters") - authorization_uri = self.get_authorization_server() - # the authorization server URI should look something like https://login.windows.net/tenant-id - raw_uri_path = str(parse.urlparse(authorization_uri).path) - uri_path = raw_uri_path.lstrip("/") - self.tenant_id = uri_path.split("/", maxsplit=1)[0] or None + self.tenant_id = self._parse_tenant_id(self.get_authorization_server()) # if the response headers were supplied if response_headers: @@ -78,6 +76,27 @@ def __init__( self.server_signature_key = response_headers.get("x-ms-message-signing-key", None) self.server_encryption_key = response_headers.get("x-ms-message-encryption-key", None) + @staticmethod + def _parse_tenant_id(authorization_uri: str) -> "Optional[str]": + """Extracts the tenant ID from the authorization server URI of a challenge. + + For Microsoft Entra ID authorities the tenant ID is the first path segment, for example + https://login.microsoftonline.com/. For DSTSv2 authorities the first path segment is the literal + "dstsv2" and the tenant ID is the second path segment, for example + https://uswest2-passive-dsts.dsts.core.windows.net/dstsv2/. + + :param str authorization_uri: The authorization server URI from the challenge. + + :returns: The tenant ID, or None if the URI does not contain one. + :rtype: str or None + """ + raw_uri_path = str(parse.urlparse(authorization_uri).path) + path_segments = raw_uri_path.lstrip("/").split("/") + tenant_id = path_segments[0] + if tenant_id.lower() == _DSTS_V2_PATH_SEGMENT and len(path_segments) > 1 and path_segments[1]: + tenant_id = path_segments[1] + return tenant_id or None + def is_bearer_challenge(self) -> bool: """Tests whether the HttpChallenge is a Bearer challenge. diff --git a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py index 904084ca9146..b6886067e1e6 100644 --- a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py @@ -992,3 +992,78 @@ def get_token(*_, **__): pipeline.run(first_request) pipeline.run(HttpRequest("GET", second_url)) + + +ENTRA_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd022db57" +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.parametrize( + "authority,expected_tenant", + [ + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}", ENTRA_TENANT_ID), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/oauth2/authorize", ENTRA_TENANT_ID), + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}", DSTS_TENANT_ID), + (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), + # a DSTSv2 authority without a tenant segment keeps the previous behavior + (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + ("https://login.microsoftonline.com/", None), + ], +) +def test_challenge_parsing_tenant_id(authority, expected_tenant): + """The tenant ID should be parsed from both Microsoft Entra ID and DSTSv2 authorization URIs""" + + challenge = HttpChallenge( + "https://request.uri", challenge=f'Bearer authorization="{authority}", resource=https://vault.azure.net' + ) + + assert challenge.get_authorization_server() == authority + assert challenge.tenant_id == expected_tenant + + +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 diff --git a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth_async.py b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth_async.py index 808bf071d501..5903de560bda 100644 --- a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth_async.py +++ b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth_async.py @@ -921,3 +921,55 @@ async def get_token(*_, **__): await pipeline.run(first_request) await pipeline.run(HttpRequest("GET", second_url)) + + +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +async def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + async def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + async def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + await pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 diff --git a/sdk/keyvault/azure-keyvault-secrets/CHANGELOG.md b/sdk/keyvault/azure-keyvault-secrets/CHANGELOG.md index b1fa82213033..941f0572e89a 100644 --- a/sdk/keyvault/azure-keyvault-secrets/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-secrets/CHANGELOG.md @@ -1,5 +1,17 @@ # Release History +## 4.11.3 (Unreleased) + +### Features Added + +### Breaking Changes + +### Bugs Fixed + +- Fixed challenge-based authentication to correctly parse the tenant ID from DSTSv2 authority URIs ([#45326](https://github.com/Azure/azure-sdk-for-python/issues/45326)). + +### Other Changes + ## 4.11.2 (2026-08-25) ### Bugs Fixed diff --git a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/http_challenge.py b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/http_challenge.py index 8b14b999de78..5055981bda1a 100644 --- a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/http_challenge.py +++ b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/http_challenge.py @@ -6,6 +6,8 @@ from typing import Dict, MutableMapping, Optional from urllib import parse +_DSTS_V2_PATH_SEGMENT = "dstsv2" + class HttpChallenge(object): """An object representing the content of a Key Vault authentication challenge. @@ -66,11 +68,7 @@ def __init__( if "authorization" not in self._parameters and "authorization_uri" not in self._parameters: raise ValueError("Invalid challenge parameters") - authorization_uri = self.get_authorization_server() - # the authorization server URI should look something like https://login.windows.net/tenant-id - raw_uri_path = str(parse.urlparse(authorization_uri).path) - uri_path = raw_uri_path.lstrip("/") - self.tenant_id = uri_path.split("/", maxsplit=1)[0] or None + self.tenant_id = self._parse_tenant_id(self.get_authorization_server()) # if the response headers were supplied if response_headers: @@ -78,6 +76,27 @@ def __init__( self.server_signature_key = response_headers.get("x-ms-message-signing-key", None) self.server_encryption_key = response_headers.get("x-ms-message-encryption-key", None) + @staticmethod + def _parse_tenant_id(authorization_uri: str) -> "Optional[str]": + """Extracts the tenant ID from the authorization server URI of a challenge. + + For Microsoft Entra ID authorities the tenant ID is the first path segment, for example + https://login.microsoftonline.com/. For DSTSv2 authorities the first path segment is the literal + "dstsv2" and the tenant ID is the second path segment, for example + https://uswest2-passive-dsts.dsts.core.windows.net/dstsv2/. + + :param str authorization_uri: The authorization server URI from the challenge. + + :returns: The tenant ID, or None if the URI does not contain one. + :rtype: str or None + """ + raw_uri_path = str(parse.urlparse(authorization_uri).path) + path_segments = raw_uri_path.lstrip("/").split("/") + tenant_id = path_segments[0] + if tenant_id.lower() == _DSTS_V2_PATH_SEGMENT and len(path_segments) > 1 and path_segments[1]: + tenant_id = path_segments[1] + return tenant_id or None + def is_bearer_challenge(self) -> bool: """Tests whether the HttpChallenge is a Bearer challenge. diff --git a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_version.py b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_version.py index f526d23db20e..bb65661c34de 100644 --- a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_version.py +++ b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_version.py @@ -3,4 +3,4 @@ # Licensed under the MIT License. # ------------------------------------ -VERSION = "4.11.2" +VERSION = "4.11.3" diff --git a/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py index 5b61e1d925af..0f8c860395d5 100644 --- a/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py @@ -16,7 +16,7 @@ from azure.core.credentials import AccessToken, AccessTokenInfo from azure.core.pipeline import Pipeline from azure.core.rest import HttpRequest -from azure.keyvault.secrets._shared import ChallengeAuthPolicy, HttpChallengeCache +from azure.keyvault.secrets._shared import ChallengeAuthPolicy, HttpChallenge, HttpChallengeCache TOKEN_TYPES = [AccessToken, AccessTokenInfo] @@ -136,3 +136,78 @@ def get_token(*_, **__): pipeline.run(first_request) pipeline.run(HttpRequest("GET", second_url)) + + +ENTRA_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd022db57" +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.parametrize( + "authority,expected_tenant", + [ + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}", ENTRA_TENANT_ID), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/oauth2/authorize", ENTRA_TENANT_ID), + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}", DSTS_TENANT_ID), + (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), + # a DSTSv2 authority without a tenant segment keeps the previous behavior + (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + ("https://login.microsoftonline.com/", None), + ], +) +def test_challenge_parsing_tenant_id(authority, expected_tenant): + """The tenant ID should be parsed from both Microsoft Entra ID and DSTSv2 authorization URIs""" + + challenge = HttpChallenge( + "https://request.uri", challenge=f'Bearer authorization="{authority}", resource=https://vault.azure.net' + ) + + assert challenge.get_authorization_server() == authority + assert challenge.tenant_id == expected_tenant + + +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 diff --git a/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth_async.py b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth_async.py index 6547fbe22c76..406c3bb05af4 100644 --- a/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth_async.py +++ b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth_async.py @@ -131,3 +131,55 @@ async def get_token(*_, **__): await pipeline.run(first_request) await pipeline.run(HttpRequest("GET", second_url)) + + +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +async def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + async def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + async def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + await pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 diff --git a/sdk/keyvault/azure-keyvault-securitydomain/CHANGELOG.md b/sdk/keyvault/azure-keyvault-securitydomain/CHANGELOG.md index 39c0b1848b71..c25d2b01141f 100644 --- a/sdk/keyvault/azure-keyvault-securitydomain/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-securitydomain/CHANGELOG.md @@ -8,6 +8,7 @@ ### Bugs Fixed +- Fixed challenge-based authentication to correctly parse the tenant ID from DSTSv2 authority URIs ([#45326](https://github.com/Azure/azure-sdk-for-python/issues/45326)). - Fixed a bug in the challenge authentication policy where the authentication challenge was cached before the challenge resource was verified. The challenge is now cached only after resource verification succeeds [#48710](https://github.com/Azure/azure-sdk-for-python/pull/48710). ### Other Changes diff --git a/sdk/keyvault/azure-keyvault-securitydomain/azure/keyvault/securitydomain/_internal/http_challenge.py b/sdk/keyvault/azure-keyvault-securitydomain/azure/keyvault/securitydomain/_internal/http_challenge.py index 8b14b999de78..5055981bda1a 100644 --- a/sdk/keyvault/azure-keyvault-securitydomain/azure/keyvault/securitydomain/_internal/http_challenge.py +++ b/sdk/keyvault/azure-keyvault-securitydomain/azure/keyvault/securitydomain/_internal/http_challenge.py @@ -6,6 +6,8 @@ from typing import Dict, MutableMapping, Optional from urllib import parse +_DSTS_V2_PATH_SEGMENT = "dstsv2" + class HttpChallenge(object): """An object representing the content of a Key Vault authentication challenge. @@ -66,11 +68,7 @@ def __init__( if "authorization" not in self._parameters and "authorization_uri" not in self._parameters: raise ValueError("Invalid challenge parameters") - authorization_uri = self.get_authorization_server() - # the authorization server URI should look something like https://login.windows.net/tenant-id - raw_uri_path = str(parse.urlparse(authorization_uri).path) - uri_path = raw_uri_path.lstrip("/") - self.tenant_id = uri_path.split("/", maxsplit=1)[0] or None + self.tenant_id = self._parse_tenant_id(self.get_authorization_server()) # if the response headers were supplied if response_headers: @@ -78,6 +76,27 @@ def __init__( self.server_signature_key = response_headers.get("x-ms-message-signing-key", None) self.server_encryption_key = response_headers.get("x-ms-message-encryption-key", None) + @staticmethod + def _parse_tenant_id(authorization_uri: str) -> "Optional[str]": + """Extracts the tenant ID from the authorization server URI of a challenge. + + For Microsoft Entra ID authorities the tenant ID is the first path segment, for example + https://login.microsoftonline.com/. For DSTSv2 authorities the first path segment is the literal + "dstsv2" and the tenant ID is the second path segment, for example + https://uswest2-passive-dsts.dsts.core.windows.net/dstsv2/. + + :param str authorization_uri: The authorization server URI from the challenge. + + :returns: The tenant ID, or None if the URI does not contain one. + :rtype: str or None + """ + raw_uri_path = str(parse.urlparse(authorization_uri).path) + path_segments = raw_uri_path.lstrip("/").split("/") + tenant_id = path_segments[0] + if tenant_id.lower() == _DSTS_V2_PATH_SEGMENT and len(path_segments) > 1 and path_segments[1]: + tenant_id = path_segments[1] + return tenant_id or None + def is_bearer_challenge(self) -> bool: """Tests whether the HttpChallenge is a Bearer challenge. diff --git a/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth.py index 5281f25155be..223b4a0b3ffb 100644 --- a/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth.py @@ -16,7 +16,7 @@ from azure.core.credentials import AccessToken, AccessTokenInfo from azure.core.pipeline import Pipeline from azure.core.rest import HttpRequest -from azure.keyvault.securitydomain._internal import ChallengeAuthPolicy, HttpChallengeCache +from azure.keyvault.securitydomain._internal import ChallengeAuthPolicy, HttpChallenge, HttpChallengeCache TOKEN_TYPES = [AccessToken, AccessTokenInfo] @@ -136,3 +136,78 @@ def get_token(*_, **__): pipeline.run(first_request) pipeline.run(HttpRequest("GET", second_url)) + + +ENTRA_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd022db57" +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.parametrize( + "authority,expected_tenant", + [ + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}", ENTRA_TENANT_ID), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/oauth2/authorize", ENTRA_TENANT_ID), + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}", DSTS_TENANT_ID), + (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), + # a DSTSv2 authority without a tenant segment keeps the previous behavior + (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + ("https://login.microsoftonline.com/", None), + ], +) +def test_challenge_parsing_tenant_id(authority, expected_tenant): + """The tenant ID should be parsed from both Microsoft Entra ID and DSTSv2 authorization URIs""" + + challenge = HttpChallenge( + "https://request.uri", challenge=f'Bearer authorization="{authority}", resource=https://vault.azure.net' + ) + + assert challenge.get_authorization_server() == authority + assert challenge.tenant_id == expected_tenant + + +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 diff --git a/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth_async.py b/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth_async.py index 56c53469bd1f..83bb473ea369 100644 --- a/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth_async.py +++ b/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth_async.py @@ -132,3 +132,55 @@ async def get_token(*_, **__): await pipeline.run(first_request) await pipeline.run(HttpRequest("GET", second_url)) + + +DSTS_TENANT_ID = "de763a21-49f7-4b08-a8e1-52c8fbc103b4" +DSTS_AUTHORITY = "https://uswest2-passive-dsts.dsts.core.windows.net" + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +async def test_tenant_dstsv2(token_type): + """The policy's token requests should pass the tenant ID that follows the "dstsv2" segment of the authority""" + + expected_token = "expected_token" + authority = f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}" + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": f'Bearer authorization="{authority}", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + async def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request should be unauthorized + assert "Authorization" not in request.headers + return challenge + elif Requests.count == 2: + # second request should be authorized according to the challenge + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + async def get_token(*_, options=None, **kwargs): + options_bag = options if options else kwargs + assert options_bag.get("tenant_id") == DSTS_TENANT_ID + return token_type(expected_token, 0) + + if token_type == AccessToken: + credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token)) + else: + credential = Mock(spec_set=["get_token_info"], get_token_info=Mock(wraps=get_token)) + + pipeline = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + await pipeline.run(HttpRequest("GET", get_random_url())) + + assert Requests.count == 2 + if hasattr(credential, "get_token"): + assert credential.get_token.call_count == 1 + else: + assert credential.get_token_info.call_count == 1 From c8fa6b0523c33969a78a7e811650237984c8f9a7 Mon Sep 17 00:00:00 2001 From: Prasanna Sankaran Date: Mon, 14 Sep 2026 17:51:11 -0700 Subject: [PATCH 2/3] Add DSTS terms to the Key Vault cspell words --- .vscode/cspell.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 82ca0b271c71..931c2f6433ee 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -1111,6 +1111,8 @@ { "filename": "sdk/keyvault/**", "words": [ + "DSTS", + "dstsv", "eddsa", "Thawte" ] From df5cec59a2a9049668ff06c37115ae87caf0bee6 Mon Sep 17 00:00:00 2001 From: Prasanna Sankaran Date: Thu, 17 Sep 2026 08:26:13 -0700 Subject: [PATCH 3/3] Cover more DSTSv2 authorization URI edge cases in the challenge tests Pins that an empty segment after dstsv2 is not used as the tenant ID, that path segments after the DSTSv2 tenant ID are ignored, and that only an exact dstsv2 first segment denotes a DSTSv2 authority. --- .../tests/test_challenge_auth.py | 8 ++++++++ .../tests/test_challenge_auth.py | 8 ++++++++ .../azure-keyvault-keys/tests/test_challenge_auth.py | 8 ++++++++ .../azure-keyvault-secrets/tests/test_challenge_auth.py | 8 ++++++++ .../tests/test_challenge_auth.py | 8 ++++++++ 5 files changed, 40 insertions(+) diff --git a/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py index 1ef91622a7ff..21122bf03a2d 100644 --- a/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py @@ -269,6 +269,14 @@ async def get_token(*_, **__): (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), # a DSTSv2 authority without a tenant segment keeps the previous behavior (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + (f"{DSTS_AUTHORITY}/dstsv2/", "dstsv2"), + # an empty segment after "dstsv2" is not used as the tenant ID + (f"{DSTS_AUTHORITY}/dstsv2//{DSTS_TENANT_ID}", "dstsv2"), + # path segments after the DSTSv2 tenant ID are ignored + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}/oauth2/token", DSTS_TENANT_ID), + # only an exact "dstsv2" first segment denotes a DSTSv2 authority + (f"{DSTS_AUTHORITY}/dstsv2x/{DSTS_TENANT_ID}", "dstsv2x"), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/dstsv2/{DSTS_TENANT_ID}", ENTRA_TENANT_ID), ("https://login.microsoftonline.com/", None), ], ) diff --git a/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth.py index 6482d7023c71..ea2fb4ea5ee4 100644 --- a/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-certificates/tests/test_challenge_auth.py @@ -152,6 +152,14 @@ def get_token(*_, **__): (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), # a DSTSv2 authority without a tenant segment keeps the previous behavior (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + (f"{DSTS_AUTHORITY}/dstsv2/", "dstsv2"), + # an empty segment after "dstsv2" is not used as the tenant ID + (f"{DSTS_AUTHORITY}/dstsv2//{DSTS_TENANT_ID}", "dstsv2"), + # path segments after the DSTSv2 tenant ID are ignored + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}/oauth2/token", DSTS_TENANT_ID), + # only an exact "dstsv2" first segment denotes a DSTSv2 authority + (f"{DSTS_AUTHORITY}/dstsv2x/{DSTS_TENANT_ID}", "dstsv2x"), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/dstsv2/{DSTS_TENANT_ID}", ENTRA_TENANT_ID), ("https://login.microsoftonline.com/", None), ], ) diff --git a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py index b6886067e1e6..6e43b458c928 100644 --- a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py @@ -1008,6 +1008,14 @@ def get_token(*_, **__): (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), # a DSTSv2 authority without a tenant segment keeps the previous behavior (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + (f"{DSTS_AUTHORITY}/dstsv2/", "dstsv2"), + # an empty segment after "dstsv2" is not used as the tenant ID + (f"{DSTS_AUTHORITY}/dstsv2//{DSTS_TENANT_ID}", "dstsv2"), + # path segments after the DSTSv2 tenant ID are ignored + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}/oauth2/token", DSTS_TENANT_ID), + # only an exact "dstsv2" first segment denotes a DSTSv2 authority + (f"{DSTS_AUTHORITY}/dstsv2x/{DSTS_TENANT_ID}", "dstsv2x"), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/dstsv2/{DSTS_TENANT_ID}", ENTRA_TENANT_ID), ("https://login.microsoftonline.com/", None), ], ) diff --git a/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py index 0f8c860395d5..482039ac35d7 100644 --- a/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py @@ -152,6 +152,14 @@ def get_token(*_, **__): (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), # a DSTSv2 authority without a tenant segment keeps the previous behavior (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + (f"{DSTS_AUTHORITY}/dstsv2/", "dstsv2"), + # an empty segment after "dstsv2" is not used as the tenant ID + (f"{DSTS_AUTHORITY}/dstsv2//{DSTS_TENANT_ID}", "dstsv2"), + # path segments after the DSTSv2 tenant ID are ignored + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}/oauth2/token", DSTS_TENANT_ID), + # only an exact "dstsv2" first segment denotes a DSTSv2 authority + (f"{DSTS_AUTHORITY}/dstsv2x/{DSTS_TENANT_ID}", "dstsv2x"), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/dstsv2/{DSTS_TENANT_ID}", ENTRA_TENANT_ID), ("https://login.microsoftonline.com/", None), ], ) diff --git a/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth.py index 223b4a0b3ffb..1410a2b8bbfa 100644 --- a/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-securitydomain/tests/test_challenge_auth.py @@ -152,6 +152,14 @@ def get_token(*_, **__): (f"{DSTS_AUTHORITY}/DSTSv2/{DSTS_TENANT_ID}/", DSTS_TENANT_ID), # a DSTSv2 authority without a tenant segment keeps the previous behavior (f"{DSTS_AUTHORITY}/dstsv2", "dstsv2"), + (f"{DSTS_AUTHORITY}/dstsv2/", "dstsv2"), + # an empty segment after "dstsv2" is not used as the tenant ID + (f"{DSTS_AUTHORITY}/dstsv2//{DSTS_TENANT_ID}", "dstsv2"), + # path segments after the DSTSv2 tenant ID are ignored + (f"{DSTS_AUTHORITY}/dstsv2/{DSTS_TENANT_ID}/oauth2/token", DSTS_TENANT_ID), + # only an exact "dstsv2" first segment denotes a DSTSv2 authority + (f"{DSTS_AUTHORITY}/dstsv2x/{DSTS_TENANT_ID}", "dstsv2x"), + (f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/dstsv2/{DSTS_TENANT_ID}", ENTRA_TENANT_ID), ("https://login.microsoftonline.com/", None), ], )