Skip to content

Commit

Permalink
feat(iot-device): Add support for specifying sas token ttl via Client…
Browse files Browse the repository at this point in the history
…Options
  • Loading branch information
abhipsaMisra authored Mar 26, 2021
1 parent c43a698 commit 3bc6473
Show file tree
Hide file tree
Showing 13 changed files with 330 additions and 28 deletions.
57 changes: 57 additions & 0 deletions e2e/test/iothub/DeviceTokenRefreshE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,63 @@ public async Task DeviceClient_TokenConnectionDoubleRelease_Ok()
} // Second release
}

// The easiest way to test that sas tokens expire with custom expiration time via the CreateFromConnectionString flow is
// by initializing a DeviceClient instance over Mqtt (since sas token expiration over Mqtt is accompanied by a disconnect).
[LoggedTestMethod]
[TestCategory("LongRunning")]
public async Task DeviceClient_CreateFromConnectionString_TokenIsRefreshed_Mqtt()
{
var sasTokenTimeToLive = TimeSpan.FromSeconds(10);
int sasTokenRenewalBuffer = 50;
using var deviceDisconnected = new SemaphoreSlim(0);

int operationTimeoutInMilliseconds = (int)sasTokenTimeToLive.TotalMilliseconds * 2;

// Service allows a buffer time of upto 10mins before dropping connections that are authenticated with an expired sas tokens.
using var tokenRefreshCts = new CancellationTokenSource((int)(sasTokenTimeToLive.TotalMilliseconds * 2 + TimeSpan.FromMinutes(10).TotalMilliseconds));

TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, DevicePrefix).ConfigureAwait(false);

var options = new ClientOptions
{
SasTokenTimeToLive = sasTokenTimeToLive,
SasTokenRenewalBuffer = sasTokenRenewalBuffer,
};

using DeviceClient deviceClient = testDevice.CreateDeviceClient(Client.TransportType.Mqtt, options);
Logger.Trace($"Created {nameof(DeviceClient)} instance for {testDevice.Id}.");

deviceClient.SetConnectionStatusChangesHandler((ConnectionStatus status, ConnectionStatusChangeReason reason) =>
{
Logger.Trace($"{nameof(ConnectionStatusChangesHandler)}: {status}; {reason}");
if (status == ConnectionStatus.Disconnected_Retrying || status == ConnectionStatus.Disconnected)
{
deviceDisconnected.Release();
}
});
deviceClient.OperationTimeoutInMilliseconds = (uint)operationTimeoutInMilliseconds;

var message = new Client.Message(Encoding.UTF8.GetBytes("Hello"));

Logger.Trace($"[{testDevice.Id}]: SendEventAsync (1)");
await deviceClient.SendEventAsync(message).ConfigureAwait(false);

// Wait for the Token to expire.
Logger.Trace($"[{testDevice.Id}]: Waiting for device disconnect.");
await deviceDisconnected.WaitAsync(tokenRefreshCts.Token).ConfigureAwait(false);

try
{
Logger.Trace($"[{testDevice.Id}]: SendEventAsync (2)");
await deviceClient.SendEventAsync(message).ConfigureAwait(false);
}
catch (OperationCanceledException ex)
{
Assert.Fail($"{testDevice.Id} did not refresh token after expected ttl of {sasTokenTimeToLive}: {ex}");
throw;
}
}

