Skip to content

Commit bbcc72f

Browse files
[ServiceBus] Support SAS token-via-connection-string auth, and remove ServiceBusSharedKeyCredential export (#13627)
- Remove public documentation and exports of ServiceBusSharedKeyCredential until we chose to release it across all languages. - Support for Sas Token connection strings (tests, etc) - Add safety net for if signature and key are both provided in connstr (inspired by .nets approach) Co-authored-by: Rakshith Bhyravabhotla <[email protected]>
1 parent d91e0f5 commit bbcc72f

24 files changed

+290
-93
lines changed

sdk/servicebus/azure-servicebus/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* `renew_lock()` now returns the UTC datetime that the lock is set to expire at.
88
* `receive_deferred_messages()` can now take a single sequence number as well as a list of sequence numbers.
99
* Messages can now be sent twice in succession.
10+
* Connection strings used with `from_connection_string` methods now support using the `SharedAccessSignature` key in leiu of `sharedaccesskey` and `sharedaccesskeyname`, taking the string of the properly constructed token as value.
1011
* Internal AMQP message properties (header, footer, annotations, properties, etc) are now exposed via `Message.amqp_message`
1112

1213
**Breaking Changes**
@@ -31,6 +32,7 @@
3132
* Remove `support_ordering` from `create_queue` and `QueueProperties`
3233
* Remove `enable_subscription_partitioning` from `create_topic` and `TopicProperties`
3334
* `get_dead_letter_[queue,subscription]_receiver()` has been removed. To connect to a dead letter queue, utilize the `sub_queue` parameter of `get_[queue,subscription]_receiver()` provided with a value from the `SubQueue` enum
35+
* No longer export `ServiceBusSharedKeyCredential`
3436
* Rename `entity_availability_status` to `availability_status`
3537

3638
## 7.0.0b5 (2020-08-10)

sdk/servicebus/azure-servicebus/azure/servicebus/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from ._servicebus_receiver import ServiceBusReceiver
1414
from ._servicebus_session_receiver import ServiceBusSessionReceiver
1515
from ._servicebus_session import ServiceBusSession
16-
from ._base_handler import ServiceBusSharedKeyCredential
1716
from ._common.message import Message, BatchMessage, PeekedMessage, ReceivedMessage
1817
from ._common.constants import ReceiveMode, SubQueue
1918
from ._common.auto_lock_renewer import AutoLockRenew
@@ -32,7 +31,6 @@
3231
'ServiceBusSessionReceiver',
3332
'ServiceBusSession',
3433
'ServiceBusSender',
35-
'ServiceBusSharedKeyCredential',
3634
'TransportType',
3735
'AutoLockRenew'
3836
]

sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import uuid
88
import time
99
from datetime import timedelta
10-
from typing import cast, Optional, Tuple, TYPE_CHECKING, Dict, Any, Callable, Type
10+
from typing import cast, Optional, Tuple, TYPE_CHECKING, Dict, Any, Callable
1111

1212
try:
1313
from urllib import quote_plus # type: ignore
@@ -18,6 +18,8 @@
1818
from uamqp import utils
1919
from uamqp.message import MessageProperties
2020

