From 7ef068ac242af0650d55b213d335eb3ca1eebb4c Mon Sep 17 00:00:00 2001 From: Hari K Date: Mon, 29 Jun 2026 10:36:33 +0100 Subject: [PATCH 01/10] Fix replay bug and update tests --- .../CHANGELOG.md | 2 ++ .../azure-keyvault-administration/assets.json | 2 +- .../_internal/async_challenge_auth_policy.py | 16 ++++++++------- .../_internal/challenge_auth_policy.py | 20 +++++++++++++------ sdk/keyvault/azure-keyvault-keys/CHANGELOG.md | 2 ++ sdk/keyvault/azure-keyvault-keys/assets.json | 2 +- .../_shared/async_challenge_auth_policy.py | 16 ++++++++------- .../keys/_shared/challenge_auth_policy.py | 20 +++++++++++++------ 8 files changed, 52 insertions(+), 28 deletions(-) diff --git a/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md b/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md index a9614600c1a8..2c8523e3aaaf 100644 --- a/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bugs Fixed +- Fixed an replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. + ### Other Changes ## 4.8.0b1 (2026-05-29) diff --git a/sdk/keyvault/azure-keyvault-administration/assets.json b/sdk/keyvault/azure-keyvault-administration/assets.json index 9f3d56889534..041ab8595e83 100644 --- a/sdk/keyvault/azure-keyvault-administration/assets.json +++ b/sdk/keyvault/azure-keyvault-administration/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/keyvault/azure-keyvault-administration", - "Tag": "python/keyvault/azure-keyvault-administration_60fd5d77a0" + "Tag": "python/keyvault/azure-keyvault-administration_28d960bd8f" } diff --git a/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/async_challenge_auth_policy.py b/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/async_challenge_auth_policy.py index 3e3ac1855178..cf8913a03cf1 100644 --- a/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/async_challenge_auth_policy.py +++ b/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/async_challenge_auth_policy.py @@ -30,7 +30,7 @@ from .http_challenge import HttpChallenge from . import http_challenge_cache as ChallengeCache -from .challenge_auth_policy import _enforce_tls, _has_claims, _update_challenge +from .challenge_auth_policy import _enforce_tls, _has_claims, _update_challenge, _REQUEST_COPY_KEY if sys.version_info < (3, 9): from typing import Awaitable @@ -80,7 +80,6 @@ def __init__(self, credential: AsyncTokenProvider, *scopes: str, **kwargs: Any) self._credential: AsyncTokenProvider = credential self._token: Optional[Union["AccessToken", "AccessTokenInfo"]] = None self._verify_challenge_resource = kwargs.pop("verify_challenge_resource", True) - self._request_copy: Optional[HttpRequest] = None async def send(self, request: PipelineRequest[HttpRequest]) -> PipelineResponse[HttpRequest, AsyncHttpResponse]: """Authorize request with a bearer token and send it to the next policy. @@ -171,8 +170,10 @@ async def on_request(self, request: PipelineRequest) -> None: # saving it for later. Key Vault will reject the request as unauthorized and respond with a challenge. # on_challenge will parse that challenge, use the original request including the body, authorize the # request, and tell super to send it again. - if request.http_request.content: - self._request_copy = request.http_request + # The original request is stashed on the request's context (per-request), so it cannot leak into a later + # request made by the same client. Don't overwrite a previously stashed copy if this is a retry. + if request.http_request.content and _REQUEST_COPY_KEY not in request.context: + request.context[_REQUEST_COPY_KEY] = request.http_request bodiless_request = HttpRequest( method=request.http_request.method, url=request.http_request.url, @@ -214,9 +215,10 @@ async def on_challenge(self, request: PipelineRequest, response: PipelineRespons "See https://aka.ms/azsdk/blog/vault-uri for more information." ) - # If we had created a request copy in on_request, use it now to send along the original body content - if self._request_copy: - request.http_request = self._request_copy + # If we stashed the original request in on_request, use it now to send along the original body content + request_copy = request.context.get(_REQUEST_COPY_KEY) + if request_copy: + request.http_request = request_copy # The tenant parsed from AD FS challenges is "adfs"; we don't actually need a tenant for AD FS authentication # For AD FS we skip cross-tenant authentication per https://github.com/Azure/azure-sdk-for-python/issues/28648 diff --git a/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/challenge_auth_policy.py b/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/challenge_auth_policy.py index eb4073d0e699..8d3685afc128 100644 --- a/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/challenge_auth_policy.py +++ b/sdk/keyvault/azure-keyvault-administration/azure/keyvault/administration/_internal/challenge_auth_policy.py @@ -36,6 +36,12 @@ from . import http_challenge_cache as ChallengeCache +# Key under which the original request is stashed on the per-request pipeline context during the challenge flow. +# Storing this per-request (rather than on the policy instance) prevents the body of one request from leaking into a +# subsequent request made by the same client. +_REQUEST_COPY_KEY = "key_vault_request_copy" + + def _enforce_tls(request: PipelineRequest) -> None: if not request.http_request.url.lower().startswith("https"): raise ServiceRequestError( @@ -93,7 +99,6 @@ def __init__(self, credential: TokenProvider, *scopes: str, **kwargs: Any) -> No self._credential: TokenProvider = credential self._token: Optional[Union["AccessToken", "AccessTokenInfo"]] = None self._verify_challenge_resource = kwargs.pop("verify_challenge_resource", True) - self._request_copy: Optional[HttpRequest] = None def send(self, request: PipelineRequest[HttpRequest]) -> PipelineResponse[HttpRequest, HttpResponse]: """Authorize request with a bearer token and send it to the next policy. @@ -184,8 +189,10 @@ def on_request(self, request: PipelineRequest) -> None: # saving it for later. Key Vault will reject the request as unauthorized and respond with a challenge. # on_challenge will parse that challenge, use the original request including the body, authorize the # request, and tell super to send it again. - if request.http_request.content: - self._request_copy = request.http_request + # The original request is stashed on the request's context (per-request), so it cannot leak into a later + # request made by the same client. Don't overwrite a previously stashed copy if this is a retry. + if request.http_request.content and _REQUEST_COPY_KEY not in request.context: + request.context[_REQUEST_COPY_KEY] = request.http_request bodiless_request = HttpRequest( method=request.http_request.method, url=request.http_request.url, @@ -227,9 +234,10 @@ def on_challenge(self, request: PipelineRequest, response: PipelineResponse) -> "See https://aka.ms/azsdk/blog/vault-uri for more information." ) - # If we had created a request copy in on_request, use it now to send along the original body content - if self._request_copy: - request.http_request = self._request_copy + # If we stashed the original request in on_request, use it now to send along the original body content + request_copy = request.context.get(_REQUEST_COPY_KEY) + if request_copy: + request.http_request = request_copy # The tenant parsed from AD FS challenges is "adfs"; we don't actually need a tenant for AD FS authentication # For AD FS we skip cross-tenant authentication per https://github.com/Azure/azure-sdk-for-python/issues/28648 diff --git a/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md b/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md index b30ee56687ea..f346e17b9dad 100644 --- a/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md @@ -12,6 +12,8 @@ ### Bugs Fixed +- Fixed an replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. + ### Other Changes ## 4.12.0b2 (2026-05-29) diff --git a/sdk/keyvault/azure-keyvault-keys/assets.json b/sdk/keyvault/azure-keyvault-keys/assets.json index a48eb13f1ca3..259c9a4d6326 100644 --- a/sdk/keyvault/azure-keyvault-keys/assets.json +++ b/sdk/keyvault/azure-keyvault-keys/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/keyvault/azure-keyvault-keys", - "Tag": "python/keyvault/azure-keyvault-keys_0031f0c596" + "Tag": "python/keyvault/azure-keyvault-keys_0b4df6bb52" } diff --git a/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/async_challenge_auth_policy.py b/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/async_challenge_auth_policy.py index 3e3ac1855178..cf8913a03cf1 100644 --- a/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/async_challenge_auth_policy.py +++ b/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/async_challenge_auth_policy.py @@ -30,7 +30,7 @@ from .http_challenge import HttpChallenge from . import http_challenge_cache as ChallengeCache -from .challenge_auth_policy import _enforce_tls, _has_claims, _update_challenge +from .challenge_auth_policy import _enforce_tls, _has_claims, _update_challenge, _REQUEST_COPY_KEY if sys.version_info < (3, 9): from typing import Awaitable @@ -80,7 +80,6 @@ def __init__(self, credential: AsyncTokenProvider, *scopes: str, **kwargs: Any) self._credential: AsyncTokenProvider = credential self._token: Optional[Union["AccessToken", "AccessTokenInfo"]] = None self._verify_challenge_resource = kwargs.pop("verify_challenge_resource", True) - self._request_copy: Optional[HttpRequest] = None async def send(self, request: PipelineRequest[HttpRequest]) -> PipelineResponse[HttpRequest, AsyncHttpResponse]: """Authorize request with a bearer token and send it to the next policy. @@ -171,8 +170,10 @@ async def on_request(self, request: PipelineRequest) -> None: # saving it for later. Key Vault will reject the request as unauthorized and respond with a challenge. # on_challenge will parse that challenge, use the original request including the body, authorize the # request, and tell super to send it again. - if request.http_request.content: - self._request_copy = request.http_request + # The original request is stashed on the request's context (per-request), so it cannot leak into a later + # request made by the same client. Don't overwrite a previously stashed copy if this is a retry. + if request.http_request.content and _REQUEST_COPY_KEY not in request.context: + request.context[_REQUEST_COPY_KEY] = request.http_request bodiless_request = HttpRequest( method=request.http_request.method, url=request.http_request.url, @@ -214,9 +215,10 @@ async def on_challenge(self, request: PipelineRequest, response: PipelineRespons "See https://aka.ms/azsdk/blog/vault-uri for more information." ) - # If we had created a request copy in on_request, use it now to send along the original body content - if self._request_copy: - request.http_request = self._request_copy + # If we stashed the original request in on_request, use it now to send along the original body content + request_copy = request.context.get(_REQUEST_COPY_KEY) + if request_copy: + request.http_request = request_copy # The tenant parsed from AD FS challenges is "adfs"; we don't actually need a tenant for AD FS authentication # For AD FS we skip cross-tenant authentication per https://github.com/Azure/azure-sdk-for-python/issues/28648 diff --git a/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/challenge_auth_policy.py b/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/challenge_auth_policy.py index eb4073d0e699..8d3685afc128 100644 --- a/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/challenge_auth_policy.py +++ b/sdk/keyvault/azure-keyvault-keys/azure/keyvault/keys/_shared/challenge_auth_policy.py @@ -36,6 +36,12 @@ from . import http_challenge_cache as ChallengeCache +# Key under which the original request is stashed on the per-request pipeline context during the challenge flow. +# Storing this per-request (rather than on the policy instance) prevents the body of one request from leaking into a +# subsequent request made by the same client. +_REQUEST_COPY_KEY = "key_vault_request_copy" + + def _enforce_tls(request: PipelineRequest) -> None: if not request.http_request.url.lower().startswith("https"): raise ServiceRequestError( @@ -93,7 +99,6 @@ def __init__(self, credential: TokenProvider, *scopes: str, **kwargs: Any) -> No self._credential: TokenProvider = credential self._token: Optional[Union["AccessToken", "AccessTokenInfo"]] = None self._verify_challenge_resource = kwargs.pop("verify_challenge_resource", True) - self._request_copy: Optional[HttpRequest] = None def send(self, request: PipelineRequest[HttpRequest]) -> PipelineResponse[HttpRequest, HttpResponse]: """Authorize request with a bearer token and send it to the next policy. @@ -184,8 +189,10 @@ def on_request(self, request: PipelineRequest) -> None: # saving it for later. Key Vault will reject the request as unauthorized and respond with a challenge. # on_challenge will parse that challenge, use the original request including the body, authorize the # request, and tell super to send it again. - if request.http_request.content: - self._request_copy = request.http_request + # The original request is stashed on the request's context (per-request), so it cannot leak into a later + # request made by the same client. Don't overwrite a previously stashed copy if this is a retry. + if request.http_request.content and _REQUEST_COPY_KEY not in request.context: + request.context[_REQUEST_COPY_KEY] = request.http_request bodiless_request = HttpRequest( method=request.http_request.method, url=request.http_request.url, @@ -227,9 +234,10 @@ def on_challenge(self, request: PipelineRequest, response: PipelineResponse) -> "See https://aka.ms/azsdk/blog/vault-uri for more information." ) - # If we had created a request copy in on_request, use it now to send along the original body content - if self._request_copy: - request.http_request = self._request_copy + # If we stashed the original request in on_request, use it now to send along the original body content + request_copy = request.context.get(_REQUEST_COPY_KEY) + if request_copy: + request.http_request = request_copy # The tenant parsed from AD FS challenges is "adfs"; we don't actually need a tenant for AD FS authentication # For AD FS we skip cross-tenant authentication per https://github.com/Azure/azure-sdk-for-python/issues/28648 From 4b7094cc20fa63fa3e3403546e0fa24d8df2e9a2 Mon Sep 17 00:00:00 2001 From: Hari K Date: Mon, 29 Jun 2026 12:31:10 +0100 Subject: [PATCH 02/10] ran api stub gen --- .../azure-keyvault-administration/api.md | 758 ++++++++++++++++++ .../api.metadata.yml | 3 + 2 files changed, 761 insertions(+) create mode 100644 sdk/keyvault/azure-keyvault-administration/api.md create mode 100644 sdk/keyvault/azure-keyvault-administration/api.metadata.yml diff --git a/sdk/keyvault/azure-keyvault-administration/api.md b/sdk/keyvault/azure-keyvault-administration/api.md new file mode 100644 index 000000000000..7b640989b115 --- /dev/null +++ b/sdk/keyvault/azure-keyvault-administration/api.md @@ -0,0 +1,758 @@ +```py +namespace azure.keyvault.administration + + class azure.keyvault.administration.ApiVersion(str, Enum, metaclass=CaseInsensitiveEnumMeta): + V2025_07_01 = "2025-07-01" + V2026_01_01_PREVIEW = "2026-01-01-preview" + V7_2 = "7.2" + V7_3 = "7.3" + V7_4 = "7.4" + V7_5 = "7.5" + V7_6 = "7.6" + + + class azure.keyvault.administration.KeyVaultAccessControlClient(KeyVaultClientBase): implements ContextManager + property vault_url: str # Read-only + + def __init__( + self, + vault_url: str, + credential: TokenCredential, + *, + api_version: Union[ApiVersion, str] = ..., + verify_challenge_resource: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + + def close(self) -> None: ... + + @distributed_trace + def create_role_assignment( + self, + scope: Union[str, KeyVaultRoleScope], + definition_id: str, + principal_id: str, + *, + name: Optional[Union[str, UUID]] = ..., + **kwargs: Any + ) -> KeyVaultRoleAssignment: ... + + @distributed_trace + def delete_role_assignment( + self, + scope: Union[str, KeyVaultRoleScope], + name: Union[str, UUID], + **kwargs: Any + ) -> None: ... + + @distributed_trace + def delete_role_definition( + self, + scope: Union[str, KeyVaultRoleScope], + name: Union[str, UUID], + **kwargs: Any + ) -> None: ... + + @distributed_trace + def get_role_assignment( + self, + scope: Union[str, KeyVaultRoleScope], + name: Union[str, UUID], + **kwargs: Any + ) -> KeyVaultRoleAssignment: ... + + @distributed_trace + def get_role_definition( + self, + scope: Union[str, KeyVaultRoleScope], + name: Union[str, UUID], + **kwargs: Any + ) -> KeyVaultRoleDefinition: ... + + @distributed_trace + def list_role_assignments( + self, + scope: Union[str, KeyVaultRoleScope], + **kwargs: Any + ) -> ItemPaged[KeyVaultRoleAssignment]: ... + + @distributed_trace + def list_role_definitions( + self, + scope: Union[str, KeyVaultRoleScope], + **kwargs: Any + ) -> ItemPaged[KeyVaultRoleDefinition]: ... + + @distributed_trace + def send_request( + self, + request: HttpRequest, + *, + stream: bool = False, + **kwargs: Any + ) -> HttpResponse: ... + + @distributed_trace + def set_role_definition( + self, + scope: Union[str, KeyVaultRoleScope], + *, + assignable_scopes: Optional[List[Union[str, KeyVaultRoleScope]]] = ..., + description: Optional[str] = ..., + name: Optional[Union[str, UUID]] = ..., + permissions: Optional[List[KeyVaultPermission]] = ..., + role_name: Optional[str] = ..., + **kwargs: Any + ) -> KeyVaultRoleDefinition: ... + + + class azure.keyvault.administration.KeyVaultBackupClient(KeyVaultClientBase): implements ContextManager + property vault_url: str # Read-only + + def __init__( + self, + vault_url: str, + credential: TokenCredential, + *, + api_version: Union[ApiVersion, str] = ..., + verify_challenge_resource: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + + @overload + def begin_backup( + self, + blob_storage_url: str, + *, + continuation_token: Optional[str] = ..., + use_managed_identity: Literal[True], + **kwargs: Any + ) -> LROPoller[KeyVaultBackupResult]: ... + + @overload + def begin_backup( + self, + blob_storage_url: str, + *, + continuation_token: Optional[str] = ..., + sas_token: str, + **kwargs: Any + ) -> LROPoller[KeyVaultBackupResult]: ... + + @overload + def begin_pre_backup( + self, + blob_storage_url: str, + *, + continuation_token: Optional[str] = ..., + use_managed_identity: Literal[True], + **kwargs: Any + ) -> LROPoller[None]: ... + + @overload + def begin_pre_backup( + self, + blob_storage_url: str, + *, + continuation_token: Optional[str] = ..., + sas_token: str, + **kwargs: Any + ) -> LROPoller[None]: ... + + @overload + def begin_pre_restore( + self, + folder_url: str, + *, + continuation_token: Optional[str] = ..., + use_managed_identity: Literal[True], + **kwargs: Any + ) -> LROPoller[None]: ... + + @overload + def begin_pre_restore( + self, + folder_url: str, + *, + continuation_token: Optional[str] = ..., + sas_token: str, + **kwargs: Any + ) -> LROPoller[None]: ... + + @overload + def begin_restore( + self, + folder_url: str, + *, + continuation_token: Optional[str] = ..., + key_name: Optional[str] = ..., + use_managed_identity: Literal[True], + **kwargs: Any + ) -> LROPoller[None]: ... + + @overload + def begin_restore( + self, + folder_url: str, + *, + continuation_token: Optional[str] = ..., + key_name: Optional[str] = ..., + sas_token: str, + **kwargs: Any + ) -> LROPoller[None]: ... + + def close(self) -> None: ... + + @distributed_trace + def send_request( + self, + request: HttpRequest, + *, + stream: bool = False, + **kwargs: Any + ) -> HttpResponse: ... + + + class azure.keyvault.administration.KeyVaultBackupResult: + folder_url: str + + def __init__(self, **kwargs: Any) -> None: ... + + + class azure.keyvault.administration.KeyVaultDataAction(str, Enum, metaclass=CaseInsensitiveEnumMeta): + BACKUP_HSM_KEYS = "Microsoft.KeyVault/managedHsm/keys/backup/action" + CREATE_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/create" + DECRYPT_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/decrypt/action" + DELETE_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/delete" + DELETE_ROLE_ASSIGNMENT = "Microsoft.KeyVault/managedHsm/roleAssignments/delete/action" + DELETE_ROLE_DEFINITION = "Microsoft.KeyVault/managedHsm/roleDefinitions/delete/action" + DOWNLOAD_HSM_SECURITY_DOMAIN = "Microsoft.KeyVault/managedHsm/securitydomain/download/action" + DOWNLOAD_HSM_SECURITY_DOMAIN_STATUS = "Microsoft.KeyVault/managedHsm/securitydomain/download/read" + ENCRYPT_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/encrypt/action" + EXPORT_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/export/action" + GET_ROLE_ASSIGNMENT = "Microsoft.KeyVault/managedHsm/roleAssignments/read/action" + IMPORT_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/import/action" + PURGE_DELETED_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/deletedKeys/delete" + RANDOM_NUMBERS_GENERATE = "Microsoft.KeyVault/managedHsm/rng/action" + READ_DELETED_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/deletedKeys/read/action" + READ_HSM_BACKUP_STATUS = "Microsoft.KeyVault/managedHsm/backup/status/action" + READ_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/read/action" + READ_HSM_RESTORE_STATUS = "Microsoft.KeyVault/managedHsm/restore/status/action" + READ_HSM_SECURITY_DOMAIN_STATUS = "Microsoft.KeyVault/managedHsm/securitydomain/upload/read" + READ_HSM_SECURITY_DOMAIN_TRANSFER_KEY = "Microsoft.KeyVault/managedHsm/securitydomain/transferkey/read" + READ_ROLE_DEFINITION = "Microsoft.KeyVault/managedHsm/roleDefinitions/read/action" + RECOVER_DELETED_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/deletedKeys/recover/action" + RELEASE_KEY = "Microsoft.KeyVault/managedHsm/keys/release/action" + RESTORE_HSM_KEYS = "Microsoft.KeyVault/managedHsm/keys/restore/action" + SIGN_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/sign/action" + START_HSM_BACKUP = "Microsoft.KeyVault/managedHsm/backup/start/action" + START_HSM_RESTORE = "Microsoft.KeyVault/managedHsm/restore/start/action" + UNWRAP_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/unwrap/action" + UPLOAD_HSM_SECURITY_DOMAIN = "Microsoft.KeyVault/managedHsm/securitydomain/upload/action" + VERIFY_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/verify/action" + WRAP_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/wrap/action" + WRITE_HSM_KEY = "Microsoft.KeyVault/managedHsm/keys/write/action" + WRITE_ROLE_ASSIGNMENT = "Microsoft.KeyVault/managedHsm/roleAssignments/write/action" + WRITE_ROLE_DEFINITION = "Microsoft.KeyVault/managedHsm/roleDefinitions/write/action" + + + class azure.keyvault.administration.KeyVaultEkmClient(KeyVaultClientBase): implements ContextManager + property vault_url: str # Read-only + + def __init__( + self, + vault_url: str, + credential: TokenCredential, + *, + api_version: Union[ApiVersion, str] = ..., + verify_challenge_resource: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + + @distributed_trace + def check_ekm_connection(self, **kwargs: Any) -> KeyVaultEkmProxyInfo: ... + + def close(self) -> None: ... + + @distributed_trace + def create_ekm_connection( + self, + connection: KeyVaultEkmConnection, + **kwargs: Any + ) -> KeyVaultEkmConnection: ... + + @distributed_trace + def delete_ekm_connection(self, **kwargs: Any) -> KeyVaultEkmConnection: ... + + @distributed_trace + def get_ekm_certificate(self, **kwargs: Any) -> KeyVaultEkmProxyClientCertificateInfo: ... + + @distributed_trace + def get_ekm_connection(self, **kwargs: Any) -> KeyVaultEkmConnection: ... + + @distributed_trace + def send_request( + self, + request: HttpRequest, + *, + stream: bool = False, + **kwargs: Any + ) -> HttpResponse: ... + + @distributed_trace + def update_ekm_connection( + self, + connection: KeyVaultEkmConnection, + **kwargs: Any + ) -> KeyVaultEkmConnection: ... + + + class azure.keyvault.administration.KeyVaultEkmConnection: + host: str + path_prefix: Union[str, None] + server_ca_certificates: list[bytes] + server_subject_common_name: Union[str, None] + + def __init__( + self, + host: str, + server_ca_certificates: List[bytes], + *, + path_prefix: Optional[str] = ..., + server_subject_common_name: Optional[str] = ... + ) -> None: ... + + def __repr__(self) -> str: ... + + + class azure.keyvault.administration.KeyVaultEkmProxyClientCertificateInfo: + ca_certificates: Union[list[bytes], None] + subject_common_name: Union[str, None] + + def __init__(self, **kwargs: Any) -> None: ... + + def __repr__(self) -> str: ... + + + class azure.keyvault.administration.KeyVaultEkmProxyInfo: + api_version: Union[str, None] + ekm_product: Union[str, None] + ekm_vendor: Union[str, None] + proxy_name: Union[str, None] + proxy_vendor: Union[str, None] + + def __init__(self, **kwargs: Any) -> None: ... + + def __repr__(self) -> str: ... + + + class azure.keyvault.administration.KeyVaultPermission: + actions: list[str] + data_actions: list[str] + not_actions: list[str] + not_data_actions: list[str] + + def __init__(self, **kwargs: Any) -> None: ... + + + class azure.keyvault.administration.KeyVaultRoleAssignment: + name: str + properties: KeyVaultRoleAssignmentProperties + role_assignment_id: str + type: str + + def __init__(self, **kwargs: Any) -> None: ... + + def __repr__(self) -> str: ... + + + class azure.keyvault.administration.KeyVaultRoleAssignmentProperties: + principal_id: str + role_definition_id: str + scope: str + + def __init__(self, **kwargs: Any) -> None: ... + + def __repr__(self) -> str: ... + + + class azure.keyvault.administration.KeyVaultRoleDefinition: + assignable_scopes: list[str] + description: str + id: str + name: str + permissions: list[KeyVaultPermission] + role_name: str + role_type: str + type: str + + def __init__(self, **kwargs: Any) -> None: ... + + def __repr__(self) -> str: ... + + + class azure.keyvault.administration.KeyVaultRoleScope(str, Enum, metaclass=CaseInsensitiveEnumMeta): + GLOBAL = "/" + KEYS = "/keys" + + + class azure.keyvault.administration.KeyVaultSetting: + name: str + setting_type: Union[str, KeyVaultSettingType, None] + value: str + + def __init__( + self, + name: str, + value: Union[str, bool], + setting_type: Optional[Union[str, KeyVaultSettingType]] = None, + **kwargs + ) -> None: ... + + def getboolean(self) -> bool: ... + + + class azure.keyvault.administration.KeyVaultSettingType(str, Enum, metaclass=CaseInsensitiveEnumMeta): + BOOLEAN = "boolean" + + + class azure.keyvault.administration.KeyVaultSettingsClient(KeyVaultClientBase): implements ContextManager + property vault_url: str # Read-only + + def __init__( + self, + vault_url: str, + credential: TokenCredential, + *, + api_version: Union[ApiVersion, str] = ..., + verify_challenge_resource: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + + def close(self) -> None: ... + + @distributed_trace + def get_setting( + self, + name: str, + **kwargs: Any + ) -> KeyVaultSetting: ... + + @distributed_trace + def list_settings(self, **kwargs: Any) -> ItemPaged[KeyVaultSetting]: ... + + @distributed_trace + def send_request( + self, + request: HttpRequest, + *, + stream: bool = False, + **kwargs: Any + ) -> HttpResponse: ... + + @distributed_trace + def update_setting( + self, + setting: KeyVaultSetting, + **kwargs: Any + ) -> KeyVaultSetting: ... + + +namespace azure.keyvault.administration.aio + + class azure.keyvault.administration.aio.KeyVaultAccessControlClient(AsyncKeyVaultClientBase): implements AsyncContextManager + property vault_url: str # Read-only + + def __init__( + self, + vault_url: str, + credential: AsyncTokenCredential, + *, + api_version: Union[ApiVersion, str] = ..., + verify_challenge_resource: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + + async def close(self) -> None: ... + + @distributed_trace_async + async def create_role_assignment( + self, + scope: Union[str, KeyVaultRoleScope], + definition_id: str, + principal_id: str, + *, + name: Optional[Union[str, UUID]] = ..., + **kwargs: Any + ) -> KeyVaultRoleAssignment: ... + + @distributed_trace_async + async def delete_role_assignment( + self, + scope: Union[str, KeyVaultRoleScope], + name: Union[str, UUID], + **kwargs: Any + ) -> None: ... + + @distributed_trace_async + async def delete_role_definition( + self, + scope: Union[str, KeyVaultRoleScope], + name: Union[str, UUID], + **kwargs: Any + ) -> None: ... + + @distributed_trace_async + async def get_role_assignment( + self, + scope: Union[str, KeyVaultRoleScope], + name: Union[str, UUID], + **kwargs: Any + ) -> KeyVaultRoleAssignment: ... + + @distributed_trace_async + async def get_role_definition( + self, + scope: Union[str, KeyVaultRoleScope], + name: Union[str, UUID], + **kwargs: Any + ) -> KeyVaultRoleDefinition: ... + + @distributed_trace + def list_role_assignments( + self, + scope: Union[str, KeyVaultRoleScope], + **kwargs: Any + ) -> AsyncItemPaged[KeyVaultRoleAssignment]: ... + + @distributed_trace + def list_role_definitions( + self, + scope: Union[str, KeyVaultRoleScope], + **kwargs: Any + ) -> AsyncItemPaged[KeyVaultRoleDefinition]: ... + + @distributed_trace_async + def send_request( + self, + request: HttpRequest, + *, + stream: bool = False, + **kwargs: Any + ) -> Awaitable[AsyncHttpResponse]: ... + + @distributed_trace_async + async def set_role_definition( + self, + scope: Union[str, KeyVaultRoleScope], + *, + assignable_scopes: Optional[List[Union[str, KeyVaultRoleScope]]] = ..., + description: Optional[str] = ..., + name: Optional[Union[str, UUID]] = ..., + permissions: Optional[List[KeyVaultPermission]] = ..., + role_name: Optional[str] = ..., + **kwargs: Any + ) -> KeyVaultRoleDefinition: ... + + + class azure.keyvault.administration.aio.KeyVaultBackupClient(AsyncKeyVaultClientBase): implements AsyncContextManager + property vault_url: str # Read-only + + def __init__( + self, + vault_url: str, + credential: AsyncTokenCredential, + *, + api_version: Union[ApiVersion, str] = ..., + verify_challenge_resource: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + + @overload + async def begin_backup( + self, + blob_storage_url: str, + *, + continuation_token: Optional[str] = ..., + use_managed_identity: Literal[True], + **kwargs: Any + ) -> AsyncLROPoller[KeyVaultBackupResult]: ... + + @overload + async def begin_backup( + self, + blob_storage_url: str, + *, + continuation_token: Optional[str] = ..., + sas_token: str, + **kwargs: Any + ) -> AsyncLROPoller[KeyVaultBackupResult]: ... + + @overload + async def begin_pre_backup( + self, + blob_storage_url: str, + *, + continuation_token: Optional[str] = ..., + use_managed_identity: Literal[True], + **kwargs: Any + ) -> AsyncLROPoller[None]: ... + + @overload + async def begin_pre_backup( + self, + blob_storage_url: str, + *, + continuation_token: Optional[str] = ..., + sas_token: str, + **kwargs: Any + ) -> AsyncLROPoller[None]: ... + + @overload + async def begin_pre_restore( + self, + folder_url: str, + *, + continuation_token: Optional[str] = ..., + use_managed_identity: Literal[True], + **kwargs: Any + ) -> AsyncLROPoller[None]: ... + + @overload + async def begin_pre_restore( + self, + folder_url: str, + *, + continuation_token: Optional[str] = ..., + sas_token: str, + **kwargs: Any + ) -> AsyncLROPoller[None]: ... + + @overload + async def begin_restore( + self, + folder_url: str, + *, + continuation_token: Optional[str] = ..., + key_name: Optional[str] = ..., + use_managed_identity: Literal[True], + **kwargs: Any + ) -> AsyncLROPoller[None]: ... + + @overload + async def begin_restore( + self, + folder_url: str, + *, + continuation_token: Optional[str] = ..., + key_name: Optional[str] = ..., + sas_token: str, + **kwargs: Any + ) -> AsyncLROPoller[None]: ... + + async def close(self) -> None: ... + + @distributed_trace_async + def send_request( + self, + request: HttpRequest, + *, + stream: bool = False, + **kwargs: Any + ) -> Awaitable[AsyncHttpResponse]: ... + + + class azure.keyvault.administration.aio.KeyVaultEkmClient(AsyncKeyVaultClientBase): implements AsyncContextManager + property vault_url: str # Read-only + + def __init__( + self, + vault_url: str, + credential: AsyncTokenCredential, + *, + api_version: Union[ApiVersion, str] = ..., + verify_challenge_resource: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + + @distributed_trace_async + async def check_ekm_connection(self, **kwargs: Any) -> KeyVaultEkmProxyInfo: ... + + async def close(self) -> None: ... + + @distributed_trace_async + async def create_ekm_connection( + self, + connection: KeyVaultEkmConnection, + **kwargs: Any + ) -> KeyVaultEkmConnection: ... + + @distributed_trace_async + async def delete_ekm_connection(self, **kwargs: Any) -> KeyVaultEkmConnection: ... + + @distributed_trace_async + async def get_ekm_certificate(self, **kwargs: Any) -> KeyVaultEkmProxyClientCertificateInfo: ... + + @distributed_trace_async + async def get_ekm_connection(self, **kwargs: Any) -> KeyVaultEkmConnection: ... + + @distributed_trace_async + def send_request( + self, + request: HttpRequest, + *, + stream: bool = False, + **kwargs: Any + ) -> Awaitable[AsyncHttpResponse]: ... + + @distributed_trace_async + async def update_ekm_connection( + self, + connection: KeyVaultEkmConnection, + **kwargs: Any + ) -> KeyVaultEkmConnection: ... + + + class azure.keyvault.administration.aio.KeyVaultSettingsClient(AsyncKeyVaultClientBase): implements AsyncContextManager + property vault_url: str # Read-only + + def __init__( + self, + vault_url: str, + credential: AsyncTokenCredential, + *, + api_version: Union[ApiVersion, str] = ..., + verify_challenge_resource: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + + async def close(self) -> None: ... + + @distributed_trace_async + async def get_setting( + self, + name: str, + **kwargs: Any + ) -> KeyVaultSetting: ... + + @distributed_trace + def list_settings(self, **kwargs: Any) -> AsyncItemPaged[KeyVaultSetting]: ... + + @distributed_trace_async + def send_request( + self, + request: HttpRequest, + *, + stream: bool = False, + **kwargs: Any + ) -> Awaitable[AsyncHttpResponse]: ... + + @distributed_trace_async + async def update_setting( + self, + setting: KeyVaultSetting, + **kwargs: Any + ) -> KeyVaultSetting: ... + + +``` \ No newline at end of file diff --git a/sdk/keyvault/azure-keyvault-administration/api.metadata.yml b/sdk/keyvault/azure-keyvault-administration/api.metadata.yml new file mode 100644 index 000000000000..51c8b30dc86e --- /dev/null +++ b/sdk/keyvault/azure-keyvault-administration/api.metadata.yml @@ -0,0 +1,3 @@ +apiMdSha256: 85dc8ab302f592b946a6721db3fdc071a569e0d74fa9aae664ceef960665a0ec +parserVersion: 0.3.28 +pythonVersion: 3.13.14 From 56cbda94a920bb4309e292af08fe02b82db28655 Mon Sep 17 00:00:00 2001 From: Hari K Date: Fri, 3 Jul 2026 16:26:34 +0100 Subject: [PATCH 03/10] added tests for replay bug --- .../CHANGELOG.md | 2 +- .../tests/test_challenge_auth.py | 190 ++++++++++++++++++ sdk/keyvault/azure-keyvault-keys/CHANGELOG.md | 2 +- .../tests/test_challenge_auth.py | 69 +++++++ .../tests/test_challenge_auth_async.py | 70 +++++++ 5 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py diff --git a/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md b/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md index 2c8523e3aaaf..350a2c92c326 100644 --- a/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md @@ -8,7 +8,7 @@ ### Bugs Fixed -- Fixed an replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. +- Fixed a replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. ### Other Changes diff --git a/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py new file mode 100644 index 000000000000..bf92a9ed03a6 --- /dev/null +++ b/sdk/keyvault/azure-keyvault-administration/tests/test_challenge_auth.py @@ -0,0 +1,190 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +""" +Tests for the HTTP challenge authentication implementation. +""" + +import functools +import time +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +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.async_challenge_auth_policy import AsyncChallengeAuthPolicy + +TOKEN_TYPES = [AccessToken, AccessTokenInfo] + + +def empty_challenge_cache(fn): + @functools.wraps(fn) + def wrapper(**kwargs): + HttpChallengeCache.clear() + assert len(HttpChallengeCache._cache) == 0 + return fn(**kwargs) + + return wrapper + + +def async_empty_challenge_cache(fn): + @functools.wraps(fn) + async def wrapper(**kwargs): + HttpChallengeCache.clear() + assert len(HttpChallengeCache._cache) == 0 + return await fn(**kwargs) + + return wrapper + + +def get_random_url(): + """The challenge cache is keyed on URLs. Random URLs defend against tests interfering with each other.""" + + return f"https://{uuid4()}.vault.azure.net/{uuid4()}".replace("-", "") + + +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +def test_request_body_not_reused_across_requests(token_type): + """A request's body must not leak into a later request made by the same client. + + Regression test for the replay bug: the original request used to be stashed on the policy instance and was + never cleared, so a subsequent bodiless request (e.g. a polling GET during a backup/restore operation) that + triggered its own challenge would have the earlier request's body (and method/URL) replayed onto it. The copy + is now stored per-request on the pipeline context, so it cannot leak across requests. See + https://github.com/Azure/azure-sdk-for-python/pull/47742. + """ + + expected_token = "expected_token" + first_content = b"a duck" + first_url = get_random_url() + second_url = get_random_url() + challenge = Mock( + status_code=401, + headers={ + "WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + }, + ) + + class Requests: + count = 0 + + def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request (POST with body): the body is stripped to elicit a challenge + assert not request.body + assert request.headers["Content-Length"] == "0" + return challenge + elif Requests.count == 2: + # first request is retried with its original body and authorization + assert request.body == first_content + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + elif Requests.count == 3: + # second request (bodiless GET): elicits its own challenge and must have no body + assert not request.body + return challenge + elif Requests.count == 4: + # second request is retried: it must remain a bodiless GET, i.e. the first request's body and + # method/URL must NOT be replayed onto it + assert not request.body + assert request.method == "GET" + assert request.url == second_url + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + def get_token(*_, **__): + return token_type(expected_token, time.time() + 3600) + + 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)) + + # a single policy instance handles both requests; the fix prevents state from one request leaking into the next + policy = ChallengeAuthPolicy(credential=credential) + pipeline = Pipeline(policies=[policy], transport=Mock(send=send)) + + first_request = HttpRequest("POST", first_url) + first_request.set_bytes_body(first_content) + pipeline.run(first_request) + + pipeline.run(HttpRequest("GET", second_url)) + + +@pytest.mark.asyncio +@async_empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +async def test_request_body_not_reused_across_requests_async(token_type): + """A request's body must not leak into a later request made by the same client (async). + + Regression test for the replay bug: the original request used to be stashed on the policy instance and was + never cleared, so a subsequent bodiless request (e.g. a polling GET during a backup/restore operation) that + triggered its own challenge would have the earlier request's body (and method/URL) replayed onto it. The copy + is now stored per-request on the pipeline context, so it cannot leak across requests. See + https://github.com/Azure/azure-sdk-for-python/pull/47742. + """ + + expected_token = "expected_token" + first_content = b"a duck" + first_url = get_random_url() + second_url = get_random_url() + challenge = Mock( + status_code=401, + headers={ + "WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + }, + ) + + class Requests: + count = 0 + + async def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request (POST with body): the body is stripped to elicit a challenge + assert not request.body + assert request.headers["Content-Length"] == "0" + return challenge + elif Requests.count == 2: + # first request is retried with its original body and authorization + assert request.body == first_content + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + elif Requests.count == 3: + # second request (bodiless GET): elicits its own challenge and must have no body + assert not request.body + return challenge + elif Requests.count == 4: + # second request is retried: it must remain a bodiless GET, i.e. the first request's body and + # method/URL must NOT be replayed onto it + assert not request.body + assert request.method == "GET" + assert request.url == second_url + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + async def get_token(*_, **__): + return token_type(expected_token, time.time() + 3600) + + 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)) + + # a single policy instance handles both requests; the fix prevents state from one request leaking into the next + policy = AsyncChallengeAuthPolicy(credential=credential) + pipeline = AsyncPipeline(policies=[policy], transport=Mock(send=send)) + + first_request = HttpRequest("POST", first_url) + first_request.set_bytes_body(first_content) + await pipeline.run(first_request) + + await pipeline.run(HttpRequest("GET", second_url)) diff --git a/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md b/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md index f346e17b9dad..07e24f4a8ac6 100644 --- a/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md @@ -12,7 +12,7 @@ ### Bugs Fixed -- Fixed an replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. +- Fixed a replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. ### Other Changes 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 f5a680f27674..92a6b94c0448 100644 --- a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py @@ -891,3 +891,72 @@ def get_token(*scopes, options=None, **kwargs): assert credential.get_token_info.call_count == 3 test_with_challenge(CAE_CHALLENGE_RESPONSE, CAE_DECODED_CLAIM) + + +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +def test_request_body_not_reused_across_requests(token_type): + """A request's body must not leak into a later request made by the same client. + + Regression test for the replay bug: the original request used to be stashed on the policy instance and was + never cleared, so a subsequent bodiless request (e.g. a polling GET) that triggered its own challenge would + have the earlier request's body (and method/URL) replayed onto it. The copy is now stored per-request on the + pipeline context, so it cannot leak across requests. See + https://github.com/Azure/azure-sdk-for-python/pull/47742. + """ + + expected_token = "expected_token" + first_content = b"a duck" + first_url = get_random_url() + second_url = get_random_url() + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request (POST with body): the body is stripped to elicit a challenge + assert not request.body + assert request.headers["Content-Length"] == "0" + return challenge + elif Requests.count == 2: + # first request is retried with its original body and authorization + assert request.body == first_content + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + elif Requests.count == 3: + # second request (bodiless GET): elicits its own challenge and must have no body + assert not request.body + return challenge + elif Requests.count == 4: + # second request is retried: it must remain a bodiless GET, i.e. the first request's body and + # method/URL must NOT be replayed onto it + assert not request.body + assert request.method == "GET" + assert request.url == second_url + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + def get_token(*_, **__): + return token_type(expected_token, time.time() + 3600) + + 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)) + + # a single policy instance handles both requests; the fix prevents state from one request leaking into the next + policy = ChallengeAuthPolicy(credential=credential) + pipeline = Pipeline(policies=[policy], transport=Mock(send=send)) + + first_request = HttpRequest("POST", first_url) + first_request.set_bytes_body(first_content) + pipeline.run(first_request) + + pipeline.run(HttpRequest("GET", second_url)) 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 d70f2f9fc7c4..ffcd846742f1 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 @@ -818,3 +818,73 @@ async def get_token(*scopes, options=None, **kwargs): assert credential.get_token_info.call_count == 3 await test_with_challenge(CAE_CHALLENGE_RESPONSE, CAE_DECODED_CLAIM) + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +async def test_request_body_not_reused_across_requests(token_type): + """A request's body must not leak into a later request made by the same client. + + Regression test for the replay bug: the original request used to be stashed on the policy instance and was + never cleared, so a subsequent bodiless request (e.g. a polling GET) that triggered its own challenge would + have the earlier request's body (and method/URL) replayed onto it. The copy is now stored per-request on the + pipeline context, so it cannot leak across requests. See + https://github.com/Azure/azure-sdk-for-python/pull/47742. + """ + + expected_token = "expected_token" + first_content = b"a duck" + first_url = get_random_url() + second_url = get_random_url() + challenge = Mock( + status_code=401, + headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net'}, + ) + + class Requests: + count = 0 + + async def send(request): + Requests.count += 1 + if Requests.count == 1: + # first request (POST with body): the body is stripped to elicit a challenge + assert not request.body + assert request.headers["Content-Length"] == "0" + return challenge + elif Requests.count == 2: + # first request is retried with its original body and authorization + assert request.body == first_content + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + elif Requests.count == 3: + # second request (bodiless GET): elicits its own challenge and must have no body + assert not request.body + return challenge + elif Requests.count == 4: + # second request is retried: it must remain a bodiless GET, i.e. the first request's body and + # method/URL must NOT be replayed onto it + assert not request.body + assert request.method == "GET" + assert request.url == second_url + assert expected_token in request.headers["Authorization"] + return Mock(status_code=200) + raise ValueError("unexpected request") + + async def get_token(*_, **__): + return token_type(expected_token, time.time() + 3600) + + 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)) + + # a single policy instance handles both requests; the fix prevents state from one request leaking into the next + policy = AsyncChallengeAuthPolicy(credential=credential) + pipeline = AsyncPipeline(policies=[policy], transport=Mock(send=send)) + + first_request = HttpRequest("POST", first_url) + first_request.set_bytes_body(first_content) + await pipeline.run(first_request) + + await pipeline.run(HttpRequest("GET", second_url)) From 9fdfdb3dbf38c865457034d913bb1461e388196c Mon Sep 17 00:00:00 2001 From: Hari K Date: Tue, 7 Jul 2026 12:33:16 +0100 Subject: [PATCH 04/10] updated recordings --- sdk/keyvault/azure-keyvault-administration/assets.json | 2 +- sdk/keyvault/azure-keyvault-keys/assets.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/keyvault/azure-keyvault-administration/assets.json b/sdk/keyvault/azure-keyvault-administration/assets.json index 041ab8595e83..ee1a54ad179a 100644 --- a/sdk/keyvault/azure-keyvault-administration/assets.json +++ b/sdk/keyvault/azure-keyvault-administration/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/keyvault/azure-keyvault-administration", - "Tag": "python/keyvault/azure-keyvault-administration_28d960bd8f" + "Tag": "python/keyvault/azure-keyvault-administration_5f2bc16381" } diff --git a/sdk/keyvault/azure-keyvault-keys/assets.json b/sdk/keyvault/azure-keyvault-keys/assets.json index 259c9a4d6326..196d25f08d50 100644 --- a/sdk/keyvault/azure-keyvault-keys/assets.json +++ b/sdk/keyvault/azure-keyvault-keys/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/keyvault/azure-keyvault-keys", - "Tag": "python/keyvault/azure-keyvault-keys_0b4df6bb52" + "Tag": "python/keyvault/azure-keyvault-keys_576f27c0d0" } From 5e03ef283eca9833552a58f5603a962bdc77d832 Mon Sep 17 00:00:00 2001 From: Hari K Date: Tue, 7 Jul 2026 14:30:55 +0100 Subject: [PATCH 05/10] updated sanitizer --- sdk/keyvault/azure-keyvault-administration/tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/keyvault/azure-keyvault-administration/tests/conftest.py b/sdk/keyvault/azure-keyvault-administration/tests/conftest.py index a5348033e2c7..e25ad32f7684 100644 --- a/sdk/keyvault/azure-keyvault-administration/tests/conftest.py +++ b/sdk/keyvault/azure-keyvault-administration/tests/conftest.py @@ -14,6 +14,7 @@ add_uri_regex_sanitizer, is_live, remove_batch_sanitizers, + set_custom_default_matcher, ) os.environ["PYTHONHASHSEED"] = "0" @@ -21,6 +22,7 @@ @pytest.fixture(scope="session", autouse=True) def add_sanitizers(test_proxy): + set_custom_default_matcher(ignored_headers="Accept") azure_keyvault_url = os.getenv("AZURE_KEYVAULT_URL", "https://Sanitized.vault.azure.net") azure_keyvault_url = azure_keyvault_url.rstrip("/") keyvault_tenant_id = os.getenv("KEYVAULT_TENANT_ID", "keyvault_tenant_id") From ef3944d8104f5f40dae46064f1883e40575a91c5 Mon Sep 17 00:00:00 2001 From: Hari K Date: Tue, 7 Jul 2026 15:42:07 +0100 Subject: [PATCH 06/10] update sanitizers --- sdk/keyvault/azure-keyvault-administration/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/keyvault/azure-keyvault-administration/tests/conftest.py b/sdk/keyvault/azure-keyvault-administration/tests/conftest.py index e25ad32f7684..2e5906a3392c 100644 --- a/sdk/keyvault/azure-keyvault-administration/tests/conftest.py +++ b/sdk/keyvault/azure-keyvault-administration/tests/conftest.py @@ -22,7 +22,7 @@ @pytest.fixture(scope="session", autouse=True) def add_sanitizers(test_proxy): - set_custom_default_matcher(ignored_headers="Accept") + set_custom_default_matcher(compare_bodies=False, ignore_query_ordering=True, ignored_headers="Accept") azure_keyvault_url = os.getenv("AZURE_KEYVAULT_URL", "https://Sanitized.vault.azure.net") azure_keyvault_url = azure_keyvault_url.rstrip("/") keyvault_tenant_id = os.getenv("KEYVAULT_TENANT_ID", "keyvault_tenant_id") From 16a1d94f15e48b452efc5a15e50724bd1daef676 Mon Sep 17 00:00:00 2001 From: Hari K Date: Tue, 7 Jul 2026 17:10:11 +0100 Subject: [PATCH 07/10] update restore tests --- sdk/keyvault/azure-keyvault-administration/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/keyvault/azure-keyvault-administration/assets.json b/sdk/keyvault/azure-keyvault-administration/assets.json index ee1a54ad179a..994168d7deb5 100644 --- a/sdk/keyvault/azure-keyvault-administration/assets.json +++ b/sdk/keyvault/azure-keyvault-administration/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/keyvault/azure-keyvault-administration", - "Tag": "python/keyvault/azure-keyvault-administration_5f2bc16381" + "Tag": "python/keyvault/azure-keyvault-administration_4f95f3a7da" } From 979ea13231467bfa3f163088fec80fd2834f81d3 Mon Sep 17 00:00:00 2001 From: Hari K Date: Tue, 7 Jul 2026 17:41:11 +0100 Subject: [PATCH 08/10] update test override --- .../azure-keyvault-administration/tests/test_backup_client.py | 1 - .../tests/test_backup_client_async.py | 1 - 2 files changed, 2 deletions(-) diff --git a/sdk/keyvault/azure-keyvault-administration/tests/test_backup_client.py b/sdk/keyvault/azure-keyvault-administration/tests/test_backup_client.py index 6f8f20155dc6..da574e2348c5 100644 --- a/sdk/keyvault/azure-keyvault-administration/tests/test_backup_client.py +++ b/sdk/keyvault/azure-keyvault-administration/tests/test_backup_client.py @@ -93,7 +93,6 @@ def test_full_backup_and_restore_rehydration(self, client, **kwargs): @KeyVaultBackupClientPreparer() @recorded_by_proxy def test_selective_key_restore(self, client, **kwargs): - set_bodiless_matcher() # create a key to selectively restore managed_hsm_url = kwargs.pop("managed_hsm_url") key_client = self.create_key_client(managed_hsm_url) diff --git a/sdk/keyvault/azure-keyvault-administration/tests/test_backup_client_async.py b/sdk/keyvault/azure-keyvault-administration/tests/test_backup_client_async.py index c35dd17a3863..33f7f9ee5303 100644 --- a/sdk/keyvault/azure-keyvault-administration/tests/test_backup_client_async.py +++ b/sdk/keyvault/azure-keyvault-administration/tests/test_backup_client_async.py @@ -94,7 +94,6 @@ async def test_full_backup_and_restore_rehydration(self, client, **kwargs): @KeyVaultBackupClientPreparer() @recorded_by_proxy_async async def test_selective_key_restore(self, client, **kwargs): - set_bodiless_matcher() # create a key to selectively restore managed_hsm_url = kwargs.pop("managed_hsm_url") key_client = self.create_key_client(managed_hsm_url, is_async=True) From c7bb4427ae7cef43e834d2e6d06aad1f2e3dcded Mon Sep 17 00:00:00 2001 From: Hari K Date: Tue, 7 Jul 2026 18:22:27 +0100 Subject: [PATCH 09/10] updated changelogs --- sdk/keyvault/azure-keyvault-administration/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md b/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md index 350a2c92c326..2a62988349ab 100644 --- a/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-administration/CHANGELOG.md @@ -8,7 +8,7 @@ ### Bugs Fixed -- Fixed a replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. +- Fixed a replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. [#47742](https://github.com/Azure/azure-sdk-for-python/pull/47742) ### Other Changes From 547c59cbadea9b6b1f34a6656392e5ff83c59085 Mon Sep 17 00:00:00 2001 From: Hari K Date: Tue, 7 Jul 2026 18:23:20 +0100 Subject: [PATCH 10/10] updated changelogs --- sdk/keyvault/azure-keyvault-keys/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md b/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md index 07e24f4a8ac6..5f90753c9f89 100644 --- a/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-keys/CHANGELOG.md @@ -12,7 +12,7 @@ ### Bugs Fixed -- Fixed a replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level. +- Fixed a replay bug in challenge authentication policy. The original request is now stored at the request level instead of the client level [#47742](https://github.com/Azure/azure-sdk-for-python/pull/47742). ### Other Changes