diff --git a/sdk/keyvault/azure-keyvault-secrets/CHANGELOG.md b/sdk/keyvault/azure-keyvault-secrets/CHANGELOG.md index bff700b96c99..61d2e190bbff 100644 --- a/sdk/keyvault/azure-keyvault-secrets/CHANGELOG.md +++ b/sdk/keyvault/azure-keyvault-secrets/CHANGELOG.md @@ -1,5 +1,14 @@ # Release History +## 4.11.3 (2026-10-02) + +### Bugs Fixed + +- Reject request URLs containing backslashes in the authority before authentication. +- Verify cached authentication challenges before token use and clear rejected or malformed challenges without removing newer entries installed by concurrent requests. +- Preserve redirect header updates and cleanup when restoring request bodies during authentication. +- Remove stale request authorization and clear the observed cached challenge when a 401 response omits `WWW-Authenticate`, without retrying authentication. + ## 4.11.2 (2026-08-25) ### Bugs Fixed diff --git a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/async_challenge_auth_policy.py b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/async_challenge_auth_policy.py index 95620bd9fc2d..82593e2545b4 100644 --- a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/async_challenge_auth_policy.py +++ b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/async_challenge_auth_policy.py @@ -18,7 +18,6 @@ import sys import time from typing import Any, Callable, cast, Optional, overload, TypeVar, Union -from urllib.parse import urlparse from typing_extensions import ParamSpec @@ -30,7 +29,20 @@ from .http_challenge import HttpChallenge from . import http_challenge_cache as ChallengeCache -from .challenge_auth_policy import _enforce_tls, _has_claims, _update_challenge, _REQUEST_COPY_KEY +from .challenge_auth_policy import ( + _enforce_tls, + _has_claims, + _update_challenge, + _validate_challenge_resource, + _request_origin, + _get_challenge_info, + _get_challenge_candidate, + _remove_challenge_for_request, + _restore_request, + _CHALLENGE_INFO_KEY, + _CHALLENGE_CACHE_KEY, + _REQUEST_COPY_KEY, +) if sys.version_info < (3, 9): from typing import Awaitable @@ -66,7 +78,6 @@ async def await_result(func: Callable[P, Union[T, Awaitable[T]]], *args: P.args, return result - class AsyncChallengeAuthPolicy(AsyncBearerTokenCredentialPolicy): """Policy for handling HTTP authentication challenges. @@ -82,9 +93,7 @@ def __init__(self, credential: AsyncTokenProvider, *scopes: str, **kwargs: Any) self._token: Optional[Union["AccessToken", "AccessTokenInfo"]] = None self._verify_challenge_resource = kwargs.pop("verify_challenge_resource", True) - async def send( - self, request: PipelineRequest[HttpRequest] - ) -> PipelineResponse[HttpRequest, AsyncHttpResponse]: + async def send(self, request: PipelineRequest[HttpRequest]) -> PipelineResponse[HttpRequest, AsyncHttpResponse]: """Authorize request with a bearer token and send it to the next policy. We implement this method to account for the valid scenario where a Key Vault authentication challenge is @@ -133,6 +142,7 @@ async def handle_challenge_flow( # If the previous challenge was a KV challenge and this one is too, return the 401 claims_challenge = _has_claims(response.http_response.headers["WWW-Authenticate"]) if consecutive_challenge and not claims_challenge: + _remove_challenge_for_request(request) return response request_authorized = await self.on_challenge(request, response) @@ -153,19 +163,37 @@ async def handle_challenge_flow( if not claims_challenge: return await self.handle_challenge_flow(request, response, consecutive_challenge=True) await await_result(self.on_response, request, response) + if response.http_response.status_code == 401 and "WWW-Authenticate" not in response.http_response.headers: + self._token = None + _remove_challenge_for_request(request) return response - async def on_request(self, request: PipelineRequest) -> None: _enforce_tls(request) + request.context.pop(_CHALLENGE_INFO_KEY, None) challenge = ChallengeCache.get_challenge_for_url(request.http_request.url) + request.context[_CHALLENGE_CACHE_KEY] = (_request_origin(request.http_request.url), challenge) if challenge: + try: + # A shared cache entry may have been stored by a client that disabled resource verification. + scope = challenge.get_scope() or challenge.get_resource() + "/.default" + if self._verify_challenge_resource: + _validate_challenge_resource(scope, request.http_request.url) + except ValueError: + self._token = None + request.http_request.headers.pop("Authorization", None) + ChallengeCache.remove_challenge_for_url_if_matches(request.http_request.url, challenge) + raise + request.context[_CHALLENGE_INFO_KEY] = ( + _request_origin(request.http_request.url), + scope, + challenge.tenant_id, + ) # Note that if the vault has moved to a new tenant since our last request for it, this request will fail. if self._need_new_token(): - # azure-identity credentials require an AADv2 scope but the challenge may specify an AADv1 resource - scope = challenge.get_scope() or challenge.get_resource() + "/.default" await self._request_kv_token(scope, challenge) + _restore_request(request) bearer_token = cast(Union[AccessToken, AccessTokenInfo], self._token).token request.http_request.headers["Authorization"] = f"Bearer {bearer_token}" return @@ -175,8 +203,9 @@ async def on_request(self, request: PipelineRequest) -> None: # on_challenge will parse that challenge, use the original request including the body, authorize the # request, and tell super to send it again. # 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 made by the same client. A restored request must be stripped again if the cache is evicted on retry. + request.http_request.headers.pop("Authorization", None) + if request.http_request.content: request.context[_REQUEST_COPY_KEY] = request.http_request bodiless_request = HttpRequest( method=request.http_request.method, @@ -187,53 +216,56 @@ async def on_request(self, request: PipelineRequest) -> None: request.http_request = bodiless_request async def on_challenge(self, request: PipelineRequest, response: PipelineResponse) -> bool: + _enforce_tls(request) + previous_challenge = request.context.pop(_CHALLENGE_INFO_KEY, None) try: - # CAE challenges may not include a scope or tenant; cache from the previous challenge to use if necessary - old_scope: Optional[str] = None - old_tenant: Optional[str] = None - cached_challenge = ChallengeCache.get_challenge_for_url(request.http_request.url) - if cached_challenge: - old_scope = cached_challenge.get_scope() or cached_challenge.get_resource() + "/.default" - old_tenant = cached_challenge.tenant_id - challenge = _update_challenge(request, response) - # CAE challenges may not include a scope or tenant; use the previous challenge's values if necessary - if challenge.claims and old_scope: - challenge._parameters["scope"] = old_scope # pylint:disable=protected-access - challenge.tenant_id = old_tenant - # azure-identity credentials require an AADv2 scope but the challenge may specify an AADv1 resource - scope = challenge.get_scope() or challenge.get_resource() + "/.default" except ValueError: + self._token = None + request.http_request.headers.pop("Authorization", None) + _remove_challenge_for_request(request) return False - if self._verify_challenge_resource: - resource_domain = urlparse(scope).netloc - if not resource_domain: - raise ValueError(f"The challenge contains invalid scope '{scope}'.") - - request_domain = urlparse(request.http_request.url).netloc - if not request_domain.lower().endswith(f".{resource_domain.lower()}"): - raise ValueError( - f"The challenge resource '{resource_domain}' does not match the requested domain. Pass " - "`verify_challenge_resource=False` to your client's constructor to disable this verification. " - "See https://aka.ms/azsdk/blog/vault-uri for more information." + if challenge.claims: + # Another request may have evicted or replaced the cache while this request was in flight. + try: + old_scope, old_tenant = _get_challenge_info( + request.http_request.url, + previous_challenge, + self._verify_challenge_resource, + _get_challenge_candidate(request), ) + except ValueError: + self._token = None + request.http_request.headers.pop("Authorization", None) + raise + if old_scope: + challenge._parameters["scope"] = old_scope # pylint:disable=protected-access + challenge.tenant_id = old_tenant + # azure-identity credentials require an AADv2 scope but the challenge may specify an AADv1 resource + try: + scope = challenge.get_scope() or challenge.get_resource() + "/.default" + if self._verify_challenge_resource: + _validate_challenge_resource(scope, request.http_request.url) + except ValueError: + self._token = None + request.http_request.headers.pop("Authorization", None) + _remove_challenge_for_request(request) + raise ChallengeCache.set_challenge_for_url(request.http_request.url, challenge) + request.context[_CHALLENGE_CACHE_KEY] = (_request_origin(request.http_request.url), challenge) + request.context[_CHALLENGE_INFO_KEY] = (_request_origin(request.http_request.url), scope, challenge.tenant_id) # 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 + _restore_request(request) # 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 if challenge.tenant_id and challenge.tenant_id.lower().endswith("adfs"): await self.authorize_request(request, scope, claims=challenge.claims) else: - await self.authorize_request( - request, scope, claims=challenge.claims, tenant_id=challenge.tenant_id - ) + await self.authorize_request(request, scope, claims=challenge.claims, tenant_id=challenge.tenant_id) return True diff --git a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/challenge_auth_policy.py b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/challenge_auth_policy.py index 0f5f99993c56..b0879800c14d 100644 --- a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/challenge_auth_policy.py +++ b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/challenge_auth_policy.py @@ -16,7 +16,7 @@ from copy import deepcopy import time -from typing import Any, cast, Optional, Union +from typing import Any, cast, Optional, Tuple, Union from urllib.parse import urlparse from azure.core.credentials import ( @@ -40,9 +40,17 @@ # 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" +_CHALLENGE_INFO_KEY = "key_vault_challenge_info" +_ChallengeInfo = Tuple[str, str, Optional[str]] +# Eviction identity is separate from the immutable accepted scope and tenant. +_CHALLENGE_CACHE_KEY = "key_vault_challenge_cache" +_ChallengeCacheEntry = Tuple[str, Optional[HttpChallenge]] def _enforce_tls(request: PipelineRequest) -> None: + # URL parsers used by transports can interpret backslashes as authority delimiters. + if "\\" in urlparse(request.http_request.url).netloc: + raise ValueError("Key Vault request URL must not contain backslashes in its authority.") if not request.http_request.url.lower().startswith("https"): raise ServiceRequestError( "Bearer token authentication is not permitted for non-TLS protected (non-https) URLs." @@ -59,8 +67,8 @@ def _has_claims(challenge: str) -> bool: :rtype: bool """ # Split the challenge into its scheme and parameters, then check if any parameter contains claims - split_challenge = challenge.strip().split(" ", 1) - return any("claims=" in item for item in split_challenge[1].split(",")) + parameters = challenge.strip().partition(" ")[2] + return any("claims=" in item for item in parameters.split(",")) def _update_challenge(request: PipelineRequest, challenger: PipelineResponse) -> HttpChallenge: @@ -75,14 +83,95 @@ def _update_challenge(request: PipelineRequest, challenger: PipelineResponse) -> :rtype: HttpChallenge """ + challenge_header = challenger.http_response.headers.get("WWW-Authenticate") + # HttpChallenge expects a scheme followed by a space and parameters. + if not challenge_header or " " not in challenge_header.strip(): + raise ValueError("Invalid challenge header") challenge = HttpChallenge( request.http_request.url, - challenger.http_response.headers.get("WWW-Authenticate"), + challenge_header, response_headers=challenger.http_response.headers, ) return challenge +def _request_origin(url: str) -> str: + # Use the same authority equivalence as the cache, but don't share authentication across schemes. + authority = ChallengeCache._get_cache_key(url).lower() # pylint:disable=protected-access + return urlparse(url).scheme.lower() + "://" + authority + + +def _get_challenge_info( + request_url: str, + previous: Optional[_ChallengeInfo], + verify_resource: bool, + expected_challenge: Optional[HttpChallenge], +) -> Tuple[Optional[str], Optional[str]]: + try: + if previous and previous[0] == _request_origin(request_url): + scope, tenant = previous[1:] + else: + cached = ChallengeCache.get_challenge_for_url(request_url) + if not cached or _request_origin(cached.source_uri) != _request_origin(request_url): + return None, None + expected_challenge = cached + scope = cached.get_scope() or cached.get_resource() + "/.default" + tenant = cached.tenant_id + if verify_resource: + _validate_challenge_resource(scope, request_url) + except ValueError: + ChallengeCache.remove_challenge_for_url_if_matches(request_url, expected_challenge) + raise + return scope, tenant + + +def _get_challenge_candidate(request: PipelineRequest) -> Optional[HttpChallenge]: + entry: Optional[_ChallengeCacheEntry] = request.context.get(_CHALLENGE_CACHE_KEY) + if entry and entry[0] == _request_origin(request.http_request.url): + return entry[1] + return None + + +def _remove_challenge_for_request(request: PipelineRequest) -> None: + ChallengeCache.remove_challenge_for_url_if_matches(request.http_request.url, _get_challenge_candidate(request)) + + +def _restore_request(request: PipelineRequest) -> None: + request_copy = request.context.pop(_REQUEST_COPY_KEY, None) + if request_copy and request_copy.method == request.http_request.method: + # Preserve header changes made during discovery, including redirect cleanup. + content_length = request_copy.headers.get("Content-Length") + headers = deepcopy(request.http_request.headers) + request_copy.headers.clear() + request_copy.headers.update(headers) + if content_length is None: + request_copy.headers.pop("Content-Length", None) + else: + request_copy.headers["Content-Length"] = content_length + request_copy.url = request.http_request.url + request.http_request = request_copy + + +def _validate_challenge_resource(scope: str, request_url: str) -> None: + resource_domain = urlparse(scope).netloc + if not resource_domain: + raise ValueError(f"The challenge contains invalid scope '{scope}'.") + + request_domain = urlparse(request_url).netloc + if request_domain.lower().endswith(f".{resource_domain.lower()}"): + return + + # Preserve existing authority matches and HTTPS request default-port equivalence for all resource schemes. + request_domain = ChallengeCache._get_cache_key(request_url) # pylint:disable=protected-access + resource_authority = ChallengeCache._get_cache_key("https://" + resource_domain) # pylint:disable=protected-access + if not request_domain.lower().endswith(f".{resource_authority.lower()}"): + raise ValueError( + f"The challenge resource '{resource_domain}' does not match the requested domain. Pass " + "`verify_challenge_resource=False` to your client's constructor to disable this verification. " + "See https://aka.ms/azsdk/blog/vault-uri for more information." + ) + + class ChallengeAuthPolicy(BearerTokenCredentialPolicy): """Policy for handling HTTP authentication challenges. @@ -148,6 +237,7 @@ def handle_challenge_flow( # If the previous challenge was a KV challenge and this one is too, return the 401 claims_challenge = _has_claims(response.http_response.headers["WWW-Authenticate"]) if consecutive_challenge and not claims_challenge: + _remove_challenge_for_request(request) return response request_authorized = self.on_challenge(request, response) @@ -168,18 +258,37 @@ def handle_challenge_flow( if not claims_challenge: return self.handle_challenge_flow(request, response, consecutive_challenge=True) self.on_response(request, response) + if response.http_response.status_code == 401 and "WWW-Authenticate" not in response.http_response.headers: + self._token = None + _remove_challenge_for_request(request) return response def on_request(self, request: PipelineRequest) -> None: _enforce_tls(request) + request.context.pop(_CHALLENGE_INFO_KEY, None) challenge = ChallengeCache.get_challenge_for_url(request.http_request.url) + request.context[_CHALLENGE_CACHE_KEY] = (_request_origin(request.http_request.url), challenge) if challenge: + try: + # A shared cache entry may have been stored by a client that disabled resource verification. + scope = challenge.get_scope() or challenge.get_resource() + "/.default" + if self._verify_challenge_resource: + _validate_challenge_resource(scope, request.http_request.url) + except ValueError: + self._token = None + request.http_request.headers.pop("Authorization", None) + ChallengeCache.remove_challenge_for_url_if_matches(request.http_request.url, challenge) + raise + request.context[_CHALLENGE_INFO_KEY] = ( + _request_origin(request.http_request.url), + scope, + challenge.tenant_id, + ) # Note that if the vault has moved to a new tenant since our last request for it, this request will fail. if self._need_new_token: - # azure-identity credentials require an AADv2 scope but the challenge may specify an AADv1 resource - scope = challenge.get_scope() or challenge.get_resource() + "/.default" self._request_kv_token(scope, challenge) + _restore_request(request) bearer_token = cast(Union["AccessToken", "AccessTokenInfo"], self._token).token request.http_request.headers["Authorization"] = f"Bearer {bearer_token}" return @@ -189,8 +298,9 @@ def on_request(self, request: PipelineRequest) -> None: # on_challenge will parse that challenge, use the original request including the body, authorize the # request, and tell super to send it again. # 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 made by the same client. A restored request must be stripped again if the cache is evicted on retry. + request.http_request.headers.pop("Authorization", None) + if request.http_request.content: request.context[_REQUEST_COPY_KEY] = request.http_request bodiless_request = HttpRequest( method=request.http_request.method, @@ -201,44 +311,49 @@ def on_request(self, request: PipelineRequest) -> None: request.http_request = bodiless_request def on_challenge(self, request: PipelineRequest, response: PipelineResponse) -> bool: + _enforce_tls(request) + previous_challenge = request.context.pop(_CHALLENGE_INFO_KEY, None) try: - # CAE challenges may not include a scope or tenant; cache from the previous challenge to use if necessary - old_scope: Optional[str] = None - old_tenant: Optional[str] = None - cached_challenge = ChallengeCache.get_challenge_for_url(request.http_request.url) - if cached_challenge: - old_scope = cached_challenge.get_scope() or cached_challenge.get_resource() + "/.default" - old_tenant = cached_challenge.tenant_id - challenge = _update_challenge(request, response) - # CAE challenges may not include a scope or tenant; use the previous challenge's values if necessary - if challenge.claims and old_scope: - challenge._parameters["scope"] = old_scope # pylint:disable=protected-access - challenge.tenant_id = old_tenant - # azure-identity credentials require an AADv2 scope but the challenge may specify an AADv1 resource - scope = challenge.get_scope() or challenge.get_resource() + "/.default" except ValueError: + self._token = None + request.http_request.headers.pop("Authorization", None) + _remove_challenge_for_request(request) return False - if self._verify_challenge_resource: - resource_domain = urlparse(scope).netloc - if not resource_domain: - raise ValueError(f"The challenge contains invalid scope '{scope}'.") - - request_domain = urlparse(request.http_request.url).netloc - if not request_domain.lower().endswith(f".{resource_domain.lower()}"): - raise ValueError( - f"The challenge resource '{resource_domain}' does not match the requested domain. Pass " - "`verify_challenge_resource=False` to your client's constructor to disable this verification. " - "See https://aka.ms/azsdk/blog/vault-uri for more information." + if challenge.claims: + # Another request may have evicted or replaced the cache while this request was in flight. + try: + old_scope, old_tenant = _get_challenge_info( + request.http_request.url, + previous_challenge, + self._verify_challenge_resource, + _get_challenge_candidate(request), ) + except ValueError: + self._token = None + request.http_request.headers.pop("Authorization", None) + raise + if old_scope: + challenge._parameters["scope"] = old_scope # pylint:disable=protected-access + challenge.tenant_id = old_tenant + # azure-identity credentials require an AADv2 scope but the challenge may specify an AADv1 resource + try: + scope = challenge.get_scope() or challenge.get_resource() + "/.default" + if self._verify_challenge_resource: + _validate_challenge_resource(scope, request.http_request.url) + except ValueError: + self._token = None + request.http_request.headers.pop("Authorization", None) + _remove_challenge_for_request(request) + raise ChallengeCache.set_challenge_for_url(request.http_request.url, challenge) + request.context[_CHALLENGE_CACHE_KEY] = (_request_origin(request.http_request.url), challenge) + request.context[_CHALLENGE_INFO_KEY] = (_request_origin(request.http_request.url), scope, challenge.tenant_id) # 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 + _restore_request(request) # 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-secrets/azure/keyvault/secrets/_shared/http_challenge_cache.py b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/http_challenge_cache.py index 99f32091e24b..a01cf0806540 100644 --- a/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/http_challenge_cache.py +++ b/sdk/keyvault/azure-keyvault-secrets/azure/keyvault/secrets/_shared/http_challenge_cache.py @@ -51,17 +51,36 @@ def _get_cache_key(url: str) -> str: return parsed.netloc -def remove_challenge_for_url(url: str) -> None: +def remove_challenge_for_url(url: str, expected_challenge: Optional[HttpChallenge] = None) -> None: """Removes the cached challenge for the specified URL. :param str url: the URL for which to remove the cached challenge + :param expected_challenge: Remove only this challenge, if it is still cached. None removes any cached challenge. + :type expected_challenge: HttpChallenge or None """ if not url: raise ValueError("URL cannot be empty") - key = _get_cache_key(url) + key = _get_cache_key(url).lower() + with _lock: + if expected_challenge is None or _cache.get(key) is expected_challenge: + _cache.pop(key, None) + + +def remove_challenge_for_url_if_matches(url: str, expected_challenge: Optional[HttpChallenge]) -> None: + """Remove only the cached challenge observed by the caller. + + :param str url: The URL for which to remove the cached challenge. + :param expected_challenge: The observed challenge, or None when no challenge was observed. + :type expected_challenge: HttpChallenge or None + """ + if not url: + raise ValueError("URL cannot be empty") + + key = _get_cache_key(url).lower() with _lock: - del _cache[key.lower()] + if expected_challenge is not None and _cache.get(key) is expected_challenge: + _cache.pop(key, None) def set_challenge_for_url(url: str, challenge: "HttpChallenge") -> None: 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 a76982a59a23..ff3ca951e4d8 100644 --- a/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth.py @@ -8,17 +8,146 @@ """ import functools +from itertools import product import time -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock +from urllib.parse import urlparse from uuid import uuid4 import pytest from azure.core.credentials import AccessToken, AccessTokenInfo -from azure.core.pipeline import Pipeline +from azure.core.exceptions import ServiceRequestError +from azure.core.pipeline import AsyncPipeline, Pipeline, PipelineContext, PipelineRequest, PipelineResponse +from azure.core.pipeline.policies import ( + AsyncRedirectPolicy, + AsyncRetryPolicy, + RedirectPolicy, + RetryPolicy, + SensitiveHeaderCleanupPolicy, +) from azure.core.rest import HttpRequest -from azure.keyvault.secrets._shared import ChallengeAuthPolicy, HttpChallengeCache +from azure.keyvault.secrets._shared import ChallengeAuthPolicy, HttpChallenge, HttpChallengeCache +from azure.keyvault.secrets._shared.async_challenge_auth_policy import AsyncChallengeAuthPolicy, await_result TOKEN_TYPES = [AccessToken, AccessTokenInfo] +CHALLENGE = 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' +BACKSLASH_AUTHORITIES = [ + r"example.net\.vault.azure.net", + r"example.net\\.vault.azure.net", + r"example.net\@test.vault.azure.net", + r"example.net\\@test.vault.azure.net", +] +BACKSLASH_CACHE_CASES = [(authority, "empty") for authority in BACKSLASH_AUTHORITIES] + [ + (BACKSLASH_AUTHORITIES[1], "challenge"), + (BACKSLASH_AUTHORITIES[3], "token"), +] +VALID_CACHED_URLS = [ + "https://test.vault.azure.net:443/secrets/item", + "https://test.managedhsm.azure.net:8443/secrets/item", + "HTTPS://VAULT.CONTOSO.TEST:8443/secrets/item", + "https://localhost/secrets/item", + "https://[::1]/secrets/item", + "https://[::1]:443/secrets/item", + "https://[2001:db8::1]:8443/secrets/item", + r"https://test.vault.azure.net/secrets/a\b", + r"https://test.vault.azure.net/secrets/item?value=a\b", + r"https://test.vault.azure.net/secrets/item#fragment=a\b", +] +VALID_CHALLENGE_AUTHORITIES = [ + (f"test.{domain}", domain, "tenant") + for domain in [ + "vault.azure.net", + "vault.usgovcloudapi.net", + "vault.azure.cn", + "vault.microsoftazure.de", + "managedhsm.azure.net", + "managedhsm.usgovcloudapi.net", + "managedhsm.azure.cn", + "managedhsm.microsoftazure.de", + ] +] + [ + ("vault.contoso.test", "contoso.test", "tenant"), + ("vault.contoso.test", "contoso.test", "adfs"), + ("TEST.VAULT.AZURE.NET", "VAULT.AZURE.NET", "tenant"), + ("user:pass@test.vault.azure.net", "vault.azure.net", "tenant"), + ("t" + chr(0xE4) + "st.vault.azure.net", "vault.azure.net", "tenant"), + ("xn--tst-qla.vault.azure.net", "vault.azure.net", "tenant"), +] + + +@pytest.fixture +def isolated_challenge_cache(): + HttpChallengeCache.clear() + yield + HttpChallengeCache.clear() + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("isolated_challenge_cache") +@pytest.mark.parametrize("producer_async,consumer_async", product([False, True], repeat=2)) +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +@pytest.mark.parametrize("parameter", ["resource", "scope"]) +@pytest.mark.parametrize("producer_fails", [False, True]) +@pytest.mark.parametrize("verify", [None, True, False]) +@pytest.mark.parametrize("resource", ["https://other.test", "https://example.test"]) +async def test_cached_challenge_respects_consumer_verification( + producer_async, consumer_async, token_type, parameter, producer_fails, verify, resource +): + url = "https://cache.example.test/items" + scope = resource + "/.default" + value = resource if parameter == "resource" else scope + header = f'Bearer authorization="https://authority.test/tenant", {parameter}="{value}"' + method = "get_token" if token_type is AccessToken else "get_token_info" + producer_token = (AsyncMock if producer_async else Mock)( + return_value=token_type("producer-token", time.time() + 3600), + side_effect=ValueError("producer credential failed") if producer_fails else None, + ) + producer = (AsyncChallengeAuthPolicy if producer_async else ChallengeAuthPolicy)( + Mock(spec_set=[method], **{method: producer_token}), verify_challenge_resource=False + ) + producer_send = (AsyncMock if producer_async else Mock)( + side_effect=[ + Mock(status_code=401, headers={"WWW-Authenticate": header}), + Mock(status_code=200), + ] + ) + producer_pipeline = (AsyncPipeline if producer_async else Pipeline)( + policies=[producer], transport=Mock(send=producer_send) + ) + if producer_fails: + with pytest.raises(ValueError, match="producer credential failed"): + await await_result(producer_pipeline.run, HttpRequest("GET", url)) + else: + await await_result(producer_pipeline.run, HttpRequest("GET", url)) + assert producer_token.call_count == 1 + assert HttpChallengeCache.get_challenge_for_url(url) is not None + + consumer_token = (AsyncMock if consumer_async else Mock)( + return_value=token_type("consumer-token", time.time() + 3600) + ) + options = {} if verify is None else {"verify_challenge_resource": verify} + consumer = (AsyncChallengeAuthPolicy if consumer_async else ChallengeAuthPolicy)( + Mock(spec_set=[method], **{method: consumer_token}), **options + ) + consumer_send = (AsyncMock if consumer_async else Mock)(return_value=Mock(status_code=200)) + consumer_pipeline = (AsyncPipeline if consumer_async else Pipeline)( + policies=[consumer], transport=Mock(send=consumer_send) + ) + request = HttpRequest("GET", url) + if verify is False or resource == "https://example.test": + await await_result(consumer_pipeline.run, request) + assert consumer_token.call_args.args == (scope,) + assert request.headers["Authorization"] == "Bearer consumer-token" + assert consumer_send.call_count == 1 + assert HttpChallengeCache.get_challenge_for_url(url) is not None + else: + with pytest.raises(ValueError, match="does not match the requested domain"): + await await_result(consumer_pipeline.run, request) + consumer_token.assert_not_called() + consumer_send.assert_not_called() + assert "Authorization" not in request.headers + assert HttpChallengeCache.get_challenge_for_url(url) is None + assert consumer._token is None def empty_challenge_cache(fn): @@ -37,6 +166,153 @@ def get_random_url(): return f"https://{uuid4()}.vault.azure.net/{uuid4()}".replace("-", "") +@empty_challenge_cache +@pytest.mark.parametrize("authority,cache_state", BACKSLASH_CACHE_CASES) +@pytest.mark.parametrize("verify_challenge_resource", [True, False]) +def test_rejects_backslash_authority(authority, cache_state, verify_challenge_resource): + url = f"https://{authority}/secrets/canary" + credential = Mock(spec_set=["get_token"], get_token=Mock(side_effect=AssertionError("unexpected token request"))) + transport = Mock(send=Mock(side_effect=AssertionError("unexpected transport send"))) + policy = ChallengeAuthPolicy(credential, verify_challenge_resource=verify_challenge_resource) + pipeline = Pipeline(policies=[policy], transport=transport) + if cache_state != "empty": + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, CHALLENGE)) + if cache_state == "token": + policy._token = AccessToken("cached-token", time.time() + 3600) + cached = HttpChallengeCache._cache.copy() + + for _ in range(2): + request = HttpRequest("POST", url) + request.set_bytes_body(b"secret") + with pytest.raises(ValueError, match="backslash"): + pipeline.run(request) + assert "Authorization" not in request.headers + assert request.body == b"secret" + assert HttpChallengeCache._cache == cached + + credential.get_token.assert_not_called() + transport.send.assert_not_called() + + +@empty_challenge_cache +@pytest.mark.parametrize("authority", BACKSLASH_AUTHORITIES) +@pytest.mark.parametrize("verify_challenge_resource", [True, False]) +def test_rejects_backslash_authority_on_challenge(authority, verify_challenge_resource): + url = f"https://{authority}/secrets/canary" + credential = Mock(spec_set=["get_token"], get_token=Mock(side_effect=AssertionError("unexpected token request"))) + policy = ChallengeAuthPolicy(credential, verify_challenge_resource=verify_challenge_resource) + transport = Mock() + response = Mock(http_response=Mock(status_code=401, headers={"WWW-Authenticate": CHALLENGE})) + + for _ in range(2): + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(transport)) + with pytest.raises(ValueError, match="backslash"): + policy.on_challenge(request, response) + assert "Authorization" not in request.http_request.headers + assert not HttpChallengeCache._cache + + credential.get_token.assert_not_called() + transport.send.assert_not_called() + + +@empty_challenge_cache +@pytest.mark.parametrize("url", VALID_CACHED_URLS) +def test_request_url_validation_preserves_cached_urls(url): + # These URL-only fixtures deliberately use an unrelated challenge resource. + challenge = HttpChallenge(url, CHALLENGE) + HttpChallengeCache.set_challenge_for_url(url, challenge) + credential = Mock(spec_set=["get_token"]) + policy = ChallengeAuthPolicy(credential, verify_challenge_resource=False) + policy._token = AccessToken("cached-token", time.time() + 3600) + + for _ in range(2): + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + policy.on_request(request) + assert request.http_request.url == url + assert request.http_request.headers["Authorization"] == "Bearer cached-token" + assert HttpChallengeCache.get_challenge_for_url(url) is challenge + + credential.get_token.assert_not_called() + + +@empty_challenge_cache +@pytest.mark.parametrize("authority,resource,tenant", VALID_CHALLENGE_AUTHORITIES) +def test_request_url_validation_preserves_challenge_flow(authority, resource, tenant): + url = f"https://{authority}/secrets/item" + challenge = Mock( + status_code=401, + headers={ + "WWW-Authenticate": f'Bearer authorization="https://authority.net/{tenant}", resource=https://{resource}' + }, + ) + + def send(request): + assert request.url == url + if "Authorization" not in request.headers: + assert not request.body + assert request.headers["Content-Length"] == "0" + return challenge + assert request.headers["Authorization"] == "Bearer expected-token" + assert request.body == b"secret" + return Mock(status_code=200) + + credential = Mock( + spec_set=["get_token"], get_token=Mock(return_value=AccessToken("expected-token", time.time() + 3600)) + ) + transport = Mock(send=Mock(wraps=send)) + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential)], transport=transport) + for _ in range(2): + request = HttpRequest("POST", url) + request.set_bytes_body(b"secret") + assert pipeline.run(request).http_response.status_code == 200 + + assert transport.send.call_count == 3 + credential.get_token.assert_called_once() + assert credential.get_token.call_args.args == (f"https://{resource}/.default",) + if tenant == "adfs": + assert "tenant_id" not in credential.get_token.call_args.kwargs + else: + assert credential.get_token.call_args.kwargs["tenant_id"] == tenant + assert HttpChallengeCache.get_challenge_for_url(url).get_resource() == f"https://{resource}" + + +@empty_challenge_cache +@pytest.mark.parametrize("fresh_policy", [False, True]) +def test_rejected_challenge_is_not_cached(fresh_policy): + url = "https://example.net/secrets/canary" + 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 + assert "Authorization" not in request.headers + assert not request.body + assert request.headers["Content-Length"] == "0" + return challenge + + credential = Mock(spec_set=["get_token"], get_token=Mock(side_effect=AssertionError("unexpected token request"))) + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + + for _ in range(2): + if fresh_policy: + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + request = HttpRequest("POST", url) + request.set_bytes_body(b"secret") + with pytest.raises(ValueError): + pipeline.run(request) + + assert Requests.count == 2 + assert not HttpChallengeCache.get_challenge_for_url(url) + assert credential.get_token.call_count == 0 + + @empty_challenge_cache @pytest.mark.parametrize("token_type", TOKEN_TYPES) def test_request_body_not_reused_across_requests(token_type): @@ -55,7 +331,9 @@ def test_request_body_not_reused_across_requests(token_type): second_url = get_random_url() challenge = Mock( status_code=401, - headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net'}, + headers={ + "WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + }, ) class Requests: @@ -104,3 +382,763 @@ def get_token(*_, **__): pipeline.run(first_request) pipeline.run(HttpRequest("GET", second_url)) + + +# These domains include the public/China/US Government cloud tables in eng/common/TestResources/clouds, +# Germany, Managed HSM, and arbitrary Azure Stack/custom domains supported by the suffix validator. +CACHE_RESOURCE_DOMAINS = [ + "vault.azure.net", + "vault.azure.cn", + "vault.usgovcloudapi.net", + "vault.microsoftazure.de", + "managedhsm.azure.net", + "vault.stack.example", + "vault.custom.example", +] +INVALID_CHALLENGE_HEADERS = [ + None, + "", + " ", + "Bearer", + "Bearer resource=https://vault.azure.net", + 'Bearer authorization="https://authority.net/tenant", resource=https://other.example', + 'Bearer authorization="https://authority.net/tenant", scope=invalid', + 'Bearer authorization="https://authority.net/tenant", scope=https://[invalid', +] + + +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +@pytest.mark.parametrize("cached_token", [False, True]) +@pytest.mark.parametrize( + "request_port,resource", + [ + ("", "https://other.example"), + ("", "invalid"), + ("", "https://vault.azure.net:8443"), + (":8443", "https://vault.azure.net"), + (":8443", "https://vault.azure.net:443"), + (":443", "https://vault.azure.net:8443"), + ], +) +def test_cached_challenge_resource_is_verified(token_type, cached_token, request_port, resource): + url = get_random_url().replace(".vault.azure.net/", f".vault.azure.net{request_port}/") + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/tenant", resource={resource}') + ) + credential = Mock( + spec_set=["get_token", "get_token_info"], + get_token=Mock(side_effect=AssertionError("unexpected token request")), + get_token_info=Mock(side_effect=AssertionError("unexpected token request")), + ) + policy = ChallengeAuthPolicy(credential) + if cached_token: + policy._token = token_type("cached-token", time.time() + 3600) + transport = Mock(send=Mock(side_effect=AssertionError("unexpected transport call"))) + request = HttpRequest("POST", url, content=b"secret") + + with pytest.raises(ValueError): + Pipeline(policies=[policy], transport=transport).run(request) + + assert "Authorization" not in request.headers + credential.get_token.assert_not_called() + credential.get_token_info.assert_not_called() + transport.send.assert_not_called() + assert HttpChallengeCache.get_challenge_for_url(url) is None + + follow_up = PipelineRequest(HttpRequest("POST", url, content=b"next secret"), PipelineContext(None)) + policy.on_request(follow_up) + response = PipelineResponse( + follow_up.http_request, Mock(status_code=401, headers={"WWW-Authenticate": "Bearer"}), follow_up.context + ) + assert policy.on_challenge(follow_up, response) is False + assert "Authorization" not in follow_up.http_request.headers + assert not follow_up.http_request.body + assert follow_up.http_request.headers["Content-Length"] == "0" + credential.get_token.assert_not_called() + credential.get_token_info.assert_not_called() + + +@pytest.mark.parametrize("cached_token", [False, True]) +def test_opt_out_cache_is_not_trusted_by_strict_policy(cached_token): + url = get_random_url() + header = 'Bearer authorization="https://authority.net/tenant", resource=https://other.example' + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("opt-out", time.time() + 3600))) + transport = Mock( + send=Mock( + side_effect=[ + Mock(status_code=401, headers={"WWW-Authenticate": header}), + Mock(status_code=200), + Mock(status_code=200), + ] + ) + ) + pipeline = Pipeline( + policies=[ChallengeAuthPolicy(credential, verify_challenge_resource=False)], transport=transport + ) + pipeline.run(HttpRequest("GET", url)) + pipeline.run(HttpRequest("GET", url)) + assert transport.send.call_count == 3 + credential.get_token.assert_called_once() + assert HttpChallengeCache.get_challenge_for_url(url) + + strict_credential = Mock( + spec_set=["get_token"], get_token=Mock(side_effect=AssertionError("unexpected token request")) + ) + strict_policy = ChallengeAuthPolicy(strict_credential) + if cached_token: + strict_policy._token = AccessToken("strict-token", time.time() + 3600) + strict_transport = Mock(send=Mock(side_effect=AssertionError("unexpected transport call"))) + with pytest.raises(ValueError): + Pipeline(policies=[strict_policy], transport=strict_transport).run(HttpRequest("GET", url)) + strict_credential.get_token.assert_not_called() + strict_transport.send.assert_not_called() + assert HttpChallengeCache.get_challenge_for_url(url) is None + + +@pytest.mark.parametrize("header", INVALID_CHALLENGE_HEADERS) +def test_rejected_response_preserves_concurrent_challenge(header): + url = get_random_url() + credential = Mock(spec_set=["get_token"]) + policy = ChallengeAuthPolicy(credential) + request = PipelineRequest(HttpRequest("POST", url, content=b"secret"), PipelineContext(None)) + policy.on_request(request) + assert not request.http_request.body + replacement = HttpChallenge( + url, 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + ) + HttpChallengeCache.set_challenge_for_url(url, replacement) + response = PipelineResponse( + request.http_request, Mock(status_code=401, headers={"WWW-Authenticate": header}), request.context + ) + if header and "authorization=" in header: + with pytest.raises(ValueError): + policy.on_challenge(request, response) + else: + assert policy.on_challenge(request, response) is False + assert HttpChallengeCache.get_challenge_for_url(url) is replacement + assert "Authorization" not in request.http_request.headers + assert not request.http_request.body + credential.get_token.assert_not_called() + + def send(fresh_request): + assert "Authorization" not in fresh_request.headers + assert not fresh_request.body + assert fresh_request.headers["Content-Length"] == "0" + return Mock(status_code=401, headers={}) + + # Start a separate cold-discovery scenario, independent of the concurrent cache entry. + HttpChallengeCache.remove_challenge_for_url(url) + fresh = Pipeline(policies=[ChallengeAuthPolicy(credential)], transport=Mock(send=send)) + assert fresh.run(HttpRequest("POST", url, content=b"new secret")).http_response.status_code == 401 + credential.get_token.assert_not_called() + + +@pytest.mark.parametrize("header", ["", " ", "Bearer", "Bearer resource=https://vault.azure.net"]) +@pytest.mark.parametrize("consecutive", [False, True]) +def test_malformed_challenge_flow_clears_cache(header, consecutive): + url = get_random_url() + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net') + ) + credential = Mock(spec_set=["get_token"]) + policy = ChallengeAuthPolicy(credential) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + policy._token = AccessToken("token", time.time() + 3600) + policy.on_request(request) + response = PipelineResponse( + request.http_request, Mock(status_code=401, headers={"WWW-Authenticate": header}), request.context + ) + assert policy.handle_challenge_flow(request, response, consecutive_challenge=consecutive) is response + assert HttpChallengeCache.get_challenge_for_url(url) is None + credential.get_token.assert_not_called() + + +def test_cae_invalid_inherited_scope_clears_cache(): + url = get_random_url() + HttpChallengeCache.set_challenge_for_url( + url, + HttpChallenge(url, 'Bearer authorization="https://authority.net/tenant", scope=https://other.example/.default'), + ) + credential = Mock(spec_set=["get_token"]) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + response = PipelineResponse( + request.http_request, + Mock( + status_code=401, + headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/", claims="e30="'}, + ), + request.context, + ) + with pytest.raises(ValueError): + ChallengeAuthPolicy(credential).on_challenge(request, response) + assert HttpChallengeCache.get_challenge_for_url(url) is None + credential.get_token.assert_not_called() + + +@pytest.mark.parametrize("domain", CACHE_RESOURCE_DOMAINS) +@pytest.mark.parametrize("parameter", ["resource", "scope"]) +@pytest.mark.parametrize( + "request_port,resource_port", [("", ""), (":443", ""), ("", ":443"), (":443", ":443"), (":8443", ":8443")] +) +def test_valid_challenge_cache_reuse(domain, parameter, request_port, resource_port): + host = f"{uuid4().hex}.{domain}" + url = f"https://{host}{request_port}/first" + alias_port = "" if request_port == ":443" else ":443" if not request_port else request_port + alias = f"https://{host.upper()}{alias_port}/next" + resource = f"https://{domain.upper()}{resource_port}" + scope = resource + "/.default" + header = f'Bearer authorization="https://authority.example/ADFS", {parameter}={scope if parameter == "scope" else resource}' + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("token", time.time() + 3600))) + requests = [] + + def send(request): + requests.append(request) + if len(requests) == 1: + assert "Authorization" not in request.headers + assert not request.body + return Mock(status_code=401, headers={"WWW-Authenticate": header}) + assert request.headers["Authorization"] == "Bearer token" + assert request.body == b"body" + return Mock(status_code=200) + + pipeline = Pipeline(policies=[ChallengeAuthPolicy(credential)], transport=Mock(send=send)) + pipeline.run(HttpRequest("POST", url, content=b"body")) + pipeline.run(HttpRequest("POST", alias, content=b"body")) + assert len(requests) == 3 + credential.get_token.assert_called_once_with(scope, claims=None, enable_cae=True) + # A new policy must also use the cached challenge directly, without another unauthenticated request. + Pipeline(policies=[ChallengeAuthPolicy(credential)], transport=Mock(send=send)).run( + HttpRequest("POST", alias, content=b"body") + ) + assert len(requests) == 4 + assert credential.get_token.call_count == 2 + assert credential.get_token.call_args.kwargs == {"enable_cae": True} + assert HttpChallengeCache.get_challenge_for_url(alias) is HttpChallengeCache.get_challenge_for_url(url) + + +def test_cached_cae_scope_and_tenant_refresh(): + url = get_random_url() + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("token", time.time() + 3600))) + policy = ChallengeAuthPolicy(credential) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + scope = "https://vault.azure.net/.default" + for header, tenant, claims in [ + (f'Bearer authorization="https://authority.net/first", scope={scope}', "first", None), + ('Bearer authorization="https://authority.net/", claims="e30="', "first", "{}"), + (f'Bearer authorization="https://authority.net/second", scope={scope}', "second", None), + ]: + response = PipelineResponse( + request.http_request, Mock(status_code=401, headers={"WWW-Authenticate": header}), request.context + ) + assert policy.on_challenge(request, response) + credential.get_token.assert_called_with(scope, claims=claims, tenant_id=tenant, enable_cae=True) + policy._token = None + policy.on_request(request) + credential.get_token.assert_called_with(scope, tenant_id="second", enable_cae=True) + + +@pytest.mark.parametrize( + "resource", + [ + "http://vault.azure.net", + "http://vault.azure.net:443", + "spn://vault.azure.net", + "//vault.azure.net", + "https://vault.azure.net/scope", + ], +) +def test_cached_resource_syntax_is_preserved(resource): + url = get_random_url() + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/tenant", resource={resource}') + ) + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("token", time.time() + 3600))) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + ChallengeAuthPolicy(credential).on_request(request) + credential.get_token.assert_called_once_with(resource + "/.default", tenant_id="tenant", enable_cae=True) + assert request.http_request.headers["Authorization"] == "Bearer token" + + +def test_cache_removal_is_idempotent(): + url = get_random_url() + alias = url.replace(".vault.azure.net", ".VAULT.AZURE.NET:443") + HttpChallengeCache.remove_challenge_for_url(url) + challenge = HttpChallenge( + url, 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + ) + HttpChallengeCache.set_challenge_for_url(url, challenge) + HttpChallengeCache.remove_challenge_for_url(alias) + HttpChallengeCache.remove_challenge_for_url(url) + HttpChallengeCache.remove_challenge_for_url(alias) + assert HttpChallengeCache.get_challenge_for_url(url) is None + + +@pytest.mark.parametrize("method,body", [("PUT", b"payload"), ("HEAD", None), ("HEAD", b"payload")]) +@pytest.mark.parametrize("warm_cache", [False, True]) +@pytest.mark.parametrize("refill_cache", [False, True]) +def test_retry_cache_eviction_preserves_request(method, body, warm_cache, refill_cache): + url = get_random_url() + header = 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + if warm_cache: + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, header)) + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("token", time.time() + 3600))) + policy = ChallengeAuthPolicy(credential) + other_credential = Mock( + spec_set=["get_token"], get_token=Mock(return_value=AccessToken("other", time.time() + 3600)) + ) + other = Pipeline( + policies=[ChallengeAuthPolicy(other_credential)], + transport=Mock(send=Mock(return_value=Mock(status_code=401, headers={"WWW-Authenticate": "Bearer invalid=1"}))), + ) + stages = ( + [(True, 503, "evict"), (False, 503, "refill"), (True, 200, None)] + if refill_cache + else [ + (True, 503, "evict"), + (False, 503, None), + (False, 401, None), + (True, 503, "evict"), + (False, 401, None), + (True, 200, None), + ] + ) + if not warm_cache: + stages.insert(0, (False, 401, None)) + + def send(request): + if not stages: + assert (request.method, request.url, request.body) == ("GET", url + "/next", None) + return Mock(status_code=200, headers={}) + authenticated, status, action = stages.pop(0) + assert (request.method, request.url) == (method, url) + if authenticated: + assert request.headers["Authorization"] == "Bearer token" + assert request.body == body + else: + assert "Authorization" not in request.headers + assert not request.body + if body: + assert request.headers["Content-Length"] == "0" + if action == "evict": + assert other.run(HttpRequest("GET", url + "/other")).http_response.status_code == 401 + assert HttpChallengeCache.get_challenge_for_url(url) is None + elif action == "refill": + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, header)) + return Mock(status_code=status, headers={"WWW-Authenticate": header} if status == 401 else {}) + + pipeline = Pipeline( + policies=[RetryPolicy(retry_total=5, retry_status=5, retry_backoff_factor=0), policy], + transport=Mock(send=send), + ) + assert pipeline.run(HttpRequest(method, url, content=body)).http_response.status_code == 200 + assert not stages + assert pipeline.run(HttpRequest("GET", url + "/next")).http_response.status_code == 200 + + +@pytest.mark.parametrize("initial", ["cached", "fresh", "refresh", "refresh-tenantless"]) +@pytest.mark.parametrize("cache_change", ["evict", "replace-valid", "replace-invalid"]) +@pytest.mark.parametrize( + "new_tenant,new_scope", + [ + (None, None), + ("new-tenant", None), + (None, "https://vault.azure.net/new-scope"), + ("new-tenant", "https://vault.azure.net/new-scope"), + ], +) +def test_cae_request_snapshot_survives_cache_changes(initial, cache_change, new_tenant, new_scope): + url = get_random_url() + old_scope = "https://vault.azure.net/.default" + if initial != "fresh": + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/old-tenant", scope={old_scope}') + ) + active_scope = "https://vault.azure.net/refreshed-scope" if initial.startswith("refresh") else old_scope + active_tenant = ( + None if initial == "refresh-tenantless" else "refreshed-tenant" if initial == "refresh" else "old-tenant" + ) + full_header = f'Bearer authorization="https://authority.net/{active_tenant or ""}", scope={active_scope}' + claims_header = f'Bearer authorization="https://authority.net/{new_tenant or ""}", claims="e30="' + if new_scope: + claims_header += f", scope={new_scope}" + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("token", time.time() + 3600))) + other_credential = Mock( + spec_set=["get_token"], get_token=Mock(return_value=AccessToken("other", time.time() + 3600)) + ) + other = Pipeline( + policies=[ChallengeAuthPolicy(other_credential)], + transport=Mock(send=Mock(return_value=Mock(status_code=401, headers={"WWW-Authenticate": "Bearer invalid=1"}))), + ) + full_challenge_pending = initial != "cached" + claims_pending = True + + def send(request): + nonlocal full_challenge_pending, claims_pending + if full_challenge_pending: + full_challenge_pending = False + assert bool(request.headers.get("Authorization")) == initial.startswith("refresh") + return Mock(status_code=401, headers={"WWW-Authenticate": full_header}) + assert request.headers["Authorization"] == "Bearer token" + assert request.body == b"payload" + if claims_pending: + claims_pending = False + assert other.run(HttpRequest("GET", url + "/other")).http_response.status_code == 401 + assert HttpChallengeCache.get_challenge_for_url(url) is None + if cache_change != "evict": + replacement = "https://other.example/.default" if cache_change == "replace-invalid" else old_scope + HttpChallengeCache.set_challenge_for_url( + url, + HttpChallenge( + url, f'Bearer authorization="https://authority.net/other-tenant", scope={replacement}' + ), + ) + return Mock(status_code=401, headers={"WWW-Authenticate": claims_header}) + return Mock(status_code=200, headers={}) + + pipeline = Pipeline( + policies=[RetryPolicy(retry_backoff_factor=0), ChallengeAuthPolicy(credential)], transport=Mock(send=send) + ) + assert pipeline.run(HttpRequest("PUT", url, content=b"payload")).http_response.status_code == 200 + # Preserve CAE precedence: both accepted values override replacements supplied in the claims challenge. + credential.get_token.assert_called_with(active_scope, tenant_id=active_tenant, claims="{}", enable_cae=True) + cached = HttpChallengeCache.get_challenge_for_url(url) + assert cached.get_scope() == active_scope + assert cached.tenant_id == active_tenant + + +@pytest.mark.parametrize( + "target", ["same-path", "same-case", "default-port", "other-host", "other-port", "other-scheme"] +) +def test_cae_request_snapshot_is_origin_bound(target): + url = get_random_url() + parsed = urlparse(url) + targets = { + "same-path": url + "/redirected", + "same-case": f"https://{parsed.netloc.upper()}{parsed.path}", + "default-port": f"https://{parsed.netloc.upper()}:443{parsed.path}", + "other-host": get_random_url(), + "other-port": f"https://{parsed.netloc}:8443{parsed.path}", + "other-scheme": url.replace("https://", "http://", 1), + } + redirect_url = targets[target] + scope = "https://vault.azure.net/.default" + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/tenant", scope={scope}') + ) + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("token", time.time() + 3600))) + requests = [] + + def send(request): + requests.append(request.url) + if len(requests) == 1: + if target != "other-scheme": + HttpChallengeCache.remove_challenge_for_url(url) + return Mock(status_code=307, headers={"location": redirect_url}) + assert request.url == redirect_url + if len(requests) == 2: + return Mock( + status_code=401, + headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/", claims="e30="'}, + ) + return Mock(status_code=200, headers={}) + + pipeline = Pipeline( + policies=[RetryPolicy(), ChallengeAuthPolicy(credential), RedirectPolicy()], transport=Mock(send=send) + ) + if target.startswith("other"): + with pytest.raises(ServiceRequestError if target == "other-scheme" else ValueError): + pipeline.run(HttpRequest("GET", url)) + assert credential.get_token.call_count == 1 + else: + assert pipeline.run(HttpRequest("GET", url)).http_response.status_code == 200 + credential.get_token.assert_called_with(scope, tenant_id="tenant", claims="{}", enable_cae=True) + assert credential.get_token.call_count == 2 + + +def test_cae_does_not_inherit_tenant_from_invalid_cache(): + url = get_random_url() + HttpChallengeCache.set_challenge_for_url( + url, + HttpChallenge(url, 'Bearer authorization="https://authority.net/untrusted", resource=https://other.example'), + ) + credential = Mock(spec_set=["get_token"]) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + response = PipelineResponse( + request.http_request, + Mock( + status_code=401, + headers={ + "WWW-Authenticate": 'Bearer authorization="https://authority.net/", scope=https://vault.azure.net/.default, claims="e30="' + }, + ), + request.context, + ) + with pytest.raises(ValueError): + ChallengeAuthPolicy(credential).on_challenge(request, response) + credential.get_token.assert_not_called() + assert HttpChallengeCache.get_challenge_for_url(url) is None + + +@pytest.mark.parametrize("redirect_status", [303, 307]) +def test_discovery_replay_preserves_redirect_target(redirect_status): + url = get_random_url() + redirect_url = get_random_url() + header = 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, header)) + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("token", time.time() + 3600))) + other = Pipeline( + policies=[ChallengeAuthPolicy(credential)], + transport=Mock(send=Mock(return_value=Mock(status_code=401, headers={"WWW-Authenticate": "Bearer invalid=1"}))), + ) + requests = [] + + def send(request): + requests.append(request.url) + if len(requests) == 1: + assert (request.method, request.url, request.body) == ("PUT", url, b"payload") + assert request.headers["Authorization"] == "Bearer token" + assert other.run(HttpRequest("GET", url + "/other")).http_response.status_code == 401 + assert HttpChallengeCache.get_challenge_for_url(url) is None + return Mock(status_code=503, headers={}) + if len(requests) == 2: + assert (request.method, request.url) == ("PUT", url) + assert "Authorization" not in request.headers + assert not request.body + return Mock(status_code=redirect_status, headers={"location": redirect_url}) + assert request.url == redirect_url + assert request.method == ("GET" if redirect_status == 303 else "PUT") + if len(requests) == 3: + assert "Authorization" not in request.headers + assert not request.body + return Mock( + status_code=401, + headers={ + "WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + }, + ) + assert request.headers["Authorization"] == "Bearer token" + assert request.body == (None if redirect_status == 303 else b"payload") + return Mock(status_code=200, headers={}) + + pipeline = Pipeline( + policies=[ + RetryPolicy(retry_total=1, retry_backoff_factor=0), + ChallengeAuthPolicy(credential), + RedirectPolicy(), + ], + transport=Mock(send=send), + ) + assert pipeline.run(HttpRequest("PUT", url, content=b"payload")).http_response.status_code == 200 + assert len(requests) == 4 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +@pytest.mark.parametrize("cached_token", [False, True]) +@pytest.mark.parametrize("challenge_sequence", ["", "kv", "cae", "kv-cae"]) +@pytest.mark.parametrize("fresh_policy", [False, True]) +@pytest.mark.parametrize("verify_challenge_resource", [False, True]) +async def test_missing_challenge_header_clears_cache_and_rediscovers( + is_async, token_type, cached_token, challenge_sequence, fresh_policy, verify_challenge_resource +): + url = get_random_url() + header = 'Bearer authorization="https://authority.net/old-tenant", resource=' + ( + "https://vault.azure.net" if verify_challenge_resource else "https://other.example" + ) + new_header = 'Bearer authorization="https://authority.net/new-tenant", resource=https://vault.azure.net' + claims_header = 'Bearer authorization="https://authority.net/old-tenant", claims="e30="' + challenges = {"": [], "kv": [header], "cae": [claims_header], "kv-cae": [header, claims_header]}[challenge_sequence] + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, header)) + token_method = "get_token" if token_type == AccessToken else "get_token_info" + token_mock = (AsyncMock if is_async else Mock)(return_value=token_type("accepted-token", time.time() + 3600)) + credential = Mock(spec_set=[token_method], **{token_method: token_mock}) + policy_type = AsyncChallengeAuthPolicy if is_async else ChallengeAuthPolicy + policy = policy_type(credential, verify_challenge_resource=verify_challenge_resource) + if cached_token: + policy._token = token_type("accepted-token", time.time() + 3600) + recovery_policy = ( + policy_type(credential, verify_challenge_resource=verify_challenge_resource) if fresh_policy else policy + ) + if fresh_policy: + recovery_policy._token = token_type("warm-token", time.time() + 3600) + missing = Mock(status_code=401, headers={}) + sent_requests = [] + + def send(request): + sent_requests.append(request) + attempt = len(sent_requests) + if attempt <= 1 + len(challenges): + assert request.headers["Authorization"] == "Bearer accepted-token" + assert request.body == b"payload" + return ( + Mock(status_code=401, headers={"WWW-Authenticate": challenges[attempt - 1]}) + if attempt <= len(challenges) + else missing + ) + if attempt == 2 + len(challenges): + assert "Authorization" not in request.headers + assert not request.body + assert request.headers["Content-Length"] == "0" + assert request.headers["x-current"] == "latest" + request.headers["x-added"] = "retained" + return Mock(status_code=401, headers={"WWW-Authenticate": new_header}) + assert request.url == url + assert request.headers["Authorization"] == "Bearer accepted-token" + assert request.body == b"payload" + assert request.headers["Content-Length"] == "7" + assert request.headers["x-current"] == "latest" + assert request.headers["x-added"] == "retained" + return Mock(status_code=200, headers={}) + + transport = Mock(send=AsyncMock(wraps=send) if is_async else Mock(wraps=send)) + + def pipeline(auth_policy): + return (AsyncPipeline if is_async else Pipeline)( + policies=[ + AsyncRedirectPolicy() if is_async else RedirectPolicy(), + AsyncRetryPolicy(retry_total=0) if is_async else RetryPolicy(retry_total=0), + auth_policy, + SensitiveHeaderCleanupPolicy(), + ], + transport=transport, + ) + + response = ( + await pipeline(policy).run(HttpRequest("PUT", url, content=b"payload")) + if is_async + else pipeline(policy).run(HttpRequest("PUT", url, content=b"payload")) + ) + assert response.http_response is missing + assert policy._token is None + assert len(sent_requests) == 1 + len(challenges) + assert token_mock.call_count == int(not cached_token) + len(challenges) + assert HttpChallengeCache.get_challenge_for_url(url) is None + next_request = HttpRequest( + "PUT", url, headers={"Authorization": "Bearer stale", "x-current": "latest"}, content=b"payload" + ) + response = ( + await pipeline(recovery_policy).run(next_request) if is_async else pipeline(recovery_policy).run(next_request) + ) + assert response.http_response.status_code == 200 + assert len(sent_requests) == 3 + len(challenges) + assert token_mock.call_count == int(not cached_token) + len(challenges) + 1 + assert token_mock.call_args.args == ("https://vault.azure.net/.default",) + assert token_mock.call_args.kwargs.get("options", token_mock.call_args.kwargs)["tenant_id"] == "new-tenant" + assert HttpChallengeCache.get_challenge_for_url(url).tenant_id == "new-tenant" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +@pytest.mark.parametrize("cache_change", ["evict", "replace-valid", "replace-invalid"]) +async def test_missing_challenge_header_preserves_inflight_cae_snapshot(is_async, token_type, cache_change): + url = get_random_url() + scope = "https://vault.azure.net/.default" + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/old-tenant", scope={scope}') + ) + token_method = "get_token" if token_type == AccessToken else "get_token_info" + token_mock = (AsyncMock if is_async else Mock)(return_value=token_type("token", time.time() + 3600)) + credential = Mock(spec_set=[token_method], **{token_method: token_mock}) + policy_type = AsyncChallengeAuthPolicy if is_async else ChallengeAuthPolicy + active, other = policy_type(credential), policy_type(credential) + request = PipelineRequest(HttpRequest("PUT", url, content=b"payload"), PipelineContext(None)) + other_request = PipelineRequest(HttpRequest("GET", url + "/other"), PipelineContext(None)) + if is_async: + await active.on_request(request) + await other.on_request(other_request) + else: + active.on_request(request) + other.on_request(other_request) + missing = PipelineResponse(other_request.http_request, Mock(status_code=401, headers={}), other_request.context) + result = ( + await other.handle_challenge_flow(other_request, missing) + if is_async + else other.handle_challenge_flow(other_request, missing) + ) + assert result is missing + assert HttpChallengeCache.get_challenge_for_url(url) is None + if cache_change != "evict": + replacement = scope if cache_change == "replace-valid" else "https://other.example/.default" + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/other-tenant", scope={replacement}') + ) + claims = PipelineResponse( + request.http_request, + Mock( + status_code=401, + headers={ + "WWW-Authenticate": ( + 'Bearer authorization="https://authority.net/new-tenant", ' + 'scope=https://vault.azure.net/new-scope, claims="e30="' + ) + }, + ), + request.context, + ) + authorized = await active.on_challenge(request, claims) if is_async else active.on_challenge(request, claims) + assert authorized + assert request.http_request.body == b"payload" + assert token_mock.call_args.args == (scope,) + options = token_mock.call_args.kwargs.get("options", token_mock.call_args.kwargs) + assert options["tenant_id"] == "old-tenant" + assert options["claims"] == "{}" + + +def redirect_replay_case(warm_cache, target_cache, redirect_status): + url = get_random_url() + target = get_random_url() + header = 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + if warm_cache: + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, header)) + requests = [] + + def send(request): + requests.append(request.url) + if warm_cache and len(requests) == 1: + assert request.body == b"payload" + HttpChallengeCache.remove_challenge_for_url(url) + return Mock(status_code=503, headers={}) + if request.url == url: + assert not request.body + assert request.headers["x-sensitive"] == "private" + request.headers["x-current"] = "updated" + request.headers["x-added"] = "retained" + if target_cache: + HttpChallengeCache.set_challenge_for_url(target, HttpChallenge(target, header)) + return Mock(status_code=redirect_status, headers={"location": target}) + assert request.url == target + assert "x-sensitive" not in request.headers + assert request.headers["x-current"] == "updated" + assert request.headers["x-added"] == "retained" + method = "GET" if redirect_status == 303 else "PUT" + assert request.method == method + if not target_cache and requests.count(target) == 1: + assert not request.body + return Mock(status_code=401, headers={"WWW-Authenticate": header}) + assert request.body == (None if redirect_status == 303 else b"payload") + assert request.headers["Content-Length"] == ("0" if redirect_status == 303 else "7") + return Mock(status_code=200, headers={}) + + request = HttpRequest("PUT", url, headers={"x-sensitive": "private", "x-current": "original"}, content=b"payload") + return request, send, requests + + +@pytest.mark.parametrize("warm_cache", [False, True]) +@pytest.mark.parametrize("target_cache", [False, True]) +@pytest.mark.parametrize("redirect_status", [303, 307]) +def test_discovery_replay_preserves_redirect_headers(warm_cache, target_cache, redirect_status): + request, send, requests = redirect_replay_case(warm_cache, target_cache, redirect_status) + credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=AccessToken("token", time.time() + 3600))) + pipeline = Pipeline( + policies=[ + RedirectPolicy(redirect_remove_headers=["x-sensitive"]), + RetryPolicy(retry_total=1, retry_backoff_factor=0), + ChallengeAuthPolicy(credential), + SensitiveHeaderCleanupPolicy(), + ], + transport=Mock(send=send), + ) + assert pipeline.run(request).http_response.status_code == 200 + assert len(requests) == 2 + int(warm_cache) + int(not target_cache) 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 7263cd55527d..9f5c71e30811 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 @@ -9,15 +9,30 @@ import functools import time -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock +from urllib.parse import urlparse +from uuid import uuid4 import pytest from azure.core.credentials import AccessToken, AccessTokenInfo -from azure.core.pipeline import AsyncPipeline +from azure.core.exceptions import ServiceRequestError +from azure.core.pipeline import AsyncPipeline, PipelineContext, PipelineRequest, PipelineResponse +from azure.core.pipeline.policies import AsyncRedirectPolicy, AsyncRetryPolicy, SensitiveHeaderCleanupPolicy from azure.core.rest import HttpRequest -from azure.keyvault.secrets._shared import AsyncChallengeAuthPolicy, HttpChallengeCache +from azure.keyvault.secrets._shared import AsyncChallengeAuthPolicy, HttpChallenge, HttpChallengeCache -from test_challenge_auth import get_random_url, TOKEN_TYPES +from test_challenge_auth import ( + CACHE_RESOURCE_DOMAINS, + INVALID_CHALLENGE_HEADERS, + redirect_replay_case, + BACKSLASH_AUTHORITIES, + BACKSLASH_CACHE_CASES, + CHALLENGE, + TOKEN_TYPES, + VALID_CACHED_URLS, + VALID_CHALLENGE_AUTHORITIES, + get_random_url, +) def empty_challenge_cache(fn): @@ -30,6 +45,164 @@ async def wrapper(**kwargs): return wrapper +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("authority,cache_state", BACKSLASH_CACHE_CASES) +@pytest.mark.parametrize("verify_challenge_resource", [True, False]) +async def test_rejects_backslash_authority(authority, cache_state, verify_challenge_resource): + url = f"https://{authority}/secrets/canary" + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(side_effect=AssertionError("unexpected token request")) + ) + transport = Mock(send=AsyncMock(side_effect=AssertionError("unexpected transport send"))) + policy = AsyncChallengeAuthPolicy(credential, verify_challenge_resource=verify_challenge_resource) + pipeline = AsyncPipeline(policies=[policy], transport=transport) + if cache_state != "empty": + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, CHALLENGE)) + if cache_state == "token": + policy._token = AccessToken("cached-token", time.time() + 3600) + cached = HttpChallengeCache._cache.copy() + + for _ in range(2): + request = HttpRequest("POST", url) + request.set_bytes_body(b"secret") + with pytest.raises(ValueError, match="backslash"): + await pipeline.run(request) + assert "Authorization" not in request.headers + assert request.body == b"secret" + assert HttpChallengeCache._cache == cached + + credential.get_token.assert_not_called() + transport.send.assert_not_called() + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("authority", BACKSLASH_AUTHORITIES) +@pytest.mark.parametrize("verify_challenge_resource", [True, False]) +async def test_rejects_backslash_authority_on_challenge(authority, verify_challenge_resource): + url = f"https://{authority}/secrets/canary" + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(side_effect=AssertionError("unexpected token request")) + ) + policy = AsyncChallengeAuthPolicy(credential, verify_challenge_resource=verify_challenge_resource) + transport = Mock() + response = Mock(http_response=Mock(status_code=401, headers={"WWW-Authenticate": CHALLENGE})) + + for _ in range(2): + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(transport)) + with pytest.raises(ValueError, match="backslash"): + await policy.on_challenge(request, response) + assert "Authorization" not in request.http_request.headers + assert not HttpChallengeCache._cache + + credential.get_token.assert_not_called() + transport.send.assert_not_called() + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("url", VALID_CACHED_URLS) +async def test_request_url_validation_preserves_cached_urls(url): + challenge = HttpChallenge(url, CHALLENGE) + HttpChallengeCache.set_challenge_for_url(url, challenge) + credential = Mock(spec_set=["get_token"], get_token=AsyncMock()) + # These URL-only fixtures deliberately use an unrelated challenge resource. + policy = AsyncChallengeAuthPolicy(credential, verify_challenge_resource=False) + policy._token = AccessToken("cached-token", time.time() + 3600) + + for _ in range(2): + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + await policy.on_request(request) + assert request.http_request.url == url + assert request.http_request.headers["Authorization"] == "Bearer cached-token" + assert HttpChallengeCache.get_challenge_for_url(url) is challenge + + credential.get_token.assert_not_called() + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("authority,resource,tenant", VALID_CHALLENGE_AUTHORITIES) +async def test_request_url_validation_preserves_challenge_flow(authority, resource, tenant): + url = f"https://{authority}/secrets/item" + challenge = Mock( + status_code=401, + headers={ + "WWW-Authenticate": f'Bearer authorization="https://authority.net/{tenant}", resource=https://{resource}' + }, + ) + + async def send(request): + assert request.url == url + if "Authorization" not in request.headers: + assert not request.body + assert request.headers["Content-Length"] == "0" + return challenge + assert request.headers["Authorization"] == "Bearer expected-token" + assert request.body == b"secret" + return Mock(status_code=200) + + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("expected-token", time.time() + 3600)) + ) + transport = Mock(send=AsyncMock(wraps=send)) + pipeline = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential)], transport=transport) + for _ in range(2): + request = HttpRequest("POST", url) + request.set_bytes_body(b"secret") + assert (await pipeline.run(request)).http_response.status_code == 200 + + assert transport.send.await_count == 3 + credential.get_token.assert_awaited_once() + assert credential.get_token.call_args.args == (f"https://{resource}/.default",) + if tenant == "adfs": + assert "tenant_id" not in credential.get_token.call_args.kwargs + else: + assert credential.get_token.call_args.kwargs["tenant_id"] == tenant + assert HttpChallengeCache.get_challenge_for_url(url).get_resource() == f"https://{resource}" + + +@pytest.mark.asyncio +@empty_challenge_cache +@pytest.mark.parametrize("fresh_policy", [False, True]) +async def test_rejected_challenge_is_not_cached(fresh_policy): + url = "https://example.net/secrets/canary" + 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 + assert "Authorization" not in request.headers + assert not request.body + assert request.headers["Content-Length"] == "0" + return challenge + + credential = Mock(spec_set=["get_token"], get_token=Mock(side_effect=AssertionError("unexpected token request"))) + pipeline = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential=credential)], transport=Mock(send=send)) + + for _ in range(2): + if fresh_policy: + pipeline = AsyncPipeline( + policies=[AsyncChallengeAuthPolicy(credential=credential)], transport=Mock(send=send) + ) + request = HttpRequest("POST", url) + request.set_bytes_body(b"secret") + with pytest.raises(ValueError): + await pipeline.run(request) + + assert Requests.count == 2 + assert not HttpChallengeCache.get_challenge_for_url(url) + assert credential.get_token.call_count == 0 + + @pytest.mark.asyncio @empty_challenge_cache @pytest.mark.parametrize("token_type", TOKEN_TYPES) @@ -49,7 +222,9 @@ async def test_request_body_not_reused_across_requests(token_type): second_url = get_random_url() challenge = Mock( status_code=401, - headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net'}, + headers={ + "WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + }, ) class Requests: @@ -98,3 +273,572 @@ async def get_token(*_, **__): await pipeline.run(first_request) await pipeline.run(HttpRequest("GET", second_url)) + + +@pytest.mark.parametrize("token_type", TOKEN_TYPES) +@pytest.mark.parametrize("cached_token", [False, True]) +@pytest.mark.parametrize( + "request_port,resource", + [ + ("", "https://other.example"), + ("", "invalid"), + ("", "https://vault.azure.net:8443"), + (":8443", "https://vault.azure.net"), + (":8443", "https://vault.azure.net:443"), + (":443", "https://vault.azure.net:8443"), + ], +) +@pytest.mark.asyncio +async def test_cached_challenge_resource_is_verified(token_type, cached_token, request_port, resource): + url = get_random_url().replace(".vault.azure.net/", f".vault.azure.net{request_port}/") + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/tenant", resource={resource}') + ) + credential = Mock( + spec_set=["get_token", "get_token_info"], + get_token=Mock(side_effect=AssertionError("unexpected token request")), + get_token_info=Mock(side_effect=AssertionError("unexpected token request")), + ) + policy = AsyncChallengeAuthPolicy(credential) + if cached_token: + policy._token = token_type("cached-token", time.time() + 3600) + transport = Mock(send=Mock(side_effect=AssertionError("unexpected transport call"))) + request = HttpRequest("POST", url, content=b"secret") + + with pytest.raises(ValueError): + await AsyncPipeline(policies=[policy], transport=transport).run(request) + + assert "Authorization" not in request.headers + credential.get_token.assert_not_called() + credential.get_token_info.assert_not_called() + transport.send.assert_not_called() + assert HttpChallengeCache.get_challenge_for_url(url) is None + + follow_up = PipelineRequest(HttpRequest("POST", url, content=b"next secret"), PipelineContext(None)) + await policy.on_request(follow_up) + response = PipelineResponse( + follow_up.http_request, Mock(status_code=401, headers={"WWW-Authenticate": "Bearer"}), follow_up.context + ) + assert await policy.on_challenge(follow_up, response) is False + assert "Authorization" not in follow_up.http_request.headers + assert not follow_up.http_request.body + assert follow_up.http_request.headers["Content-Length"] == "0" + credential.get_token.assert_not_called() + credential.get_token_info.assert_not_called() + + +@pytest.mark.parametrize("cached_token", [False, True]) +@pytest.mark.asyncio +async def test_opt_out_cache_is_not_trusted_by_strict_policy(cached_token): + url = get_random_url() + header = 'Bearer authorization="https://authority.net/tenant", resource=https://other.example' + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("opt-out", time.time() + 3600)) + ) + transport = Mock( + send=AsyncMock( + side_effect=[ + Mock(status_code=401, headers={"WWW-Authenticate": header}), + Mock(status_code=200), + Mock(status_code=200), + ] + ) + ) + pipeline = AsyncPipeline( + policies=[AsyncChallengeAuthPolicy(credential, verify_challenge_resource=False)], transport=transport + ) + await pipeline.run(HttpRequest("GET", url)) + await pipeline.run(HttpRequest("GET", url)) + assert transport.send.call_count == 3 + credential.get_token.assert_called_once() + assert HttpChallengeCache.get_challenge_for_url(url) + + strict_credential = Mock( + spec_set=["get_token"], get_token=Mock(side_effect=AssertionError("unexpected token request")) + ) + strict_policy = AsyncChallengeAuthPolicy(strict_credential) + if cached_token: + strict_policy._token = AccessToken("strict-token", time.time() + 3600) + strict_transport = Mock(send=Mock(side_effect=AssertionError("unexpected transport call"))) + with pytest.raises(ValueError): + await AsyncPipeline(policies=[strict_policy], transport=strict_transport).run(HttpRequest("GET", url)) + strict_credential.get_token.assert_not_called() + strict_transport.send.assert_not_called() + assert HttpChallengeCache.get_challenge_for_url(url) is None + + +@pytest.mark.parametrize("header", INVALID_CHALLENGE_HEADERS) +@pytest.mark.asyncio +async def test_rejected_response_preserves_concurrent_challenge(header): + url = get_random_url() + credential = Mock(spec_set=["get_token"]) + policy = AsyncChallengeAuthPolicy(credential) + request = PipelineRequest(HttpRequest("POST", url, content=b"secret"), PipelineContext(None)) + await policy.on_request(request) + assert not request.http_request.body + replacement = HttpChallenge( + url, 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + ) + HttpChallengeCache.set_challenge_for_url(url, replacement) + response = PipelineResponse( + request.http_request, Mock(status_code=401, headers={"WWW-Authenticate": header}), request.context + ) + if header and "authorization=" in header: + with pytest.raises(ValueError): + await policy.on_challenge(request, response) + else: + assert await policy.on_challenge(request, response) is False + assert HttpChallengeCache.get_challenge_for_url(url) is replacement + assert "Authorization" not in request.http_request.headers + assert not request.http_request.body + credential.get_token.assert_not_called() + + async def send(fresh_request): + assert "Authorization" not in fresh_request.headers + assert not fresh_request.body + assert fresh_request.headers["Content-Length"] == "0" + return Mock(status_code=401, headers={}) + + # Start a separate cold-discovery scenario, independent of the concurrent cache entry. + HttpChallengeCache.remove_challenge_for_url(url) + fresh = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential)], transport=Mock(send=send)) + assert (await fresh.run(HttpRequest("POST", url, content=b"new secret"))).http_response.status_code == 401 + credential.get_token.assert_not_called() + + +@pytest.mark.parametrize("header", ["", " ", "Bearer", "Bearer resource=https://vault.azure.net"]) +@pytest.mark.parametrize("consecutive", [False, True]) +@pytest.mark.asyncio +async def test_malformed_challenge_flow_clears_cache(header, consecutive): + url = get_random_url() + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net') + ) + credential = Mock(spec_set=["get_token"]) + policy = AsyncChallengeAuthPolicy(credential) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + policy._token = AccessToken("token", time.time() + 3600) + await policy.on_request(request) + response = PipelineResponse( + request.http_request, Mock(status_code=401, headers={"WWW-Authenticate": header}), request.context + ) + assert await policy.handle_challenge_flow(request, response, consecutive_challenge=consecutive) is response + assert HttpChallengeCache.get_challenge_for_url(url) is None + credential.get_token.assert_not_called() + + +@pytest.mark.asyncio +async def test_cae_invalid_inherited_scope_clears_cache(): + url = get_random_url() + HttpChallengeCache.set_challenge_for_url( + url, + HttpChallenge(url, 'Bearer authorization="https://authority.net/tenant", scope=https://other.example/.default'), + ) + credential = Mock(spec_set=["get_token"]) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + response = PipelineResponse( + request.http_request, + Mock( + status_code=401, + headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/", claims="e30="'}, + ), + request.context, + ) + with pytest.raises(ValueError): + await AsyncChallengeAuthPolicy(credential).on_challenge(request, response) + assert HttpChallengeCache.get_challenge_for_url(url) is None + credential.get_token.assert_not_called() + + +@pytest.mark.parametrize("domain", CACHE_RESOURCE_DOMAINS) +@pytest.mark.parametrize("parameter", ["resource", "scope"]) +@pytest.mark.parametrize( + "request_port,resource_port", [("", ""), (":443", ""), ("", ":443"), (":443", ":443"), (":8443", ":8443")] +) +@pytest.mark.asyncio +async def test_valid_challenge_cache_reuse(domain, parameter, request_port, resource_port): + host = f"{uuid4().hex}.{domain}" + url = f"https://{host}{request_port}/first" + alias_port = "" if request_port == ":443" else ":443" if not request_port else request_port + alias = f"https://{host.upper()}{alias_port}/next" + resource = f"https://{domain.upper()}{resource_port}" + scope = resource + "/.default" + header = f'Bearer authorization="https://authority.example/ADFS", {parameter}={scope if parameter == "scope" else resource}' + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("token", time.time() + 3600)) + ) + requests = [] + + async def send(request): + requests.append(request) + if len(requests) == 1: + assert "Authorization" not in request.headers + assert not request.body + return Mock(status_code=401, headers={"WWW-Authenticate": header}) + assert request.headers["Authorization"] == "Bearer token" + assert request.body == b"body" + return Mock(status_code=200) + + pipeline = AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential)], transport=Mock(send=send)) + await pipeline.run(HttpRequest("POST", url, content=b"body")) + await pipeline.run(HttpRequest("POST", alias, content=b"body")) + assert len(requests) == 3 + credential.get_token.assert_called_once_with(scope, claims=None, enable_cae=True) + # A new policy must also use the cached challenge directly, without another unauthenticated request. + await AsyncPipeline(policies=[AsyncChallengeAuthPolicy(credential)], transport=Mock(send=send)).run( + HttpRequest("POST", alias, content=b"body") + ) + assert len(requests) == 4 + assert credential.get_token.call_count == 2 + assert credential.get_token.call_args.kwargs == {"enable_cae": True} + assert HttpChallengeCache.get_challenge_for_url(alias) is HttpChallengeCache.get_challenge_for_url(url) + + +@pytest.mark.asyncio +async def test_cached_cae_scope_and_tenant_refresh(): + url = get_random_url() + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("token", time.time() + 3600)) + ) + policy = AsyncChallengeAuthPolicy(credential) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + scope = "https://vault.azure.net/.default" + for header, tenant, claims in [ + (f'Bearer authorization="https://authority.net/first", scope={scope}', "first", None), + ('Bearer authorization="https://authority.net/", claims="e30="', "first", "{}"), + (f'Bearer authorization="https://authority.net/second", scope={scope}', "second", None), + ]: + response = PipelineResponse( + request.http_request, Mock(status_code=401, headers={"WWW-Authenticate": header}), request.context + ) + assert await policy.on_challenge(request, response) + credential.get_token.assert_called_with(scope, claims=claims, tenant_id=tenant, enable_cae=True) + policy._token = None + await policy.on_request(request) + credential.get_token.assert_called_with(scope, tenant_id="second", enable_cae=True) + + +@pytest.mark.parametrize( + "resource", + [ + "http://vault.azure.net", + "http://vault.azure.net:443", + "spn://vault.azure.net", + "//vault.azure.net", + "https://vault.azure.net/scope", + ], +) +@pytest.mark.asyncio +async def test_cached_resource_syntax_is_preserved(resource): + url = get_random_url() + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/tenant", resource={resource}') + ) + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("token", time.time() + 3600)) + ) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + await AsyncChallengeAuthPolicy(credential).on_request(request) + credential.get_token.assert_called_once_with(resource + "/.default", tenant_id="tenant", enable_cae=True) + assert request.http_request.headers["Authorization"] == "Bearer token" + + +@pytest.mark.parametrize("method,body", [("PUT", b"payload"), ("HEAD", None), ("HEAD", b"payload")]) +@pytest.mark.parametrize("warm_cache", [False, True]) +@pytest.mark.parametrize("refill_cache", [False, True]) +@pytest.mark.asyncio +async def test_retry_cache_eviction_preserves_request(method, body, warm_cache, refill_cache): + url = get_random_url() + header = 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + if warm_cache: + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, header)) + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("token", time.time() + 3600)) + ) + policy = AsyncChallengeAuthPolicy(credential) + other_credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("other", time.time() + 3600)) + ) + other = AsyncPipeline( + policies=[AsyncChallengeAuthPolicy(other_credential)], + transport=Mock( + send=AsyncMock(return_value=Mock(status_code=401, headers={"WWW-Authenticate": "Bearer invalid=1"})) + ), + ) + stages = ( + [(True, 503, "evict"), (False, 503, "refill"), (True, 200, None)] + if refill_cache + else [ + (True, 503, "evict"), + (False, 503, None), + (False, 401, None), + (True, 503, "evict"), + (False, 401, None), + (True, 200, None), + ] + ) + if not warm_cache: + stages.insert(0, (False, 401, None)) + + async def send(request): + if not stages: + assert (request.method, request.url, request.body) == ("GET", url + "/next", None) + return Mock(status_code=200, headers={}) + authenticated, status, action = stages.pop(0) + assert (request.method, request.url) == (method, url) + if authenticated: + assert request.headers["Authorization"] == "Bearer token" + assert request.body == body + else: + assert "Authorization" not in request.headers + assert not request.body + if body: + assert request.headers["Content-Length"] == "0" + if action == "evict": + assert (await other.run(HttpRequest("GET", url + "/other"))).http_response.status_code == 401 + assert HttpChallengeCache.get_challenge_for_url(url) is None + elif action == "refill": + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, header)) + return Mock(status_code=status, headers={"WWW-Authenticate": header} if status == 401 else {}) + + pipeline = AsyncPipeline( + policies=[AsyncRetryPolicy(retry_total=5, retry_status=5, retry_backoff_factor=0), policy], + transport=Mock(send=send), + ) + assert (await pipeline.run(HttpRequest(method, url, content=body))).http_response.status_code == 200 + assert not stages + assert (await pipeline.run(HttpRequest("GET", url + "/next"))).http_response.status_code == 200 + + +@pytest.mark.parametrize("initial", ["cached", "fresh", "refresh", "refresh-tenantless"]) +@pytest.mark.parametrize("cache_change", ["evict", "replace-valid", "replace-invalid"]) +@pytest.mark.parametrize( + "new_tenant,new_scope", + [ + (None, None), + ("new-tenant", None), + (None, "https://vault.azure.net/new-scope"), + ("new-tenant", "https://vault.azure.net/new-scope"), + ], +) +@pytest.mark.asyncio +async def test_cae_request_snapshot_survives_cache_changes(initial, cache_change, new_tenant, new_scope): + url = get_random_url() + old_scope = "https://vault.azure.net/.default" + if initial != "fresh": + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/old-tenant", scope={old_scope}') + ) + active_scope = "https://vault.azure.net/refreshed-scope" if initial.startswith("refresh") else old_scope + active_tenant = ( + None if initial == "refresh-tenantless" else "refreshed-tenant" if initial == "refresh" else "old-tenant" + ) + full_header = f'Bearer authorization="https://authority.net/{active_tenant or ""}", scope={active_scope}' + claims_header = f'Bearer authorization="https://authority.net/{new_tenant or ""}", claims="e30="' + if new_scope: + claims_header += f", scope={new_scope}" + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("token", time.time() + 3600)) + ) + other_credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("other", time.time() + 3600)) + ) + other = AsyncPipeline( + policies=[AsyncChallengeAuthPolicy(other_credential)], + transport=Mock( + send=AsyncMock(return_value=Mock(status_code=401, headers={"WWW-Authenticate": "Bearer invalid=1"})) + ), + ) + full_challenge_pending = initial != "cached" + claims_pending = True + + async def send(request): + nonlocal full_challenge_pending, claims_pending + if full_challenge_pending: + full_challenge_pending = False + assert bool(request.headers.get("Authorization")) == initial.startswith("refresh") + return Mock(status_code=401, headers={"WWW-Authenticate": full_header}) + assert request.headers["Authorization"] == "Bearer token" + assert request.body == b"payload" + if claims_pending: + claims_pending = False + assert (await other.run(HttpRequest("GET", url + "/other"))).http_response.status_code == 401 + assert HttpChallengeCache.get_challenge_for_url(url) is None + if cache_change != "evict": + replacement = "https://other.example/.default" if cache_change == "replace-invalid" else old_scope + HttpChallengeCache.set_challenge_for_url( + url, + HttpChallenge( + url, f'Bearer authorization="https://authority.net/other-tenant", scope={replacement}' + ), + ) + return Mock(status_code=401, headers={"WWW-Authenticate": claims_header}) + return Mock(status_code=200, headers={}) + + pipeline = AsyncPipeline( + policies=[AsyncRetryPolicy(retry_backoff_factor=0), AsyncChallengeAuthPolicy(credential)], + transport=Mock(send=send), + ) + assert (await pipeline.run(HttpRequest("PUT", url, content=b"payload"))).http_response.status_code == 200 + credential.get_token.assert_called_with(active_scope, tenant_id=active_tenant, claims="{}", enable_cae=True) + cached = HttpChallengeCache.get_challenge_for_url(url) + assert cached.get_scope() == active_scope + assert cached.tenant_id == active_tenant + + +@pytest.mark.parametrize( + "target", ["same-path", "same-case", "default-port", "other-host", "other-port", "other-scheme"] +) +@pytest.mark.asyncio +async def test_cae_request_snapshot_is_origin_bound(target): + url = get_random_url() + parsed = urlparse(url) + targets = { + "same-path": url + "/redirected", + "same-case": f"https://{parsed.netloc.upper()}{parsed.path}", + "default-port": f"https://{parsed.netloc.upper()}:443{parsed.path}", + "other-host": get_random_url(), + "other-port": f"https://{parsed.netloc}:8443{parsed.path}", + "other-scheme": url.replace("https://", "http://", 1), + } + redirect_url = targets[target] + scope = "https://vault.azure.net/.default" + HttpChallengeCache.set_challenge_for_url( + url, HttpChallenge(url, f'Bearer authorization="https://authority.net/tenant", scope={scope}') + ) + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("token", time.time() + 3600)) + ) + requests = [] + + async def send(request): + requests.append(request.url) + if len(requests) == 1: + if target != "other-scheme": + HttpChallengeCache.remove_challenge_for_url(url) + return Mock(status_code=307, headers={"location": redirect_url}) + assert request.url == redirect_url + if len(requests) == 2: + return Mock( + status_code=401, + headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/", claims="e30="'}, + ) + return Mock(status_code=200, headers={}) + + pipeline = AsyncPipeline( + policies=[AsyncRetryPolicy(retry_total=0), AsyncChallengeAuthPolicy(credential), AsyncRedirectPolicy()], + transport=Mock(send=send), + ) + if target.startswith("other"): + with pytest.raises(ServiceRequestError if target == "other-scheme" else ValueError): + await pipeline.run(HttpRequest("GET", url)) + assert credential.get_token.call_count == 1 + else: + assert (await pipeline.run(HttpRequest("GET", url))).http_response.status_code == 200 + credential.get_token.assert_called_with(scope, tenant_id="tenant", claims="{}", enable_cae=True) + assert credential.get_token.call_count == 2 + + +@pytest.mark.asyncio +async def test_cae_does_not_inherit_tenant_from_invalid_cache(): + url = get_random_url() + HttpChallengeCache.set_challenge_for_url( + url, + HttpChallenge(url, 'Bearer authorization="https://authority.net/untrusted", resource=https://other.example'), + ) + credential = Mock(spec_set=["get_token"]) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + response = PipelineResponse( + request.http_request, + Mock( + status_code=401, + headers={ + "WWW-Authenticate": 'Bearer authorization="https://authority.net/", scope=https://vault.azure.net/.default, claims="e30="' + }, + ), + request.context, + ) + with pytest.raises(ValueError): + await AsyncChallengeAuthPolicy(credential).on_challenge(request, response) + credential.get_token.assert_not_called() + assert HttpChallengeCache.get_challenge_for_url(url) is None + + +@pytest.mark.parametrize("redirect_status", [303, 307]) +@pytest.mark.asyncio +async def test_discovery_replay_preserves_redirect_target(redirect_status): + url = get_random_url() + redirect_url = get_random_url() + header = 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, header)) + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("token", time.time() + 3600)) + ) + other = AsyncPipeline( + policies=[AsyncChallengeAuthPolicy(credential)], + transport=Mock( + send=AsyncMock(return_value=Mock(status_code=401, headers={"WWW-Authenticate": "Bearer invalid=1"})) + ), + ) + requests = [] + + async def send(request): + requests.append(request.url) + if len(requests) == 1: + assert (request.method, request.url, request.body) == ("PUT", url, b"payload") + assert request.headers["Authorization"] == "Bearer token" + assert (await other.run(HttpRequest("GET", url + "/other"))).http_response.status_code == 401 + assert HttpChallengeCache.get_challenge_for_url(url) is None + return Mock(status_code=503, headers={}) + if len(requests) == 2: + assert (request.method, request.url) == ("PUT", url) + assert "Authorization" not in request.headers + assert not request.body + return Mock(status_code=redirect_status, headers={"location": redirect_url}) + assert request.url == redirect_url + assert request.method == ("GET" if redirect_status == 303 else "PUT") + if len(requests) == 3: + assert "Authorization" not in request.headers + assert not request.body + return Mock( + status_code=401, + headers={ + "WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' + }, + ) + assert request.headers["Authorization"] == "Bearer token" + assert request.body == (None if redirect_status == 303 else b"payload") + return Mock(status_code=200, headers={}) + + pipeline = AsyncPipeline( + policies=[ + AsyncRetryPolicy(retry_total=1, retry_backoff_factor=0), + AsyncChallengeAuthPolicy(credential), + AsyncRedirectPolicy(), + ], + transport=Mock(send=send), + ) + assert (await pipeline.run(HttpRequest("PUT", url, content=b"payload"))).http_response.status_code == 200 + assert len(requests) == 4 + + +@pytest.mark.parametrize("warm_cache", [False, True]) +@pytest.mark.parametrize("target_cache", [False, True]) +@pytest.mark.parametrize("redirect_status", [303, 307]) +@pytest.mark.asyncio +async def test_discovery_replay_preserves_redirect_headers(warm_cache, target_cache, redirect_status): + request, send, requests = redirect_replay_case(warm_cache, target_cache, redirect_status) + credential = Mock( + spec_set=["get_token"], get_token=AsyncMock(return_value=AccessToken("token", time.time() + 3600)) + ) + pipeline = AsyncPipeline( + policies=[ + AsyncRedirectPolicy(redirect_remove_headers=["x-sensitive"]), + AsyncRetryPolicy(retry_total=1, retry_backoff_factor=0), + AsyncChallengeAuthPolicy(credential), + SensitiveHeaderCleanupPolicy(), + ], + transport=Mock(send=AsyncMock(side_effect=send)), + ) + assert (await pipeline.run(request)).http_response.status_code == 200 + assert len(requests) == 2 + int(warm_cache) + int(not target_cache) diff --git a/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth_cas.py b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth_cas.py new file mode 100644 index 000000000000..e3febcded48d --- /dev/null +++ b/sdk/keyvault/azure-keyvault-secrets/tests/test_challenge_auth_cas.py @@ -0,0 +1,327 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import importlib +import threading +import time +from unittest.mock import AsyncMock, Mock +from urllib.parse import urlparse + +import pytest +from azure.core.credentials import AccessToken, AccessTokenInfo +from azure.core.pipeline import AsyncPipeline, Pipeline, PipelineContext, PipelineRequest, PipelineResponse +from azure.core.pipeline.policies import ( + AsyncRedirectPolicy, + AsyncRetryPolicy, + RedirectPolicy, + RetryPolicy, + SensitiveHeaderCleanupPolicy, +) +from azure.core.rest import HttpRequest +from test_challenge_auth import ( + AsyncChallengeAuthPolicy, + ChallengeAuthPolicy, + HttpChallenge, + HttpChallengeCache, + get_random_url, +) + + +HEADER = 'Bearer authorization="https://authority.net/tenant", resource=https://vault.azure.net' +CLAIMS = 'Bearer authorization="https://authority.net/replacement", scope=https://vault.azure.net/new, claims="e30="' +FLOWS = { + "malformed": ["Bearer"], + "mismatch": ['Bearer authorization="https://authority.net/tenant", resource=https://other.example'], + "missing": [None], + "kv-missing": [HEADER, None], + "cae-missing": [CLAIMS, None], + "kv-cae-missing": [HEADER, CLAIMS, None], + "skipped-kv": [HEADER, HEADER], +} + + +@pytest.mark.parametrize("state", ["unchanged", "replacement", "identical", "absent"]) +@pytest.mark.parametrize("alias", ["path", "case", "default-port"]) +def test_conditional_cache_removal_is_atomic(monkeypatch, state, alias): + url = get_random_url() + expected = HttpChallenge(url, HEADER) + replacement = HttpChallenge(url, HEADER if state == "identical" else HEADER.replace("tenant", "other")) + HttpChallengeCache.set_challenge_for_url(url, expected if state == "unchanged" else replacement) + target = url + "/next" if alias == "path" else url.upper() + if alias == "default-port": + parsed = urlparse(url) + target = f"https://{parsed.netloc.upper()}:443{parsed.path}" + + class RecordingLock: + def __init__(self): + self.lock = threading.Lock() + self.entries = 0 + + def __enter__(self): + self.lock.acquire() + self.entries += 1 + + def __exit__(self, *args): + self.lock.release() + + lock = RecordingLock() + + class LockedCache(dict): + def get(self, *args): + assert lock.lock.locked() + return super().get(*args) + + def pop(self, *args): + assert lock.lock.locked() + return super().pop(*args) + + monkeypatch.setattr(HttpChallengeCache, "_lock", lock) + monkeypatch.setattr(HttpChallengeCache, "_cache", LockedCache(HttpChallengeCache._cache)) + HttpChallengeCache.remove_challenge_for_url_if_matches( + url=target, expected_challenge=None if state == "absent" else expected + ) + assert lock.entries == 1 + assert HttpChallengeCache.get_challenge_for_url(url) is (None if state == "unchanged" else replacement) + HttpChallengeCache.remove_challenge_for_url_if_matches(target, expected) + assert HttpChallengeCache.get_challenge_for_url(url) is (None if state == "unchanged" else replacement) + + +@pytest.mark.parametrize("url", ["", None, "https://host:invalid", "https://[invalid"]) +def test_conditional_cache_removal_validates_url_when_absent(url): + with pytest.raises(ValueError): + HttpChallengeCache.remove_challenge_for_url_if_matches(url, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("token_type", [AccessToken, AccessTokenInfo]) +@pytest.mark.parametrize("warm", [False, True]) +@pytest.mark.parametrize("flow", list(FLOWS)) +@pytest.mark.parametrize("replacement_valid", [False, True]) +async def test_failed_request_preserves_concurrent_challenge(is_async, token_type, warm, flow, replacement_valid): + url = get_random_url() + if warm: + HttpChallengeCache.set_challenge_for_url(url, HttpChallenge(url, HEADER)) + method = "get_token" if token_type == AccessToken else "get_token_info" + token_mock = (AsyncMock if is_async else Mock)(return_value=token_type("token", time.time() + 3600)) + credential = Mock(spec_set=[method], **{method: token_mock}) + policy = (AsyncChallengeAuthPolicy if is_async else ChallengeAuthPolicy)(credential) + if warm: + policy._token = token_type("token", time.time() + 3600) + replacement = HttpChallenge( + url, HEADER if replacement_valid else HEADER.replace("vault.azure.net", "other.example") + ) + pending = FLOWS[flow].copy() + failed = Mock(status_code=401, headers={}) + sent = [] + + def send(request): + sent.append(request) + assert request.body == (b"payload" if warm or len(sent) > 1 else None) + if not warm and len(sent) == 1: + assert "Authorization" not in request.headers + header = pending.pop(0) + if not pending: + HttpChallengeCache.set_challenge_for_url(url, replacement) + failed.headers = {"WWW-Authenticate": header} if header is not None else {} + return failed + return Mock(status_code=401, headers={"WWW-Authenticate": header}) + + transport = Mock(send=AsyncMock(wraps=send) if is_async else Mock(wraps=send)) + pipeline = (AsyncPipeline if is_async else Pipeline)( + policies=[ + AsyncRedirectPolicy() if is_async else RedirectPolicy(), + AsyncRetryPolicy(retry_total=0) if is_async else RetryPolicy(retry_total=0), + policy, + SensitiveHeaderCleanupPolicy(), + ], + transport=transport, + ) + request = HttpRequest("PUT", url, content=b"payload") + if flow == "mismatch": + with pytest.raises(ValueError): + if is_async: + await pipeline.run(request) + else: + pipeline.run(request) + else: + response = await pipeline.run(request) if is_async else pipeline.run(request) + assert response.http_response is failed + assert not pending + assert policy._token is None + assert HttpChallengeCache.get_challenge_for_url(url) is replacement + assert token_mock.call_count == len(FLOWS[flow]) - 1 + if not replacement_valid: + next_request = HttpRequest("PUT", url, content=b"next") + with pytest.raises(ValueError): + if is_async: + await pipeline.run(next_request) + else: + pipeline.run(next_request) + assert HttpChallengeCache.get_challenge_for_url(url) is None + assert "Authorization" not in next_request.headers + assert next_request.body == b"next" + assert len(sent) == len(FLOWS[flow]) + assert token_mock.call_count == len(FLOWS[flow]) - 1 + + +@pytest.mark.parametrize("target", ["path", "case", "default-port", "other-host", "other-port", "other-scheme"]) +def test_eviction_candidate_is_origin_bound(target): + url = get_random_url() + parsed = urlparse(url) + urls = { + "path": url + "/next", + "case": url.upper(), + "default-port": f"https://{parsed.netloc.upper()}:443{parsed.path}", + "other-host": get_random_url(), + "other-port": f"https://{parsed.netloc}:8443{parsed.path}", + "other-scheme": url.replace("https://", "http://"), + } + original = HttpChallenge(url, HEADER) + HttpChallengeCache.set_challenge_for_url(url, original) + policy = ChallengeAuthPolicy(Mock(spec_set=["get_token"])) + policy._token = AccessToken("token", time.time() + 3600) + request = PipelineRequest(HttpRequest("GET", url), PipelineContext(None)) + policy.on_request(request) + request.http_request.url = urls[target] + module = importlib.import_module(ChallengeAuthPolicy.__module__) + assert module._get_challenge_candidate(request) is (None if target.startswith("other") else original) + replacement = HttpChallenge(urls[target], HEADER) + if target.startswith("other"): + HttpChallengeCache.set_challenge_for_url(urls[target], replacement) + response = PipelineResponse(request.http_request, Mock(status_code=401, headers={}), request.context) + assert policy.handle_challenge_flow(request, response) is response + assert policy._token is None + assert HttpChallengeCache.get_challenge_for_url(urls[target]) is ( + replacement if target.startswith("other") else None + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("token_type", [AccessToken, AccessTokenInfo]) +@pytest.mark.parametrize("failure", ["missing", "malformed", "mismatch"]) +async def test_credential_boundary_replacement_survives_failure(is_async, token_type, failure): + url = get_random_url() + replacement = HttpChallenge(url, HEADER.replace("tenant", "other")) + method = "get_token" if token_type == AccessToken else "get_token_info" + + def acquire(*_args, **_kwargs): + assert HttpChallengeCache.get_challenge_for_url(url) is not None + HttpChallengeCache.set_challenge_for_url(url, replacement) + return token_type("token", time.time() + 3600) + + credential = Mock(spec_set=[method], **{method: (AsyncMock if is_async else Mock)(side_effect=acquire)}) + policy = (AsyncChallengeAuthPolicy if is_async else ChallengeAuthPolicy)(credential) + request = PipelineRequest(HttpRequest("PUT", url, content=b"payload"), PipelineContext(None)) + challenge = PipelineResponse( + request.http_request, Mock(status_code=401, headers={"WWW-Authenticate": HEADER}), request.context + ) + if is_async: + await policy.on_request(request) + assert await policy.on_challenge(request, challenge) + else: + policy.on_request(request) + assert policy.on_challenge(request, challenge) + headers = {} if failure == "missing" else {"WWW-Authenticate": FLOWS[failure][0]} + rejected = PipelineResponse(request.http_request, Mock(status_code=401, headers=headers), request.context) + if failure == "mismatch": + with pytest.raises(ValueError): + if is_async: + await policy.handle_challenge_flow(request, rejected) + else: + policy.handle_challenge_flow(request, rejected) + else: + result = ( + await policy.handle_challenge_flow(request, rejected) + if is_async + else policy.handle_challenge_flow(request, rejected) + ) + assert result is rejected + assert policy._token is None + assert request.http_request.body == b"payload" + assert HttpChallengeCache.get_challenge_for_url(url) is replacement + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("path", ["cached-resource", "cached-parse", "fallback-resource", "fallback-parse"]) +@pytest.mark.parametrize("replacement_valid", [False, True]) +async def test_validation_preserves_replacement_of_evaluated_challenge(is_async, path, replacement_valid): + url = get_random_url() + original = HttpChallenge(url, HEADER) + replacement = HttpChallenge( + url, HEADER if replacement_valid else HEADER.replace("vault.azure.net", "other.example") + ) + + def scope(): + HttpChallengeCache.set_challenge_for_url(url, replacement) + return "https://other.example/.default" if path.endswith("resource") else "https://[invalid" + + original.get_scope = Mock(side_effect=scope) + HttpChallengeCache.set_challenge_for_url(url, original) + credential = Mock(spec_set=["get_token", "get_token_info"]) + policy = (AsyncChallengeAuthPolicy if is_async else ChallengeAuthPolicy)(credential) + policy._token = AccessTokenInfo("stale-token", time.time() + 3600) + request = PipelineRequest( + HttpRequest("PUT", url, headers={"Authorization": "Bearer stale-token"}, content=b"payload"), + PipelineContext(None), + ) + with pytest.raises(ValueError): + if path.startswith("cached"): + if is_async: + await policy.on_request(request) + else: + policy.on_request(request) + else: + response = PipelineResponse( + request.http_request, + Mock( + status_code=401, + headers={"WWW-Authenticate": 'Bearer authorization="https://authority.net/", claims="e30="'}, + ), + request.context, + ) + if is_async: + await policy.on_challenge(request, response) + else: + policy.on_challenge(request, response) + assert HttpChallengeCache.get_challenge_for_url(url) is replacement + assert policy._token is None + assert "Authorization" not in request.http_request.headers + assert request.http_request.body == b"payload" + credential.get_token.assert_not_called() + credential.get_token_info.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_retry_resets_eviction_candidate_to_observed_absence(is_async): + url = get_random_url() + original = HttpChallenge(url, HEADER) + HttpChallengeCache.set_challenge_for_url(url, original) + policy = (AsyncChallengeAuthPolicy if is_async else ChallengeAuthPolicy)(Mock(spec_set=["get_token"])) + policy._token = AccessToken("token", time.time() + 3600) + request = PipelineRequest(HttpRequest("PUT", url, content=b"payload"), PipelineContext(None)) + if is_async: + await policy.on_request(request) + else: + policy.on_request(request) + HttpChallengeCache.remove_challenge_for_url(url) + if is_async: + await policy.on_request(request) + else: + policy.on_request(request) + replacement = HttpChallenge(url, HEADER) + HttpChallengeCache.set_challenge_for_url(url, replacement) + response = PipelineResponse(request.http_request, Mock(status_code=401, headers={}), request.context) + result = ( + await policy.handle_challenge_flow(request, response) + if is_async + else policy.handle_challenge_flow(request, response) + ) + assert result is response + assert HttpChallengeCache.get_challenge_for_url(url) is replacement + assert "Authorization" not in request.http_request.headers + assert not request.http_request.body