private async Task DeviceClient_TokenIsRefreshed_Internal(Client.TransportType transport, int ttl = 20)
{
TestDevice testDevice = await TestDevice.GetTestDeviceAsync(Logger, DevicePrefix).ConfigureAwait(false);
Expand Down
10 changes: 9 additions & 1 deletion iothub/device/src/ClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -366,10 +366,18 @@ internal static InternalClient CreateFromConnectionString(
throw new ArgumentOutOfRangeException(nameof(connectionString), "Must specify at least one TransportSettings instance");
}

var builder = IotHubConnectionStringBuilder.CreateWithIAuthenticationOverride(
IotHubConnectionStringBuilder builder = IotHubConnectionStringBuilder.CreateWithIAuthenticationOverride(
connectionString,
authenticationMethod);

// Clients that derive their authentication method from AuthenticationWithTokenRefresh will need to specify
// the token time to live and renewal buffer values through the corresponding AuthenticationWithTokenRefresh implementation constructors instead.
if (!(builder.AuthenticationMethod is AuthenticationWithTokenRefresh))
{
builder.SasTokenTimeToLive = options?.SasTokenTimeToLive ?? default;
builder.SasTokenRenewalBuffer = options?.SasTokenRenewalBuffer ?? default;
}

IotHubConnectionString iotHubConnectionString = builder.ToIotHubConnectionString();

foreach (ITransportSettings transportSetting in transportSettings)
Expand Down
25 changes: 25 additions & 0 deletions iothub/device/src/ClientOptions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using Microsoft.Azure.Devices.Shared;

namespace Microsoft.Azure.Devices.Client
Expand Down Expand Up @@ -29,5 +30,29 @@ public class ClientOptions
/// The default behavior is that <see cref="Message.MessageId"/> is set only by the user.
/// </summary>
public SdkAssignsMessageId SdkAssignsMessageId { get; set; } = SdkAssignsMessageId.Never;

/// <summary>
/// The suggested time to live value for tokens generated for SAS authenticated clients.
/// The <see cref="TimeSpan"/> provided should be a positive value, signifying that it is not possible to generate tokens that have already expired.
/// If unset the generated SAS tokens will be valid for 1 hour.
/// </summary>
/// <remarks>
/// This is used only for SAS token authenticated clients through either the
/// <see cref="DeviceClient.CreateFromConnectionString(string, ClientOptions)"/> flow, the <see cref="ModuleClient.CreateFromConnectionString(string, ClientOptions)"/> flow
/// or the <see cref="ModuleClient.CreateFromEnvironmentAsync(ClientOptions)"/> flow.
/// </remarks>
public TimeSpan SasTokenTimeToLive { get; set; }

/// <summary>
/// The time buffer before expiry when the token should be renewed, expressed as a percentage of the time to live. Acceptable values lie between 0 and 100 (including the endpoints).
/// Eg. if set to a value of 30, the token will be renewed when it has 30% or less of its lifespan left.
/// If unset the token will be renewed when it has 15% or less of its lifespan left.
/// </summary>
/// <remarks>
/// This is used only for SAS token authenticated clients through either the
/// <see cref="DeviceClient.CreateFromConnectionString(string, ClientOptions)"/> flow, the <see cref="ModuleClient.CreateFromConnectionString(string, ClientOptions)"/> flow
/// or the <see cref="ModuleClient.CreateFromEnvironmentAsync(ClientOptions)"/> flow.
/// </remarks>
public int SasTokenRenewalBuffer { get; set; }
}
}
9 changes: 9 additions & 0 deletions iothub/device/src/DeviceAuthenticationWithSakRefresh.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ public DeviceAuthenticationWithSakRefresh(
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}

internal DeviceAuthenticationWithSakRefresh(
string deviceId,
IotHubConnectionString connectionString,
TimeSpan sasTokenTimeToLive,
int sasTokenRenewalBuffer) : base(deviceId, (int)sasTokenTimeToLive.TotalSeconds, sasTokenRenewalBuffer)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}

