Skip to content

Commit 3a27401

Browse files
authored
[Tables] Add multitenant challenge auth policy support (Azure#24278)
1 parent 8947de0 commit 3a27401

15 files changed

Lines changed: 1922 additions & 12 deletions

sdk/tables/azure-data-tables/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Release History
22

3+
## 12.4.0 (2022-05-10)
4+
5+
### Features Added
6+
- Support for multitenant authentication ([#24278](https://github.com/Azure/azure-sdk-for-python/pull/24278))
7+
38
## 12.3.0 (2022-03-10)
49

510
### Bugs Fixed

sdk/tables/azure-data-tables/azure/data/tables/_authentication.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from urlparse import urlparse # type: ignore
1313

1414
from azure.core.exceptions import ClientAuthenticationError
15-
from azure.core.pipeline.policies import SansIOHTTPPolicy
15+
from azure.core.pipeline.policies import BearerTokenCredentialPolicy, SansIOHTTPPolicy
1616

1717
try:
1818
from azure.core.pipeline.transport import AsyncHttpTransport
@@ -33,7 +33,9 @@
3333
)
3434

3535
if TYPE_CHECKING:
36-
from azure.core.pipeline import PipelineRequest # pylint: disable=ungrouped-imports
36+
from typing import Any
37+
from azure.core.credentials import TokenCredential
38+
from azure.core.pipeline import PipelineResponse, PipelineRequest # pylint: disable=ungrouped-imports
3739

3840

3941
class AzureSigningError(ClientAuthenticationError):
@@ -44,6 +46,47 @@ class AzureSigningError(ClientAuthenticationError):
4446
"""
4547

4648

49+
class _HttpChallenge(object): # pylint:disable=too-few-public-methods
50+
"""Represents a parsed HTTP WWW-Authentication Bearer challenge from a server."""
51+
52+
def __init__(self, challenge):
53+
if not challenge:
54+
raise ValueError("Challenge cannot be empty")
55+
56+
self._parameters = {}
57+
58+
# Split the scheme ("Bearer") from the challenge parameters
59+
trimmed_challenge = challenge.strip()
60+
split_challenge = trimmed_challenge.split(" ", 1)
61+
trimmed_challenge = split_challenge[1]
62+
63+
# Split trimmed challenge into name=value pairs; these pairs are expected to be split by either commas or spaces
64+
# Values may be surrounded by quotes, which are stripped here
65+
separator = "," if "," in trimmed_challenge else " "
66+
for item in trimmed_challenge.split(separator):
67+
# Process 'name=value' pairs
68+
comps = item.split("=")
69+
if len(comps) == 2:
70+
key = comps[0].strip(' "')
71+
value = comps[1].strip(' "')
72+
if key:
73+
self._parameters[key] = value
74+
75+
# Challenge must specify authorization or authorization_uri
76+
if not self._parameters or (
77+
"authorization" not in self._parameters and "authorization_uri" not in self._parameters
78+
):
79+
raise ValueError("Invalid challenge parameters. `authorization` or `authorization_uri` must be present.")
80+
81+
authorization_uri = self._parameters.get("authorization") or self._parameters.get("authorization_uri") or ""
82+
# the authorization server URI should look something like https://login.windows.net/tenant-id[/oauth2/authorize]
83+
uri_path = urlparse(authorization_uri).path.lstrip("/")
84+
self.tenant_id = uri_path.split("/")[0] or None
85+
86+
self.scope = self._parameters.get("scope") or ""
87+
self.resource = self._parameters.get("resource") or self._parameters.get("resource_id") or ""
88+
89+
4790
# pylint: disable=no-self-use
4891
class SharedKeyCredentialPolicy(SansIOHTTPPolicy):
4992
def __init__(self, credential, is_emulated=False):
@@ -128,3 +171,60 @@ def _get_canonicalized_resource_query(self, request):
128171
if name == "comp":
129172
return "?comp=" + value
130173
return ""
174+
175+
176+
class BearerTokenChallengePolicy(BearerTokenCredentialPolicy):
177+
"""Adds a bearer token Authorization header to requests, for the tenant provided in authentication challenges.
178+
179+
See https://docs.microsoft.com/azure/active-directory/develop/claims-challenge for documentation on AAD
180+
authentication challenges.
181+
182+
:param credential: The credential.
183+
:type credential: ~azure.core.TokenCredential
184+
:param str scopes: Lets you specify the type of access needed.
185+
:keyword bool discover_tenant: Determines if tenant discovery should be enabled. Defaults to True.
186+
:keyword bool discover_scopes: Determines if scopes from authentication challenges should be provided to token
187+
requests, instead of the scopes given to the policy's constructor, if any are present. Defaults to True.
188+
:raises: :class:`~azure.core.exceptions.ServiceRequestError`
189+
"""
190+
191+
def __init__(
192+
self,
193+
credential: "TokenCredential",
194+
*scopes: str,
195+
discover_tenant: bool = True,
196+
discover_scopes: bool = True,
197+
**kwargs: "Any"
198+
) -> None:
199+
self._discover_tenant = discover_tenant
200+
self._discover_scopes = discover_scopes
201+
super().__init__(credential, *scopes, **kwargs)
202+
203+
def on_challenge(self, request: "PipelineRequest", response: "PipelineResponse") -> bool:
204+
"""Authorize request according to an authentication challenge
205+
206+
This method is called when the resource provider responds 401 with a WWW-Authenticate header.
207+
208+
:param ~azure.core.pipeline.PipelineRequest request: the request which elicited an authentication challenge
209+
:param ~azure.core.pipeline.PipelineResponse response: the resource provider's response
210+
:returns: a bool indicating whether the policy should send the request
211+
"""
212+
if not self._discover_tenant and not self._discover_scopes:
213+
# We can't discover the tenant or use a different scope; the request will fail because it hasn't changed
214+
return False
215+
216+
try:
217+
challenge = _HttpChallenge(response.http_response.headers.get("WWW-Authenticate"))
218+
# azure-identity credentials require an AADv2 scope but the challenge may specify an AADv1 resource
219+
# if no scopes are included in the challenge, challenge.scope and challenge.resource will both be ''
220+
scope = challenge.scope or challenge.resource + "/.default" if self._discover_scopes else self._scopes
221+
if scope == "/.default":
222+
scope = self._scopes
223+
except ValueError:
224+
return False
225+
226+
if self._discover_tenant:
227+
self.authorize_request(request, scope, tenant_id=challenge.tenant_id)
228+
else:
229+
self.authorize_request(request, scope)
230+
return True

sdk/tables/azure-data-tables/azure/data/tables/_base_client.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from azure.core.pipeline.policies import (
2222
RedirectPolicy,
2323
ContentDecodePolicy,
24-
BearerTokenCredentialPolicy,
2524
ProxyPolicy,
2625
DistributedTracingPolicy,
2726
HttpLoggingPolicy,
@@ -46,7 +45,7 @@
4645
_validate_tablename_error
4746
)
4847
from ._models import LocationMode
49-
from ._authentication import SharedKeyCredentialPolicy
48+
from ._authentication import BearerTokenChallengePolicy, SharedKeyCredentialPolicy
5049
from ._policies import (
5150
CosmosPatchTransformPolicy,
5251
StorageHeadersPolicy,
@@ -58,7 +57,7 @@
5857
if TYPE_CHECKING:
5958
from azure.core.credentials import TokenCredential
6059

61-
_SUPPORTED_API_VERSIONS = ["2019-02-02", "2019-07-07"]
60+
_SUPPORTED_API_VERSIONS = ["2019-02-02", "2019-07-07", "2020-12-06"]
6261

6362

6463
def get_api_version(kwargs, default):
@@ -249,7 +248,7 @@ def _configure_policies(self, **kwargs):
249248
def _configure_credential(self, credential):
250249
# type: (Any) -> None
251250
if hasattr(credential, "get_token"):
252-
self._credential_policy = BearerTokenCredentialPolicy( # type: ignore
251+
self._credential_policy = BearerTokenChallengePolicy( # type: ignore
253252
credential, STORAGE_OAUTH_SCOPE
254253
)
255254
elif isinstance(credential, SharedKeyCredentialPolicy):

sdk/tables/azure-data-tables/azure/data/tables/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
# license information.
55
# --------------------------------------------------------------------------
66

7-
VERSION = "12.3.0"
7+
VERSION = "12.4.0"
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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 typing import TYPE_CHECKING
8+
9+
from azure.core.pipeline.policies import AsyncBearerTokenCredentialPolicy
10+
11+
from .._authentication import _HttpChallenge
12+
13+
if TYPE_CHECKING:
14+
from typing import Any
15+
from azure.core.credentials_async import AsyncTokenCredential
16+
from azure.core.pipeline import PipelineResponse, PipelineRequest # pylint: disable=ungrouped-imports
17+
18+
19+
class AsyncBearerTokenChallengePolicy(AsyncBearerTokenCredentialPolicy):
20+
"""Adds a bearer token Authorization header to requests, for the tenant provided in authentication challenges.
21+
22+
See https://docs.microsoft.com/azure/active-directory/develop/claims-challenge for documentation on AAD
23+
authentication challenges.
24+
25+
:param credential: The credential.
26+
:type credential: ~azure.core.TokenCredential
27+
:param str scopes: Lets you specify the type of access needed.
28+
:keyword bool discover_tenant: Determines if tenant discovery should be enabled. Defaults to True.
29+
:keyword bool discover_scopes: Determines if scopes from authentication challenges should be provided to token
30+
requests, instead of the scopes given to the policy's constructor, if any are present. Defaults to True.
31+
:raises: :class:`~azure.core.exceptions.ServiceRequestError`
32+
"""
33+
34+
def __init__(
35+
self,
36+
credential: "AsyncTokenCredential",
37+
*scopes: str,
38+
discover_tenant: bool = True,
39+
discover_scopes: bool = True,
40+
**kwargs: "Any"
41+
) -> None:
42+
self._discover_tenant = discover_tenant
43+
self._discover_scopes = discover_scopes
44+
super().__init__(credential, *scopes, **kwargs)
45+
46+
async def on_challenge(self, request: "PipelineRequest", response: "PipelineResponse") -> bool:
47+
"""Authorize request according to an authentication challenge
48+
49+
This method is called when the resource provider responds 401 with a WWW-Authenticate header.
50+
51+
:param ~azure.core.pipeline.PipelineRequest request: the request which elicited an authentication challenge
52+
:param ~azure.core.pipeline.PipelineResponse response: the resource provider's response
53+
:returns: a bool indicating whether the policy should send the request
54+
"""
55+
if not self._discover_tenant and not self._discover_scopes:
56+
# We can't discover the tenant or use a different scope; the request will fail because it hasn't changed
57+
return False
58+
59+
try:
60+
challenge = _HttpChallenge(response.http_response.headers.get("WWW-Authenticate"))
61+
# azure-identity credentials require an AADv2 scope but the challenge may specify an AADv1 resource
62+
# if no scopes are included in the challenge, challenge.scope and challenge.resource will both be ''
63+
scope = challenge.scope or challenge.resource + "/.default" if self._discover_scopes else self._scopes
64+
if scope == "/.default":
65+
scope = self._scopes
66+
except ValueError:
67+
return False
68+
69+
if self._discover_tenant:
70+
await self.authorize_request(request, scope, tenant_id=challenge.tenant_id)
71+
else:
72+
await self.authorize_request(request, scope)
73+
return True

sdk/tables/azure-data-tables/azure/data/tables/aio/_base_client_async.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from azure.core.credentials import AzureSasCredential, AzureNamedKeyCredential
1111
from azure.core.pipeline.policies import (
1212
ContentDecodePolicy,
13-
AsyncBearerTokenCredentialPolicy,
1413
AsyncRedirectPolicy,
1514
DistributedTracingPolicy,
1615
HttpLoggingPolicy,
@@ -26,6 +25,7 @@
2625
HttpRequest,
2726
)
2827

28+
from ._authentication_async import AsyncBearerTokenChallengePolicy
2929
from .._generated.aio import AzureTable
3030
from .._base_client import AccountHostsMixin, get_api_version, extract_batch_part_metadata
3131
from .._authentication import SharedKeyCredentialPolicy
@@ -78,7 +78,7 @@ async def close(self) -> None:
7878
def _configure_credential(self, credential):
7979
# type: (Any) -> None
8080
if hasattr(credential, "get_token"):
81-
self._credential_policy = AsyncBearerTokenCredentialPolicy( # type: ignore
81+
self._credential_policy = AsyncBearerTokenChallengePolicy( # type: ignore
8282
credential, STORAGE_OAUTH_SCOPE
8383
)
8484
elif isinstance(credential, SharedKeyCredentialPolicy):

sdk/tables/azure-data-tables/tests/_shared/asynctestcase.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class AsyncFakeTokenCredential(object):
3434
def __init__(self):
3535
self.token = AccessToken("YOU SHALL NOT PASS", 0)
3636

37-
async def get_token(self, *args):
37+
async def get_token(self, *args, **kwargs):
3838
return self.token
3939

4040

sdk/tables/azure-data-tables/tests/_shared/testcase.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class FakeTokenCredential(object):
5151
def __init__(self):
5252
self.token = AccessToken("YOU SHALL NOT PASS", 0)
5353

54-
def get_token(self, *args):
54+
def get_token(self, *args, **kwargs):
5555
return self.token
5656

5757

sdk/tables/azure-data-tables/tests/conftest.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
# IN THE SOFTWARE.
2424
#
2525
# --------------------------------------------------------------------------
26+
import os
27+
2628
import pytest
27-
from devtools_testutils import add_general_regex_sanitizer, test_proxy
29+
from devtools_testutils import add_general_regex_sanitizer, add_body_key_sanitizer, test_proxy
2830

2931
# fixture needs to be visible from conftest
3032

@@ -42,3 +44,11 @@ def add_sanitizers(test_proxy):
4244
regex="batch[a-z]*_([0-9a-f]{8}\\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\\b[0-9a-f]{12}\\b)",
4345
group_for_replace="1",
4446
)
47+
# sanitizes tenant ID
48+
tenant_id = os.environ.get("TABLES_TENANT_ID", "00000000-0000-0000-0000-000000000000")
49+
add_general_regex_sanitizer(value="00000000-0000-0000-0000-000000000000", regex=tenant_id)
50+
# sanitizes tenant ID used in test_challenge_auth(_async).py tests
51+
challenge_tenant_id = os.environ.get("CHALLENGE_TABLES_TENANT_ID", "00000000-0000-0000-0000-000000000000")
52+
add_general_regex_sanitizer(value="00000000-0000-0000-0000-000000000000", regex=challenge_tenant_id)
53+
# sanitizes access tokens in response bodies
54+
add_body_key_sanitizer(json_path="$..access_token", value="access_token")

0 commit comments

Comments
 (0)