Skip to content

Commit 17c1e4e

Browse files
mrm9084Copilotavanigupta
authored
[AppConfig] Added Audience Scope support (#44399)
* App Config Audience Scope * pylint updates * Update CHANGELOG.md * Update sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/_utils.py Co-authored-by: Copilot <[email protected]> * Update _utils.py * Update _utils.py * Code review comments * Update _scope.py * fixed cspell * _DEFAULT_SCOPE_SUFFIX usage * case insenstitive * Rename to _credential_scope * pr comments * Update test_credential_scope.py * switching to audience * rename everything to audience * Update CHANGELOG.md * added doc string + test rename * Update sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/_azure_appconfiguration_client.py Co-authored-by: Avani Gupta <[email protected]> * Update _azure_appconfiguration_client_async.py --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Avani Gupta <[email protected]>
1 parent f74cf82 commit 17c1e4e

File tree

6 files changed

+145
-13
lines changed

6 files changed

+145
-13
lines changed

sdk/appconfiguration/azure-appconfiguration/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44

55
### Features Added
66

7+
- Added support for custom authentication audiences via the `audience` keyword argument in `AzureAppConfigurationClient` constructor to enable authentication against sovereign clouds.
8+
79
### Breaking Changes
810

911
### Bugs Fixed
1012

1113
### Other Changes
1214

1315
- Replaced deprecated `datetime.utcnow()` with timezone-aware `datetime.now(timezone.utc)`.
16+
- Improved authentication scope handling to automatically detect and use correct audience URLs for Azure Public Cloud, Azure US Government, and Azure China cloud environments.
1417

1518
## 1.7.2 (2025-10-20)
1619

sdk/appconfiguration/azure-appconfiguration/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "python",
44
"TagPrefix": "python/appconfiguration/azure-appconfiguration",
5-
"Tag": "python/appconfiguration/azure-appconfiguration_710e235678"
5+
"Tag": "python/appconfiguration/azure-appconfiguration_5a7879bd17"
66
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
7+
# Azure Configuration audience URLs
8+
DEFAULT_SCOPE_SUFFIX = ".default"
9+
_AZURE_PUBLIC_CLOUD_AUDIENCE = "https://appconfig.azure.com/"
10+
_AZURE_US_GOVERNMENT_AUDIENCE = "https://appconfig.azure.us/"
11+
_AZURE_CHINA_AUDIENCE = "https://appconfig.azure.cn/"
12+
13+
14+
# Endpoint suffixes for cloud detection
15+
_US_GOVERNMENT_SUFFIX_LEGACY = "azconfig.azure.us" # cspell:disable-line
16+
_US_GOVERNMENT_SUFFIX = "appconfig.azure.us"
17+
_CHINA_SUFFIX_LEGACY = "azconfig.azure.cn" # cspell:disable-line
18+
_CHINA_SUFFIX = "appconfig.azure.cn"
19+
20+
21+
def get_audience(endpoint: str) -> str:
22+
"""
23+
Gets the default audience for the given endpoint.
24+
25+
:param endpoint: The endpoint to get the default audience for.
26+
:type endpoint: str
27+
:return: The default audience for the given endpoint.
28+
:rtype: str
29+
"""
30+
# Normalize endpoint by stripping trailing slashes and converting to lowercase for suffix checks
31+
normalized_endpoint = endpoint.rstrip("/").lower()
32+
if normalized_endpoint.endswith(_US_GOVERNMENT_SUFFIX_LEGACY) or normalized_endpoint.endswith(
33+
_US_GOVERNMENT_SUFFIX
34+
):
35+
return _AZURE_US_GOVERNMENT_AUDIENCE
36+
if normalized_endpoint.endswith(_CHINA_SUFFIX_LEGACY) or normalized_endpoint.endswith(_CHINA_SUFFIX):
37+
return _AZURE_CHINA_AUDIENCE
38+
return _AZURE_PUBLIC_CLOUD_AUDIENCE

sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/_azure_appconfiguration_client.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
ConfigurationSnapshot,
3333
ConfigurationSettingLabel,
3434
)
35+
from ._audience import get_audience, DEFAULT_SCOPE_SUFFIX
3536
from ._utils import (
3637
get_key_filter,
3738
get_label_filter,
@@ -49,6 +50,9 @@ class AzureAppConfigurationClient:
4950
:keyword api_version: Api Version. Default value is "2023-11-01". Note that overriding this default
5051
value may result in unsupported behavior.
5152
:paramtype api_version: str
53+
:keyword audience: The audience to use for authentication with Microsoft Entra. Defaults to the public Azure App
54+
Configuration audience. See the supported audience list at https://aka.ms/appconfig/client-token-audience
55+
:paramtype audience: str
5256
5357
"""
5458

@@ -65,11 +69,9 @@ def __init__(self, base_url: str, credential: TokenCredential, **kwargs: Any) ->
6569

6670
self._sync_token_policy = SyncTokenPolicy()
6771

68-
credential_scopes = kwargs.pop("credential_scopes", [f"{base_url.strip('/')}/.default"])
69-
# Ensure all scopes end with /.default
70-
kwargs["credential_scopes"] = [
71-
scope if scope.endswith("/.default") else f"{scope}/.default" for scope in credential_scopes
72-
]
72+
audience = kwargs.pop("audience", get_audience(base_url))
73+
# Ensure all scopes end with /.default and strip any trailing slashes before adding suffix
74+
kwargs["credential_scopes"] = [audience + DEFAULT_SCOPE_SUFFIX]
7375

7476
if isinstance(credential, AzureKeyCredential):
7577
id_credential = kwargs.pop("id_credential")
@@ -81,7 +83,9 @@ def __init__(self, base_url: str, credential: TokenCredential, **kwargs: Any) ->
8183
elif isinstance(credential, TokenCredential):
8284
kwargs.update(
8385
{
84-
"authentication_policy": BearerTokenCredentialPolicy(credential, *credential_scopes, **kwargs),
86+
"authentication_policy": BearerTokenCredentialPolicy(
87+
credential, *kwargs["credential_scopes"], **kwargs
88+
),
8589
}
8690
)
8791
else:

sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/aio/_azure_appconfiguration_client_async.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
ConfigurationSnapshot,
3636
ConfigurationSettingLabel,
3737
)
38+
from .._audience import get_audience, DEFAULT_SCOPE_SUFFIX
3839
from .._utils import (
3940
get_key_filter,
4041
get_label_filter,
@@ -51,6 +52,10 @@ class AzureAppConfigurationClient:
5152
:keyword api_version: Api Version. Default value is "2023-11-01". Note that overriding this default
5253
value may result in unsupported behavior.
5354
:paramtype api_version: str
55+
:keyword audience: The audience to use for authentication with Microsoft Entra. Defaults to the public Azure App
56+
Configuration audience. See the supported audience list at https://aka.ms/appconfig/client-token-audience
57+
:paramtype audience: str
58+
5459
5560
This is the async version of :class:`~azure.appconfiguration.AzureAppConfigurationClient`
5661
@@ -69,11 +74,9 @@ def __init__(self, base_url: str, credential: AsyncTokenCredential, **kwargs: An
6974

7075
self._sync_token_policy = AsyncSyncTokenPolicy()
7176

72-
credential_scopes = kwargs.pop("credential_scopes", [f"{base_url.strip('/')}/.default"])
73-
# Ensure all scopes end with /.default
74-
kwargs["credential_scopes"] = [
75-
scope if scope.endswith("/.default") else f"{scope}/.default" for scope in credential_scopes
76-
]
77+
audience = kwargs.pop("audience", get_audience(base_url))
78+
# Ensure all scopes end with /.default and strip any trailing slashes before adding suffix
79+
kwargs["credential_scopes"] = [audience + DEFAULT_SCOPE_SUFFIX]
7780

7881
if isinstance(credential, AzureKeyCredential):
7982
id_credential = kwargs.pop("id_credential")
@@ -85,7 +88,9 @@ def __init__(self, base_url: str, credential: AsyncTokenCredential, **kwargs: An
8588
elif hasattr(credential, "get_token"): # AsyncFakeCredential is not an instance of AsyncTokenCredential
8689
kwargs.update(
8790
{
88-
"authentication_policy": AsyncBearerTokenCredentialPolicy(credential, *credential_scopes, **kwargs),
91+
"authentication_policy": AsyncBearerTokenCredentialPolicy(
92+
credential, *kwargs["credential_scopes"], **kwargs
93+
),
8994
}
9095
)
9196
else:
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# --------------------------------------------------------------------------
6+
7+
from azure.appconfiguration._audience import get_audience
8+
9+
# Expected scope constants
10+
_EXPECTED_PUBLIC_CLOUD_AUDIENCE = "https://appconfig.azure.com/"
11+
_EXPECTED_US_GOVERNMENT_AUDIENCE = "https://appconfig.azure.us/"
12+
_EXPECTED_CHINA_AUDIENCE = "https://appconfig.azure.cn/"
13+
14+
15+
class TestGetDefaultScope:
16+
"""Tests for the get_default_scope utility function."""
17+
18+
def test_get_default_scope_public_cloud_legacy(self):
19+
"""Test default scope for Azure public cloud with legacy endpoint."""
20+
endpoint = "https://example1.azconfig.azure.com" # cspell:disable-line
21+
actual_scope = get_audience(endpoint)
22+
assert actual_scope == _EXPECTED_PUBLIC_CLOUD_AUDIENCE
23+
24+
def test_get_default_scope_public_cloud(self):
25+
"""Test default scope for Azure public cloud with regular endpoint."""
26+
endpoint = "https://example1.appconfig.azure.com"
27+
actual_scope = get_audience(endpoint)
28+
assert actual_scope == _EXPECTED_PUBLIC_CLOUD_AUDIENCE
29+
30+
def test_get_default_scope_china_legacy(self):
31+
"""Test default scope for Azure China cloud with legacy endpoint."""
32+
endpoint = "https://example1.azconfig.azure.cn" # cspell:disable-line
33+
actual_scope = get_audience(endpoint)
34+
assert actual_scope == _EXPECTED_CHINA_AUDIENCE
35+
36+
def test_get_default_scope_china(self):
37+
"""Test default scope for Azure China cloud with regular endpoint."""
38+
endpoint = "https://example1.appconfig.azure.cn"
39+
actual_scope = get_audience(endpoint)
40+
assert actual_scope == _EXPECTED_CHINA_AUDIENCE
41+
42+
def test_get_default_scope_us_government_legacy(self):
43+
"""Test default scope for Azure US Government cloud with legacy endpoint."""
44+
endpoint = "https://example1.azconfig.azure.us" # cspell:disable-line
45+
actual_scope = get_audience(endpoint)
46+
assert actual_scope == _EXPECTED_US_GOVERNMENT_AUDIENCE
47+
48+
def test_get_default_scope_us_government(self):
49+
"""Test default scope for Azure US Government cloud with regular endpoint."""
50+
endpoint = "https://example1.appconfig.azure.us"
51+
actual_scope = get_audience(endpoint)
52+
assert actual_scope == _EXPECTED_US_GOVERNMENT_AUDIENCE
53+
54+
def test_default_scope_with_different_subdomain(self):
55+
"""Test that different subdomains still resolve to correct scope."""
56+
endpoint = "https://my-store-123.appconfig.azure.com"
57+
actual_scope = get_audience(endpoint)
58+
assert actual_scope == _EXPECTED_PUBLIC_CLOUD_AUDIENCE
59+
60+
def test_get_default_scope_public_cloud_with_trailing_slash(self):
61+
"""Test default scope for Azure public cloud with trailing slash."""
62+
endpoint = "https://example1.appconfig.azure.com/"
63+
actual_scope = get_audience(endpoint)
64+
assert actual_scope == _EXPECTED_PUBLIC_CLOUD_AUDIENCE
65+
66+
def test_get_default_scope_china_with_trailing_slash(self):
67+
"""Test default scope for Azure China cloud with trailing slash."""
68+
endpoint = "https://example1.appconfig.azure.cn/"
69+
actual_scope = get_audience(endpoint)
70+
assert actual_scope == _EXPECTED_CHINA_AUDIENCE
71+
72+
def test_get_default_scope_us_government_with_trailing_slash(self):
73+
"""Test default scope for Azure US Government cloud with trailing slash."""
74+
endpoint = "https://example1.appconfig.azure.us/"
75+
actual_scope = get_audience(endpoint)
76+
assert actual_scope == _EXPECTED_US_GOVERNMENT_AUDIENCE
77+
78+
def test_get_default_scope_legacy_with_trailing_slash(self):
79+
"""Test default scope for legacy endpoint with trailing slash."""
80+
endpoint = "https://example1.azconfig.azure.us/" # cspell:disable-line
81+
actual_scope = get_audience(endpoint)
82+
assert actual_scope == _EXPECTED_US_GOVERNMENT_AUDIENCE

0 commit comments

Comments
 (0)