Skip to content

Commit

Permalink
[Service Bus Client] Connection String SAS Support (#14806)
Browse files Browse the repository at this point in the history
The focus of these changes is to add support for a precomputed shared
access signature token to be used as part of the connection string.
  • Loading branch information
jsquire authored Sep 3, 2020
1 parent 0972863 commit 596ab9b
Show file tree
Hide file tree
Showing 12 changed files with 222 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ public override AccessToken GetToken(
TokenRequestContext requestContext,
CancellationToken cancellationToken)
{
if (SharedAccessSignature.SignatureExpiration <= DateTimeOffset.UtcNow.Add(SignatureRefreshBuffer))
// If the signature was derived from a shared key rather than being provided externally,
// determine if the expiration is approaching and attempt to extend the token.

if ((!string.IsNullOrEmpty(SharedAccessSignature.SharedAccessKey))
&& (SharedAccessSignature.SignatureExpiration <= DateTimeOffset.UtcNow.Add(SignatureRefreshBuffer)))
{
lock (SignatureSyncRoot)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ internal static class ConnectionStringParser
/// <summary>The token that identifies the value of a shared access key.</summary>
private const string SharedAccessKeyValueToken = "SharedAccessKey";

/// <summary>The token that identifies the value of a shared access signature.</summary>
private const string SharedAccessSignatureToken = "SharedAccessSignature";

/// <summary>
/// Parses the specified Service Bus connection string into its component properties.
/// </summary>
Expand Down Expand Up @@ -61,7 +64,8 @@ public static ConnectionStringProperties Parse(string connectionString)
EndpointToken: default(UriBuilder),
EntityNameToken: default(string),
SharedAccessKeyNameToken: default(string),
SharedAccessKeyValueToken: default(string)
SharedAccessKeyValueToken: default(string),
SharedAccessSignatureToken: default(string)
);

while (currentPosition != -1)
Expand Down Expand Up @@ -131,6 +135,10 @@ public static ConnectionStringProperties Parse(string connectionString)
{
parsedValues.SharedAccessKeyValueToken = value;
}
else if (string.Compare(SharedAccessSignatureToken, token, StringComparison.OrdinalIgnoreCase) == 0)
{
parsedValues.SharedAccessSignatureToken = value;
}
}
else if ((slice.Length != 1) || (slice[0] != TokenValuePairDelimiter))
{
Expand All @@ -149,7 +157,8 @@ public static ConnectionStringProperties Parse(string connectionString)
parsedValues.EndpointToken?.Uri,
parsedValues.EntityNameToken,
parsedValues.SharedAccessKeyNameToken,
parsedValues.SharedAccessKeyValueToken
parsedValues.SharedAccessKeyValueToken,
parsedValues.SharedAccessSignatureToken
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ internal struct ConnectionStringProperties
///
public string SharedAccessKey { get; }

/// <summary>
/// The value of the fully-formed shared access signature, either for the Service Bus
/// namespace or the Service Bus entity.
/// </summary>
///
public string SharedAccessSignature { get; }

/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStringProperties"/> structure.
/// </summary>
Expand All @@ -48,17 +55,20 @@ internal struct ConnectionStringProperties
/// <param name="entityName">The name of the specific Service Bus entity under the namespace.</param>
/// <param name="sharedAccessKeyName">The name of the shared access key, to use authorization.</param>
/// <param name="sharedAccessKey">The shared access key to use for authorization.</param>
/// <param name="sharedAccessSignature">The precomputed shared access signature to use for authorization.</param>
///
public ConnectionStringProperties(
Uri endpoint,
string entityName,
string sharedAccessKeyName,
string sharedAccessKey)
string sharedAccessKey,
string sharedAccessSignature)
{
Endpoint = endpoint;
EntityPath = entityName;
SharedAccessKeyName = sharedAccessKeyName;
SharedAccessKey = sharedAccessKey;
SharedAccessSignature = sharedAccessSignature;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,33 +83,34 @@ internal ServiceBusConnection(
ServiceBusClientOptions options)
{
Argument.AssertNotNullOrEmpty(connectionString, nameof(connectionString));

ValidateConnectionOptions(options);
ConnectionStringProperties connectionStringProperties = ConnectionStringParser.Parse(connectionString);

if (string.IsNullOrEmpty(connectionStringProperties.Endpoint?.Host)
|| string.IsNullOrEmpty(connectionStringProperties.SharedAccessKeyName)
|| string.IsNullOrEmpty(connectionStringProperties.SharedAccessKey))
{
throw new ArgumentException(Resources.MissingConnectionInformation, nameof(connectionString));
}
var connectionStringProperties = ConnectionStringParser.Parse(connectionString);
ValidateConnectionStringProperties(connectionStringProperties, nameof(connectionString));

FullyQualifiedNamespace = connectionStringProperties.Endpoint.Host;
TransportType = options.TransportType;
EntityPath = connectionStringProperties.EntityPath;
RetryOptions = options.RetryOptions;

var sharedAccessSignature = new SharedAccessSignature
(
BuildAudienceResource(options.TransportType, FullyQualifiedNamespace, EntityPath),
connectionStringProperties.SharedAccessKeyName,
connectionStringProperties.SharedAccessKey
);
SharedAccessSignature sharedAccessSignature;

if (string.IsNullOrEmpty(connectionStringProperties.SharedAccessSignature))
{
sharedAccessSignature = new SharedAccessSignature(
BuildConnectionResource(options.TransportType, FullyQualifiedNamespace, EntityPath),
connectionStringProperties.SharedAccessKeyName,
connectionStringProperties.SharedAccessKey);
}
else
{
sharedAccessSignature = new SharedAccessSignature(connectionStringProperties.SharedAccessSignature);
}

var sharedCredential = new SharedAccessSignatureCredential(sharedAccessSignature);
var tokenCredential = new ServiceBusTokenCredential(
sharedCredential,
BuildAudienceResource(TransportType, FullyQualifiedNamespace, EntityPath));
BuildConnectionResource(TransportType, FullyQualifiedNamespace, EntityPath));
#pragma warning disable CA2214 // Do not call overridable methods in constructors. This internal method is virtual for testing purposes.
_innerClient = CreateTransportClient(tokenCredential, options);
#pragma warning restore CA2214 // Do not call overridable methods in constructors
Expand Down Expand Up @@ -137,11 +138,11 @@ internal ServiceBusConnection(
break;

case ServiceBusSharedKeyCredential sharedKeyCredential:
credential = sharedKeyCredential.AsSharedAccessSignatureCredential(BuildAudienceResource(options.TransportType, fullyQualifiedNamespace, EntityPath));
credential = sharedKeyCredential.AsSharedAccessSignatureCredential(BuildConnectionResource(options.TransportType, fullyQualifiedNamespace, EntityPath));
break;
}

var tokenCredential = new ServiceBusTokenCredential(credential, BuildAudienceResource(options.TransportType, fullyQualifiedNamespace, EntityPath));
var tokenCredential = new ServiceBusTokenCredential(credential, BuildConnectionResource(options.TransportType, fullyQualifiedNamespace, EntityPath));

FullyQualifiedNamespace = fullyQualifiedNamespace;
TransportType = options.TransportType;
Expand Down Expand Up @@ -265,7 +266,7 @@ internal virtual TransportClient CreateTransportClient(
}

/// <summary>
/// Builds the audience for use in the signature.
/// Builds the audience of the connection for use in the signature.
/// </summary>
///
/// <param name="transportType">The type of protocol and transport that will be used for communicating with the Service Bus service.</param>
Expand All @@ -274,7 +275,7 @@ internal virtual TransportClient CreateTransportClient(
///
/// <returns>The value to use as the audience of the signature.</returns>
///
private static string BuildAudienceResource(
internal static string BuildConnectionResource(
ServiceBusTransportType transportType,
string fullyQualifiedNamespace,
string entityName)
Expand All @@ -297,6 +298,12 @@ private static string BuildAudienceResource(
return builder.Uri.AbsoluteUri.ToLowerInvariant();
}

/// <summary>
/// Throw an ObjectDisposedException if the object is Closing.
/// </summary>
internal virtual void ThrowIfClosed() =>
Argument.AssertNotDisposed(IsClosed, nameof(ServiceBusConnection));

/// <summary>
/// Performs the actions needed to validate the <see cref="ServiceBusClientOptions" /> associated
/// with this client.
Expand Down Expand Up @@ -327,13 +334,36 @@ private static void ValidateConnectionOptions(ServiceBusClientOptions connection
}

/// <summary>
/// Throw an ObjectDisposedException if the object is Closing.
/// Performs the actions needed to validate the set of connection string properties for connecting to the
/// Service Bus service.
/// </summary>
internal virtual void ThrowIfClosed()
///
/// <param name="connectionStringProperties">The set of connection string properties to validate.</param>
/// <param name="connectionStringArgumentName">The name of the argument associated with the connection string; to be used when raising <see cref="ArgumentException" /> variants.</param>
///
/// <exception cref="ArgumentException">In the case that the properties violate an invariant or otherwise represent a combination that is not permissible, an appropriate exception will be thrown.</exception>
///
private static void ValidateConnectionStringProperties(
ConnectionStringProperties connectionStringProperties,
string connectionStringArgumentName)
{
if (IsClosed)
var hasSharedKey = ((!string.IsNullOrEmpty(connectionStringProperties.SharedAccessKeyName)) && (!string.IsNullOrEmpty(connectionStringProperties.SharedAccessKey)));
var hasSharedSignature = (!string.IsNullOrEmpty(connectionStringProperties.SharedAccessSignature));

// Ensure that each of the needed components are present for connecting.

if ((string.IsNullOrEmpty(connectionStringProperties.Endpoint?.Host))
|| ((!hasSharedKey) && (!hasSharedSignature)))
{
throw new ArgumentException(Resources.MissingConnectionInformation, connectionStringArgumentName);
}

// The connection string may contain a precomputed shared access signature OR a shared key name and value,
// but not both.

if (hasSharedKey && hasSharedSignature)
{
throw new ObjectDisposedException($"{nameof(ServiceBusConnection)} has already been closed. Please create a new instance");
throw new ArgumentException(Resources.OnlyOneSharedAccessAuthorizationMayBeSpecified, connectionStringArgumentName);
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions sdk/servicebus/Azure.Messaging.ServiceBus/src/Resources.Designer.cs
100755 → 100644

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions sdk/servicebus/Azure.Messaging.ServiceBus/src/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@
<value>The message (id:{0}, size:{1} bytes) is larger than is currently allowed ({2} bytes).</value>
</data>
<data name="MissingConnectionInformation" xml:space="preserve">
<value>The connection string used for an Service Bus entity client must specify the Service Bus namespace host, and a Shared Access Signature (both the name and value) to be valid. The path to an Service Bus entity must be included in the connection string or specified separately.</value>
<value>The connection string used for an Service Bus client must specify the Service Bus namespace host and either a Shared Access Key (both the name and value) OR a Shard Access Signature to be valid.</value>
</data>
<data name="OnlyOneEntityNameMayBeSpecified" xml:space="preserve">
<value>The path to an Service Bus entity may be specified as part of the connection string or as a separate value, but not both.</value>
Expand Down Expand Up @@ -297,4 +297,7 @@
<data name="MessageProcessorIsNotRunning" xml:space="preserve">
<value>The message processor is not currently running. It needs to be started before it can be stopped.</value>
</data>
</root>
<data name="OnlyOneSharedAccessAuthorizationMayBeSpecified" xml:space="preserve">
<value>The authorization for a connection string may specifiy a shared key or precomputed shared access signature, but not both. Please verify that your connection string does not have the `SharedAccessSignature` token if you are passing the `SharedKeyName` and `SharedKey`.</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,40 @@ namespace Azure.Messaging.ServiceBus.Tests.Client
{
public class ServiceBusClientLiveTests : ServiceBusLiveTestBase
{
/// <summary>
/// Verifies that the <see cref="EventHubConnection" /> is able to
/// connect to the Event Hubs service.
/// </summary>
///
[Test]
public async Task ClientCanConnectUsingSharedAccessSignatureConnectionString()
{
await using (var scope = await ServiceBusScope.CreateWithQueue(enablePartitioning: true, enableSession: false))
{
var options = new ServiceBusClientOptions();
var audience = ServiceBusConnection.BuildConnectionResource(options.TransportType, TestEnvironment.FullyQualifiedNamespace, scope.QueueName);
var connectionString = TestEnvironment.BuildConnectionStringWithSharedAccessSignature(scope.QueueName, audience);

await using (var client = new ServiceBusClient(connectionString, options))
{
Assert.That(async () =>
{
ServiceBusReceiver receiver = null;
try
{
receiver = client.CreateReceiver(scope.QueueName);
}
finally
{
await (receiver?.DisposeAsync() ?? new ValueTask());
}
}, Throws.Nothing);
}
}
}

[Test]
[TestCase(true)]
[TestCase(false)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,26 @@ public void ConstructorDoesNotRequireEntityNameInConnectionString()
[TestCase("SharedAccessKeyName=[value];SharedAccessKey=[value];EntityPath=[value]")]
[TestCase("Endpoint=value.com;SharedAccessKey=[value];EntityPath=[value]")]
[TestCase("Endpoint=value.com;SharedAccessKeyName=[value];EntityPath=[value]")]
public void ConstructorValidatesConnectionString(string connectionString)
[TestCase("HostName=value.azure-devices.net;SharedAccessKeyName=[value];SharedAccessKey=[value]")]
[TestCase("HostName=value.azure-devices.net;SharedAccessKeyName=[value];SharedAccessKey=[value];EntityPath=[value]")]
[TestCase("HostName=value.azure-devices.net;SharedAccessKeyName=[value];SharedAccessSignature=[sas];EntityPath=[value]")]
[TestCase("HostName=value.azure-devices.net;SharedAccessKey=[value];SharedAccessSignature=[sas];EntityPath=[value]")]
public void ConstructorValidatesConnectionStringForMissingInformation(string connectionString)
{
Assert.That(() => new ServiceBusClient(connectionString), Throws.InstanceOf<ArgumentException>());
Assert.That(() => new ServiceBusClient(connectionString), Throws.ArgumentException.And.Message.StartsWith(Resources.MissingConnectionInformation));
}

/// <summary>
/// Verifies functionality of the <see cref="ServiceBusClient" />
/// constructor.
/// </summary>
///
[Test]

[TestCase("Endpoint=value.azure-devices.net;SharedAccessKeyName=[value];SharedAccessKey=[value];SharedAccessSignature=[sas]")]
public void ConstructorValidatesConnectionStringForDuplicateAuthorization(string connectionString)
{
Assert.That(() => new ServiceBusClient(connectionString), Throws.ArgumentException.And.Message.StartsWith(Resources.OnlyOneSharedAccessAuthorizationMayBeSpecified));
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using Azure.Messaging.ServiceBus.Core;
using NUnit.Framework;

namespace Azure.Messaging.ServiceBus.Tests.Client
namespace Azure.Messaging.ServiceBus.Tests.Core
{
public class ConnectionStringParserTests
{
Expand Down
Loading

0 comments on commit 596ab9b

Please sign in to comment.