21+
from azure.core.credentials import AccessToken
22+
2123
from ._common._configuration import Configuration
2224
from .exceptions import (
2325
ServiceBusError,
@@ -41,11 +43,13 @@
4143

4244

4345
def _parse_conn_str(conn_str):
44-
# type: (str) -> Tuple[str, str, str, str]
46+
# type: (str) -> Tuple[str, Optional[str], Optional[str], str, Optional[str], Optional[int]]
4547
endpoint = None
4648
shared_access_key_name = None
4749
shared_access_key = None
4850
entity_path = None # type: Optional[str]
51+
shared_access_signature = None # type: Optional[str]
52+
shared_access_signature_expiry = None # type: Optional[int]
4953
for element in conn_str.split(";"):
5054
key, _, value = element.partition("=")
5155
if key.lower() == "endpoint":
@@ -58,18 +62,35 @@ def _parse_conn_str(conn_str):
5862
shared_access_key = value
5963
elif key.lower() == "entitypath":
6064
entity_path = value
61-
if not all([endpoint, shared_access_key_name, shared_access_key]):
65+
elif key.lower() == "sharedaccesssignature":
66+
shared_access_signature = value
67+
try:
68+
# Expiry can be stored in the "se=<timestamp>" clause of the token. ('&'-separated key-value pairs)
69+
# type: ignore
70+
shared_access_signature_expiry = int(shared_access_signature.split('se=')[1].split('&')[0])
71+
except (IndexError, TypeError, ValueError): # Fallback since technically expiry is optional.
72+
# An arbitrary, absurdly large number, since you can't renew.
73+
shared_access_signature_expiry = int(time.time() * 2)
74+
if not (all((endpoint, shared_access_key_name, shared_access_key)) or all((endpoint, shared_access_signature))) \
75+
or all((shared_access_key_name, shared_access_signature)): # this latter clause since we don't accept both
6276
raise ValueError(
6377
"Invalid connection string. Should be in the format: "
6478
"Endpoint=sb://<FQDN>/;SharedAccessKeyName=<KeyName>;SharedAccessKey=<KeyValue>"
79+
"\nWith alternate option of providing SharedAccessSignature instead of SharedAccessKeyName and Key"
6580
)
6681
entity = cast(str, entity_path)
6782
left_slash_pos = cast(str, endpoint).find("//")
6883
if left_slash_pos != -1:
6984
host = cast(str, endpoint)[left_slash_pos + 2:]
7085
else:
7186
host = str(endpoint)
72-
return host, str(shared_access_key_name), str(shared_access_key), entity
87+
88+
return (host,
89+
str(shared_access_key_name) if shared_access_key_name else None,
90+
str(shared_access_key) if shared_access_key else None,
91+
entity,
92+
str(shared_access_signature) if shared_access_signature else None,
93+
shared_access_signature_expiry)
7394

7495

7596
def _generate_sas_token(uri, policy, key, expiry=None):
@@ -90,29 +111,27 @@ def _generate_sas_token(uri, policy, key, expiry=None):
90111
return _AccessToken(token=token, expires_on=abs_expiry)
91112

92113

93-
def _convert_connection_string_to_kwargs(conn_str, shared_key_credential_type, **kwargs):
94-
# type: (str, Type, Any) -> Dict[str, Any]
95-
host, policy, key, entity_in_conn_str = _parse_conn_str(conn_str)
96-
queue_name = kwargs.get("queue_name")
97-
topic_name = kwargs.get("topic_name")
98-
if not (queue_name or topic_name or entity_in_conn_str):
99-
raise ValueError("Entity name is missing. Please specify `queue_name` or `topic_name`"
100-
" or use a connection string including the entity information.")
101-
102-
if queue_name and topic_name:
103-
raise ValueError("`queue_name` and `topic_name` can not be specified simultaneously.")
104-
105-
entity_in_kwargs = queue_name or topic_name
106-
if entity_in_conn_str and entity_in_kwargs and (entity_in_conn_str != entity_in_kwargs):
107-
raise ServiceBusAuthenticationError(
108-
"Entity names do not match, the entity name in connection string is {};"
109-
" the entity name in parameter is {}.".format(entity_in_conn_str, entity_in_kwargs)
110-
)
114+
class ServiceBusSASTokenCredential(object):
115+
"""The shared access token credential used for authentication.
116+
:param str token: The shared access token string
117+
:param int expiry: The epoch timestamp
118+
"""
119+
def __init__(self, token, expiry):
120+
# type: (str, int) -> None
121+
"""
122+
:param str token: The shared access token string
123+
:param float expiry: The epoch timestamp
124+
"""
125+
self.token = token
126+
self.expiry = expiry
127+
self.token_type = b"servicebus.windows.net:sastoken"
111128

112-
kwargs["fully_qualified_namespace"] = host
113-
kwargs["entity_name"] = entity_in_conn_str or entity_in_kwargs
114-
kwargs["credential"] = shared_key_credential_type(policy, key)
115-
return kwargs
129+
def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
130+
# type: (str, Any) -> AccessToken
131+
"""
132+
This method is automatically called when token is about to expire.
133+
"""
134+
return AccessToken(self.token, self.expiry)
116135

117136

118137
class ServiceBusSharedKeyCredential(object):
@@ -158,6 +177,41 @@ def __init__(
158177
self._auth_uri = None
159178
self._properties = create_properties(self._config.user_agent)
160179

180+
@classmethod
181+
def _convert_connection_string_to_kwargs(cls, conn_str, **kwargs):
182+
# type: (str, Any) -> Dict[str, Any]
183+
host, policy, key, entity_in_conn_str, token, token_expiry = _parse_conn_str(conn_str)
184+
queue_name = kwargs.get("queue_name")
185+
topic_name = kwargs.get("topic_name")
186+
if not (queue_name or topic_name or entity_in_conn_str):
187+
raise ValueError("Entity name is missing. Please specify `queue_name` or `topic_name`"
188+
" or use a connection string including the entity information.")
189+
190+
if queue_name and topic_name:
191+
raise ValueError("`queue_name` and `topic_name` can not be specified simultaneously.")
192+
193+
entity_in_kwargs = queue_name or topic_name
194+
if entity_in_conn_str and entity_in_kwargs and (entity_in_conn_str != entity_in_kwargs):
195+
raise ServiceBusAuthenticationError(
196+
"Entity names do not match, the entity name in connection string is {};"
197+
" the entity name in parameter is {}.".format(entity_in_conn_str, entity_in_kwargs)
198+
)
199+
200+
kwargs["fully_qualified_namespace"] = host
201+
kwargs["entity_name"] = entity_in_conn_str or entity_in_kwargs
202+
# This has to be defined seperately to support sync vs async credentials.
203+
kwargs["credential"] = cls._create_credential_from_connection_string_parameters(token,
204+
token_expiry,
205+
policy,
206+
key)
207+
return kwargs
208+
209+
@classmethod
210+
def _create_credential_from_connection_string_parameters(cls, token, token_expiry, policy, key):
211+
if token and token_expiry:
212+
return ServiceBusSASTokenCredential(token, token_expiry)
213+
return ServiceBusSharedKeyCredential(policy, key)
214+
161215
def __enter__(self):
162216
self._open_with_retry()
163217
return self

sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import logging
1010
import functools
1111
import platform
12-
from typing import Optional, Dict
12+
import time
13+
from typing import Optional, Dict, Tuple
1314
try:
1415
from urlparse import urlparse
1516
except ImportError:
@@ -63,11 +64,15 @@ def utc_now():
6364
return datetime.datetime.now(tz=TZ_UTC)
6465

6566

67+
# This parse_conn_str is used for mgmt, the other in base_handler for handlers. Should be unified.
6668
def parse_conn_str(conn_str):
67-
endpoint = None
68-
shared_access_key_name = None
69-
shared_access_key = None
70-
entity_path = None
69+
# type: (str) -> Tuple[str, Optional[str], Optional[str], str, Optional[str], Optional[int]]
70+
endpoint = ''
71+
shared_access_key_name = None # type: Optional[str]
72+
shared_access_key = None # type: Optional[str]
73+
entity_path = ''
74+
shared_access_signature = None # type: Optional[str]
75+
shared_access_signature_expiry = None # type: Optional[int]
7176
for element in conn_str.split(';'):
7277
key, _, value = element.partition('=')
7378
if key.lower() == 'endpoint':
@@ -78,9 +83,28 @@ def parse_conn_str(conn_str):
7883
shared_access_key = value
7984
elif key.lower() == 'entitypath':
8085
entity_path = value
81-
if not all([endpoint, shared_access_key_name, shared_access_key]):
82-
raise ValueError("Invalid connection string")
83-
return endpoint, shared_access_key_name, shared_access_key, entity_path
86+
elif key.lower() == "sharedaccesssignature":
87+
shared_access_signature = value
88+
try:
89+
# Expiry can be stored in the "se=<timestamp>" clause of the token. ('&'-separated key-value pairs)
90+
# type: ignore
91+
shared_access_signature_expiry = int(shared_access_signature.split('se=')[1].split('&')[0])
92+
except (IndexError, TypeError, ValueError): # Fallback since technically expiry is optional.
93+
# An arbitrary, absurdly large number, since you can't renew.
94+
shared_access_signature_expiry = int(time.time() * 2)
95+
if not (all((endpoint, shared_access_key_name, shared_access_key)) or all((endpoint, shared_access_signature))) \
96+
or all((shared_access_key_name, shared_access_signature)): # this latter clause since we don't accept both
97+
raise ValueError(
98+
"Invalid connection string. Should be in the format: "
99+
"Endpoint=sb://<FQDN>/;SharedAccessKeyName=<KeyName>;SharedAccessKey=<KeyValue>"
100+
"\nWith alternate option of providing SharedAccessSignature instead of SharedAccessKeyName and Key"
101+
)
102+
return (endpoint,
103+
str(shared_access_key_name) if shared_access_key_name else None,
104+
str(shared_access_key) if shared_access_key else None,
105+
entity_path,
106+
str(shared_access_signature) if shared_access_signature else None,
107+
shared_access_signature_expiry)
84108

85109

86110
def build_uri(address, entity):

sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_client.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import uamqp
99

10-
from ._base_handler import _parse_conn_str, ServiceBusSharedKeyCredential, BaseHandler
10+
from ._base_handler import (
11+
_parse_conn_str,
12+
ServiceBusSharedKeyCredential,
13+
ServiceBusSASTokenCredential,
14+
BaseHandler)
1115
from ._servicebus_sender import ServiceBusSender
1216
from ._servicebus_receiver import ServiceBusReceiver
1317
from ._servicebus_session_receiver import ServiceBusSessionReceiver
@@ -33,8 +37,8 @@ class ServiceBusClient(object):
3337
The namespace format is: `<yournamespace>.servicebus.windows.net`.
3438
:param ~azure.core.credentials.TokenCredential credential: The credential object used for authentication which
3539
implements a particular interface for getting tokens. It accepts
36-
:class:`ServiceBusSharedKeyCredential<azure.servicebus.ServiceBusSharedKeyCredential>`, or credential objects
37-
generated by the azure-identity library and objects that implement the `get_token(self, *scopes)` method.
40+
credential objects generated by the azure-identity library and objects that implement the
41+
`get_token(self, *scopes)` method.
3842
:keyword bool logging_enable: Whether to output network trace logs to the logger. Default is `False`.
3943
:keyword transport_type: The type of transport protocol that will be used for communicating with
4044
the Service Bus service. Default is `TransportType.Amqp`.
@@ -153,11 +157,15 @@ def from_connection_string(
153157
:caption: Create a new instance of the ServiceBusClient from connection string.
154158
155159
"""
156-
host, policy, key, entity_in_conn_str = _parse_conn_str(conn_str)
160+
host, policy, key, entity_in_conn_str, token, token_expiry = _parse_conn_str(conn_str)
161+
if token and token_expiry:
162+
credential = ServiceBusSASTokenCredential(token, token_expiry)
163+
elif policy and key:
164+
credential = ServiceBusSharedKeyCredential(policy, key) # type: ignore
157165
return cls(
158166
fully_qualified_namespace=host,
159167
entity_name=entity_in_conn_str or kwargs.pop("entity_name", None),
160-
credential=ServiceBusSharedKeyCredential(policy, key), # type: ignore
168+
credential=credential, # type: ignore
161169
**kwargs
162170
)
163171

sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_receiver.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from uamqp.constants import SenderSettleMode
1414
from uamqp.authentication.common import AMQPAuth
1515

16-
from ._base_handler import BaseHandler, ServiceBusSharedKeyCredential, _convert_connection_string_to_kwargs
16+
from ._base_handler import BaseHandler
1717
from ._common.utils import create_authentication
1818
from ._common.message import PeekedMessage, ReceivedMessage
1919
from ._common.constants import (
@@ -56,8 +56,8 @@ class ServiceBusReceiver(BaseHandler, ReceiverMixin): # pylint: disable=too-man
5656
The namespace format is: `<yournamespace>.servicebus.windows.net`.
5757
:param ~azure.core.credentials.TokenCredential credential: The credential object used for authentication which
5858
implements a particular interface for getting tokens. It accepts
59-
:class:`ServiceBusSharedKeyCredential<azure.servicebus.ServiceBusSharedKeyCredential>`, or credential objects
60-
generated by the azure-identity library and objects that implement the `get_token(self, *scopes)` method.
59+
:class: credential objects generated by the azure-identity library and objects that implement the
60+
`get_token(self, *scopes)` method.
6161
:keyword str queue_name: The path of specific Service Bus Queue the client connects to.
6262
:keyword str topic_name: The path of specific Service Bus Topic which contains the Subscription
6363
the client connects to.
@@ -363,9 +363,8 @@ def from_connection_string(
363363
:caption: Create a new instance of the ServiceBusReceiver from connection string.
364364
365365
"""
366-
constructor_args = _convert_connection_string_to_kwargs(
366+
constructor_args = cls._convert_connection_string_to_kwargs(
367367
conn_str,
368-
ServiceBusSharedKeyCredential,
369368
**kwargs
370369
)
371370
if kwargs.get("queue_name") and kwargs.get("subscription_name"):

sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_sender.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from uamqp import SendClient, types
1212
from uamqp.authentication.common import AMQPAuth
1313

14-
from ._base_handler import BaseHandler, ServiceBusSharedKeyCredential, _convert_connection_string_to_kwargs
14+
from ._base_handler import BaseHandler
1515
from ._common import mgmt_handlers
1616
from ._common.message import Message, BatchMessage
1717
from .exceptions import (
@@ -97,8 +97,8 @@ class ServiceBusSender(BaseHandler, SenderMixin):
9797
The namespace format is: `<yournamespace>.servicebus.windows.net`.
9898
:param ~azure.core.credentials.TokenCredential credential: The credential object used for authentication which
9999
implements a particular interface for getting tokens. It accepts
100-
:class:`ServiceBusSharedKeyCredential<azure.servicebus.ServiceBusSharedKeyCredential>`, or credential objects
101-
generated by the azure-identity library and objects that implement the `get_token(self, *scopes)` method.
100+
:class: credential objects generated by the azure-identity library and objects that implement the
101+
`get_token(self, *scopes)` method.
102102
:keyword str queue_name: The path of specific Service Bus Queue the client connects to.
103103
:keyword str topic_name: The path of specific Service Bus Topic the client connects to.
104104
:keyword bool logging_enable: Whether to output network trace logs to the logger. Default is `False`.
@@ -293,9 +293,8 @@ def from_connection_string(
293293
:caption: Create a new instance of the ServiceBusSender from connection string.
294294
295295
"""
296-
constructor_args = _convert_connection_string_to_kwargs(
296+
constructor_args = cls._convert_connection_string_to_kwargs(
297297
conn_str,
298-
ServiceBusSharedKeyCredential,
299298
**kwargs
300299
)
301300
return cls(**constructor_args)

sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_session_receiver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ class ServiceBusSessionReceiver(ServiceBusReceiver, SessionReceiverMixin):
3333
The namespace format is: `<yournamespace>.servicebus.windows.net`.
3434
:param ~azure.core.credentials.TokenCredential credential: The credential object used for authentication which
3535
implements a particular interface for getting tokens. It accepts
36-
:class:`ServiceBusSharedKeyCredential<azure.servicebus.ServiceBusSharedKeyCredential>`, or credential objects
37-
generated by the azure-identity library and objects that implement the `get_token(self, *scopes)` method.
36+
:class: credential objects generated by the azure-identity library and objects that implement the
37+
`get_token(self, *scopes)` method.
3838
:keyword str queue_name: The path of specific Service Bus Queue the client connects to.
3939
:keyword str topic_name: The path of specific Service Bus Topic which contains the Subscription
4040
the client connects to.

sdk/servicebus/azure-servicebus/azure/servicebus/aio/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
# license information.
55
# -------------------------------------------------------------------------
66
from ._async_message import ReceivedMessage
7-
from ._base_handler_async import ServiceBusSharedKeyCredential
87
from ._servicebus_sender_async import ServiceBusSender
98
from ._servicebus_receiver_async import ServiceBusReceiver
109
from ._servicebus_session_receiver_async import ServiceBusSessionReceiver
@@ -19,6 +18,5 @@
1918
'ServiceBusReceiver',
2019
'ServiceBusSessionReceiver',
2120
'ServiceBusSession',
22-
'ServiceBusSharedKeyCredential',
2321
'AutoLockRenew'
2422
]

0 commit comments

Comments
 (0)