///<inheritdoc/>
protected override Task<string> SafeCreateNewToken(string iotHub, int suggestedTimeToLive)
{
Expand Down
32 changes: 25 additions & 7 deletions iothub/device/src/DeviceAuthenticationWithTokenRefresh.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ namespace Microsoft.Azure.Devices.Client
/// </summary>
public abstract class DeviceAuthenticationWithTokenRefresh : AuthenticationWithTokenRefresh
{
private const int DefaultTimeToLiveSeconds = 1 * 60 * 60;
private const int DefaultBufferPercentage = 15;
internal const int DefaultTimeToLiveSeconds = 1 * 60 * 60;
internal const int DefaultBufferPercentage = 15;

/// <summary>
/// Initializes a new instance of the <see cref="DeviceAuthenticationWithTokenRefresh"/> class using default
Expand All @@ -28,15 +28,19 @@ public DeviceAuthenticationWithTokenRefresh(string deviceId)
/// Initializes a new instance of the <see cref="DeviceAuthenticationWithTokenRefresh"/> class.
/// </summary>
/// <param name="deviceId">Device Identifier.</param>
/// <param name="suggestedTimeToLiveSeconds">Token time to live suggested value. The implementations of this abstract
/// may choose to ignore this value.</param>
/// <param name="timeBufferPercentage">Time buffer before expiry when the token should be renewed expressed as
/// a percentage of the time to live.</param>
/// <param name="suggestedTimeToLiveSeconds">
/// The suggested time to live value for the generated SAS tokens.
/// The default value is 1 hour.
/// </param>
/// <param name="timeBufferPercentage">
/// The time buffer before expiry when the token should be renewed, expressed as a percentage of the time to live.
/// The default behavior is that the token will be renewed when it has 15% or less of its lifespan left.
///</param>
public DeviceAuthenticationWithTokenRefresh(
string deviceId,
int suggestedTimeToLiveSeconds,
int timeBufferPercentage)
: base(suggestedTimeToLiveSeconds, timeBufferPercentage)
: base(SetSasTokenSuggestedTimeToLiveSeconds(suggestedTimeToLiveSeconds), SetSasTokenRenewalBufferPercentage(timeBufferPercentage))
{
if (deviceId.IsNullOrWhiteSpace())
{
Expand All @@ -63,5 +67,19 @@ public override IotHubConnectionStringBuilder Populate(IotHubConnectionStringBui
iotHubConnectionStringBuilder.DeviceId = DeviceId;
return iotHubConnectionStringBuilder;
}

private static int SetSasTokenSuggestedTimeToLiveSeconds(int suggestedTimeToLiveSeconds)
{
return suggestedTimeToLiveSeconds == 0
? DefaultTimeToLiveSeconds
: suggestedTimeToLiveSeconds;
}

private static int SetSasTokenRenewalBufferPercentage(int timeBufferPercentage)
{
return timeBufferPercentage == 0
? DefaultBufferPercentage
: timeBufferPercentage;
}
}
}
10 changes: 7 additions & 3 deletions iothub/device/src/Edge/EdgeModuleClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,13 @@ private async Task<ModuleClient> CreateInternalClientFromEnvironmentAsync()
}

ISignatureProvider signatureProvider = new HttpHsmSignatureProvider(edgedUri, DefaultApiVersion);
#pragma warning disable CA2000 // This is disposed when the client is disposed.
var authMethod = new ModuleAuthenticationWithHsm(signatureProvider, deviceId, moduleId, generationId);
#pragma warning restore CA2000

TimeSpan sasTokenTimeToLive = _options?.SasTokenTimeToLive ?? default;
int sasTokenRenewalBuffer = _options?.SasTokenRenewalBuffer ?? default;

#pragma warning disable CA2000 // Dispose objects before losing scope - IDisposable ModuleAuthenticationWithHsm is disposed when the client is disposed.
var authMethod = new ModuleAuthenticationWithHsm(signatureProvider, deviceId, moduleId, generationId, sasTokenTimeToLive, sasTokenRenewalBuffer);
#pragma warning restore CA2000 // Dispose objects before losing scope - IDisposable ModuleAuthenticationWithHsm is disposed when the client is disposed.

Debug.WriteLine("EdgeModuleClientFactory setupTrustBundle from service");

Expand Down
12 changes: 12 additions & 0 deletions iothub/device/src/HsmAuthentication/ModuleAuthenticationWithHsm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ internal ModuleAuthenticationWithHsm(ISignatureProvider signatureProvider, strin
_generationId = generationId ?? throw new ArgumentNullException(nameof(generationId));
}

internal ModuleAuthenticationWithHsm(
ISignatureProvider signatureProvider,
string deviceId,
string moduleId,
string generationId,
TimeSpan sasTokenTimeToLive,
int sasTokenRenewalBuffer) : base(deviceId, moduleId, (int)sasTokenTimeToLive.TotalSeconds, sasTokenRenewalBuffer)
{
_signatureProvider = signatureProvider ?? throw new ArgumentNullException(nameof(signatureProvider));
_generationId = generationId ?? throw new ArgumentNullException(nameof(generationId));
}

///<inheritdoc/>
protected override async Task<string> SafeCreateNewToken(string iotHub, int suggestedTimeToLive)
{
Expand Down
4 changes: 2 additions & 2 deletions iothub/device/src/IotHubConnectionString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ public IotHubConnectionString(IotHubConnectionStringBuilder builder)
{
if (ModuleId.IsNullOrWhiteSpace())
{
TokenRefresher = new DeviceAuthenticationWithSakRefresh(DeviceId, this) as AuthenticationWithTokenRefresh;
TokenRefresher = new DeviceAuthenticationWithSakRefresh(DeviceId, this, builder.SasTokenTimeToLive, builder.SasTokenRenewalBuffer);
if (Logging.IsEnabled)
{
Logging.Info(this, $"{nameof(IAuthenticationMethod)} is {nameof(DeviceAuthenticationWithSakRefresh)}: {Logging.IdOf(TokenRefresher)}");
}
}
else
{
TokenRefresher = new ModuleAuthenticationWithSakRefresh(DeviceId, ModuleId, this) as AuthenticationWithTokenRefresh;
TokenRefresher = new ModuleAuthenticationWithSakRefresh(DeviceId, ModuleId, this, builder.SasTokenTimeToLive, builder.SasTokenRenewalBuffer);
if (Logging.IsEnabled)
{
Logging.Info(this, $"{nameof(IAuthenticationMethod)} is {nameof(ModuleAuthenticationWithSakRefresh)}: {Logging.IdOf(TokenRefresher)}");
Expand Down
9 changes: 8 additions & 1 deletion iothub/device/src/IotHubConnectionStringBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,16 @@ public IAuthenticationMethod AuthenticationMethod
// Device certificate
internal X509Certificate2 Certificate { get; set; }

/// Full chain of certificates from the one used to sign the device certificate to the one uploaded to the service.
// Full chain of certificates from the one used to sign the device certificate to the one uploaded to the service.
internal X509Certificate2Collection ChainCertificates { get; set; }

// The suggested time to live value for tokens generated for SAS authenticated clients.
internal TimeSpan SasTokenTimeToLive { get; set; }

// The time buffer before expiry when the token should be renewed, expressed as a percentage of the time to live.
// This setting is valid only for SAS authenticated clients.
internal int SasTokenRenewalBuffer { get; set; }

internal IotHubConnectionString ToIotHubConnectionString()
{
Validate();
Expand Down
14 changes: 12 additions & 2 deletions iothub/device/src/ModuleAuthenticationWithSakRefresh.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,24 @@ namespace Microsoft.Azure.Devices.Client
// Implementing SAS Token refresh based on a SharedAccessKey (SAK).
internal class ModuleAuthenticationWithSakRefresh : ModuleAuthenticationWithTokenRefresh
{
private IotHubConnectionString _connectionString;
private readonly IotHubConnectionString _connectionString;

public ModuleAuthenticationWithSakRefresh(
string deviceId,
string moduleId,
IotHubConnectionString connectionString) : base(deviceId, moduleId)
{
_connectionString = connectionString;
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}

internal ModuleAuthenticationWithSakRefresh(
string deviceId,
string moduleId,
IotHubConnectionString connectionString,
TimeSpan sasTokenTimeToLive,
int sasTokenRenewalBuffer) : base(deviceId, moduleId, (int)sasTokenTimeToLive.TotalSeconds, sasTokenRenewalBuffer)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}

///<inheritdoc/>
Expand Down
28 changes: 23 additions & 5 deletions iothub/device/src/ModuleAuthenticationWithTokenRefresh.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,20 @@ public ModuleAuthenticationWithTokenRefresh(string deviceId, string moduleId)
/// </summary>
/// <param name="deviceId">The device Id.</param>
/// <param name="moduleId">The module Id.</param>
/// <param name="suggestedTimeToLiveSeconds">Token time to live suggested value. The implementations of this abstract
/// may choose to ignore this value.</param>
/// <param name="timeBufferPercentage">Time buffer before expiry when the token should be renewed expressed as
/// a percentage of the time to live.</param>
/// <param name="suggestedTimeToLiveSeconds">
/// The suggested time to live value for the generated SAS tokens.
/// The default value is 1 hour.
/// </param>
/// <param name="timeBufferPercentage">
/// The time buffer before expiry when the token should be renewed, expressed as a percentage of the time to live.
/// The default behavior is that the token will be renewed when it has 15% or less of its lifespan left.
///</param>
public ModuleAuthenticationWithTokenRefresh(
string deviceId,
string moduleId,
int suggestedTimeToLiveSeconds,
int timeBufferPercentage)
: base(suggestedTimeToLiveSeconds, timeBufferPercentage)
: base(SetSasTokenSuggestedTimeToLiveSeconds(suggestedTimeToLiveSeconds), SetSasTokenRenewalBufferPercentage(timeBufferPercentage))
{
if (moduleId.IsNullOrWhiteSpace())
{
Expand Down Expand Up @@ -78,5 +82,19 @@ public override IotHubConnectionStringBuilder Populate(IotHubConnectionStringBui
iotHubConnectionStringBuilder.ModuleId = ModuleId;
return iotHubConnectionStringBuilder;
}

private static int SetSasTokenSuggestedTimeToLiveSeconds(int suggestedTimeToLiveSeconds)
{
return suggestedTimeToLiveSeconds == 0
? DefaultTimeToLiveSeconds
: suggestedTimeToLiveSeconds;
}

private static int SetSasTokenRenewalBufferPercentage(int timeBufferPercentage)
{
return timeBufferPercentage == 0
? DefaultBufferPercentage
: timeBufferPercentage;
}
}
}
Loading

0 comments on commit 3bc6473

Please sign in to comment.