diff --git a/sdk/communication/Azure.Communication.Common/CHANGELOG.md b/sdk/communication/Azure.Communication.Common/CHANGELOG.md index 9b0bede7dc11..884c23905795 100644 --- a/sdk/communication/Azure.Communication.Common/CHANGELOG.md +++ b/sdk/communication/Azure.Communication.Common/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.4.0-beta.1 (Unreleased) ### Features Added +- Introduced support for `Azure.Core.TokenCredential` with `EntraCommunicationTokenCredentialOptions`, enabling Entra users to authorize Communication Services and allowing an Entra user with a Teams license to use Teams Phone Extensibility features through the Azure Communication Services resource. ### Breaking Changes diff --git a/sdk/communication/Azure.Communication.Common/README.md b/sdk/communication/Azure.Communication.Common/README.md index 5d24945d71db..7308319eb436 100644 --- a/sdk/communication/Azure.Communication.Common/README.md +++ b/sdk/communication/Azure.Communication.Common/README.md @@ -38,6 +38,7 @@ Depending on your scenario, you may want to initialize the `CommunicationTokenCr - a static token (suitable for short-lived clients used to e.g. send one-off Chat messages) or - a callback function that ensures a continuous authentication state (ideal e.g. for long Calling sessions). +- a token credential capable of obtaining an Entra user token. You can provide any implementation of [Azure.Core.TokenCredential](https://docs.microsoft.com/dotnet/api/azure.core.tokencredential?view=azure-dotnet). It is suitable for scenarios where Entra user access tokens are needed to authenticate with Communication Services. The tokens supplied to the `CommunicationTokenCredential` either through the constructor or via the token refresher callback can be obtained using the Azure Communication Identity library. @@ -101,6 +102,54 @@ using var tokenCredential = new CommunicationTokenCredential( }); ``` +### Create a credential with a token credential capable of obtaining an Entra user token + +For scenarios where an Entra user can be used with Communication Services, you need to initialize any implementation of [Azure.Core.TokenCredential](https://docs.microsoft.com/dotnet/api/azure.core.tokencredential?view=azure-dotnet) and provide it to the ``EntraCommunicationTokenCredentialOptions``. +Along with this, you must provide the URI of the Azure Communication Services resource and the scopes required for the Entra user token. These scopes determine the permissions granted to the token. +If the scopes are not provided, by default, it sets the scopes to `https://communication.azure.com/clients/.default`. +```C# +var options = new InteractiveBrowserCredentialOptions + { + TenantId = "", + ClientId = "", + RedirectUri = new Uri("") + }; +var entraTokenCredential = new InteractiveBrowserCredential(options); + +var entraTokenCredentialOptions = new EntraCommunicationTokenCredentialOptions( + resourceEndpoint: "https://.communication.azure.com", + entraTokenCredential: entraTokenCredential) + { + Scopes = new[] { "https://communication.azure.com/clients/VoIP" } + }; + +var credential = new CommunicationTokenCredential(entraTokenCredentialOptions); + +``` + +The same approach can be used for authorizing an Entra user with a Teams license to use Teams Phone Extensibility features through your Azure Communication Services resource. +This requires providing the `https://auth.msft.communication.azure.com/TeamsExtension.ManageCalls` scope +```C# +var options = new InteractiveBrowserCredentialOptions + { + TenantId = "", + ClientId = "", + RedirectUri = new Uri("") + }; +var entraTokenCredential = new InteractiveBrowserCredential(options); + +var entraTokenCredentialOptions = new EntraCommunicationTokenCredentialOptions( + resourceEndpoint: "https://.communication.azure.com", + entraTokenCredential: entraTokenCredential) + ) + { + Scopes = new[] { "https://auth.msft.communication.azure.com/TeamsExtension.ManageCalls" } + }; + +var credential = new CommunicationTokenCredential(entraTokenCredentialOptions); + +``` + ## Troubleshooting The proactive refreshing failures happen in a background thread and to avoid crashing your app the exceptions will be silently handled. All the other failures will happen during your request using other clients such as chat where you can catch the exception using `RequestFailedException`. diff --git a/sdk/communication/Azure.Communication.Common/api/Azure.Communication.Common.net8.0.cs b/sdk/communication/Azure.Communication.Common/api/Azure.Communication.Common.net8.0.cs index 97e437ca8729..469930c7ccef 100644 --- a/sdk/communication/Azure.Communication.Common/api/Azure.Communication.Common.net8.0.cs +++ b/sdk/communication/Azure.Communication.Common/api/Azure.Communication.Common.net8.0.cs @@ -34,6 +34,7 @@ protected CommunicationIdentifier() { } public sealed partial class CommunicationTokenCredential : System.IDisposable { public CommunicationTokenCredential(Azure.Communication.CommunicationTokenRefreshOptions options) { } + public CommunicationTokenCredential(Azure.Communication.EntraCommunicationTokenCredentialOptions options) { } public CommunicationTokenCredential(string token) { } public void Dispose() { } public Azure.Core.AccessToken GetToken(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } @@ -53,6 +54,13 @@ public CommunicationUserIdentifier(string id) { } public override bool Equals(Azure.Communication.CommunicationIdentifier other) { throw null; } public override string ToString() { throw null; } } + public partial class EntraCommunicationTokenCredentialOptions + { + public EntraCommunicationTokenCredentialOptions(string resourceEndpoint, Azure.Core.TokenCredential entraTokenCredential) { } + public string ResourceEndpoint { get { throw null; } } + public string[] Scopes { get { throw null; } set { } } + public Azure.Core.TokenCredential TokenCredential { get { throw null; } } + } public partial class MicrosoftTeamsAppIdentifier : Azure.Communication.CommunicationIdentifier { public MicrosoftTeamsAppIdentifier(string appId, Azure.Communication.CommunicationCloudEnvironment? cloud = default(Azure.Communication.CommunicationCloudEnvironment?)) { } diff --git a/sdk/communication/Azure.Communication.Common/api/Azure.Communication.Common.netstandard2.0.cs b/sdk/communication/Azure.Communication.Common/api/Azure.Communication.Common.netstandard2.0.cs index 97e437ca8729..469930c7ccef 100644 --- a/sdk/communication/Azure.Communication.Common/api/Azure.Communication.Common.netstandard2.0.cs +++ b/sdk/communication/Azure.Communication.Common/api/Azure.Communication.Common.netstandard2.0.cs @@ -34,6 +34,7 @@ protected CommunicationIdentifier() { } public sealed partial class CommunicationTokenCredential : System.IDisposable { public CommunicationTokenCredential(Azure.Communication.CommunicationTokenRefreshOptions options) { } + public CommunicationTokenCredential(Azure.Communication.EntraCommunicationTokenCredentialOptions options) { } public CommunicationTokenCredential(string token) { } public void Dispose() { } public Azure.Core.AccessToken GetToken(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } @@ -53,6 +54,13 @@ public CommunicationUserIdentifier(string id) { } public override bool Equals(Azure.Communication.CommunicationIdentifier other) { throw null; } public override string ToString() { throw null; } } + public partial class EntraCommunicationTokenCredentialOptions + { + public EntraCommunicationTokenCredentialOptions(string resourceEndpoint, Azure.Core.TokenCredential entraTokenCredential) { } + public string ResourceEndpoint { get { throw null; } } + public string[] Scopes { get { throw null; } set { } } + public Azure.Core.TokenCredential TokenCredential { get { throw null; } } + } public partial class MicrosoftTeamsAppIdentifier : Azure.Communication.CommunicationIdentifier { public MicrosoftTeamsAppIdentifier(string appId, Azure.Communication.CommunicationCloudEnvironment? cloud = default(Azure.Communication.CommunicationCloudEnvironment?)) { } diff --git a/sdk/communication/Azure.Communication.Common/src/CommunicationTokenCredential.cs b/sdk/communication/Azure.Communication.Common/src/CommunicationTokenCredential.cs index 0473faacc704..487ede35cbaf 100644 --- a/sdk/communication/Azure.Communication.Common/src/CommunicationTokenCredential.cs +++ b/sdk/communication/Azure.Communication.Common/src/CommunicationTokenCredential.cs @@ -34,6 +34,16 @@ public CommunicationTokenCredential(CommunicationTokenRefreshOptions options) _tokenCredential = new AutoRefreshTokenCredential(options); } + /// + /// Initializes a new instance of . + /// + /// The options for how the token will be fetched + public CommunicationTokenCredential(EntraCommunicationTokenCredentialOptions options) + { + Argument.AssertNotNull(options, nameof(options)); + _tokenCredential = new EntraTokenCredential(options); + } + /// /// Gets an for the user. /// diff --git a/sdk/communication/Azure.Communication.Common/src/EntraCommunicationTokenCredentialOptions.cs b/sdk/communication/Azure.Communication.Common/src/EntraCommunicationTokenCredentialOptions.cs new file mode 100644 index 000000000000..034cd1f03d82 --- /dev/null +++ b/sdk/communication/Azure.Communication.Common/src/EntraCommunicationTokenCredentialOptions.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Azure.Core; + +namespace Azure.Communication +{ + /// + /// The Entra Communication Token Options. + /// + public class EntraCommunicationTokenCredentialOptions + { + private static string[] DefaultScopes = { "https://communication.azure.com/clients/.default" }; + /// + /// The URI of the Azure Communication Services resource. + /// + public string ResourceEndpoint { get; } + + /// + /// The credential capable of fetching an Entra user token. You can provide any implementation of . + /// + public TokenCredential TokenCredential { get; } + + /// + /// The scopes required for the Entra user token. These scopes determine the permissions granted to the token. For example, ["https://communication.azure.com/clients/VoIP"]. + /// + public string[] Scopes { get; set; } + + /// + /// Initializes a new instance of . + /// + /// The URI of the Azure Communication Services resource.For example, https://myResource.communication.azure.com. + /// The credential capable of fetching an Entra user token. + public EntraCommunicationTokenCredentialOptions( + string resourceEndpoint, + TokenCredential entraTokenCredential) + { + Argument.AssertNotNullOrEmpty(resourceEndpoint, nameof(resourceEndpoint)); + Argument.AssertNotNull(entraTokenCredential, nameof(entraTokenCredential)); + + this.ResourceEndpoint = resourceEndpoint; + this.TokenCredential = entraTokenCredential; + this.Scopes = DefaultScopes; + } + } +} diff --git a/sdk/communication/Azure.Communication.Common/src/EntraTokenCredential.cs b/sdk/communication/Azure.Communication.Common/src/EntraTokenCredential.cs new file mode 100644 index 000000000000..90567bb6e524 --- /dev/null +++ b/sdk/communication/Azure.Communication.Common/src/EntraTokenCredential.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.Pipeline; + +namespace Azure.Communication +{ + /// + /// Represents a credential that exchanges an Entra token for an Azure Communication Services (ACS) token, enabling access to ACS resources. + /// + internal sealed class EntraTokenCredential : ICommunicationTokenCredential + { + private const string TeamsExtensionScopePrefix = "https://auth.msft.communication.azure.com/"; + private const string ComunicationClientsScopePrefix = "https://communication.azure.com/clients/"; + private const string TeamsExtensionEndpoint = "/access/teamsPhone/:exchangeAccessToken"; + private const string TeamsExtensionApiVersion = "2025-03-02-preview"; + private const string ComunicationClientsEndpoint = "/access/entra/:exchangeAccessToken"; + private const string ComunicationClientsApiVersion = "2024-04-01-preview"; + + private HttpPipeline _pipeline; + private string _resourceEndpoint; + private string[] _scopes { get; set; } + private readonly ThreadSafeRefreshableAccessTokenCache _accessTokenCache; + + /// + /// Initializes a new instance of . + /// + /// The options for how the token will be fetched + /// Only for testing. + public EntraTokenCredential(EntraCommunicationTokenCredentialOptions options, HttpPipelineTransport pipelineTransport = null) + { + this._resourceEndpoint = options.ResourceEndpoint; + this._scopes = options.Scopes; + _pipeline = CreatePipelineFromOptions(options, pipelineTransport); + _accessTokenCache = new ThreadSafeRefreshableAccessTokenCache( + ExchangeEntraToken, + ExchangeEntraTokenAsync, + false, null, null); + _accessTokenCache.GetValueAsync(default); + } + + private HttpPipeline CreatePipelineFromOptions(EntraCommunicationTokenCredentialOptions options, HttpPipelineTransport pipelineTransport) + { + var authenticationPolicy = new BearerTokenAuthenticationPolicy(options.TokenCredential, options.Scopes); + var entraTokenGuardPolicy = new EntraTokenGuardPolicy(); + var clientOptions = ClientOptions.Default; + if (pipelineTransport != null) + { + clientOptions.Transport = pipelineTransport; + } + return HttpPipelineBuilder.Build(clientOptions, authenticationPolicy, entraTokenGuardPolicy); + } + + /// + public void Dispose() + { + _pipeline = default; + _accessTokenCache.Dispose(); + } + + /// + /// Gets an . + /// + /// The cancellation token for the task. + /// Contains the access token. + public AccessToken GetToken(CancellationToken cancellationToken = default) + => _accessTokenCache.GetValue(cancellationToken, () => true); + + /// + /// Gets an . + /// + /// The cancellation token for the task. + /// + /// A task that represents the asynchronous get token operation. The value of its property contains the access token. + /// + public ValueTask GetTokenAsync(CancellationToken cancellationToken = default) + => _accessTokenCache.GetValueAsync(cancellationToken, () => true); + + private AccessToken ExchangeEntraToken(CancellationToken cancellationToken) + { + return ExchangeEntraTokenAsync(false, cancellationToken).EnsureCompleted(); + } + + private async ValueTask ExchangeEntraTokenAsync(CancellationToken cancellationToken) + { + var result = await ExchangeEntraTokenAsync(true, cancellationToken).ConfigureAwait(false); + return result; + } + + private async ValueTask ExchangeEntraTokenAsync(bool async, CancellationToken cancellationToken) + { + var message = CreateRequestMessage(); + if (async) + { + await _pipeline.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + else + { + _pipeline.Send(message, cancellationToken); + } + return ParseAccessTokenFromResponse(message.Response); + } + + private HttpMessage CreateRequestMessage() + { + var message = _pipeline.CreateMessage(); + var request = message.Request; + request.Uri = CreateRequestUri(); + request.Method = RequestMethod.Post; + request.Headers.Add("Accept", "application/json"); + request.Headers.Add("Content-Type", "application/json"); + request.Content = "{}"; + + return message; + } + + private RequestUriBuilder CreateRequestUri() + { + var uri = new RequestUriBuilder(); + uri.Reset(new Uri(_resourceEndpoint)); + + var (endpoint, apiVersion) = DetermineEndpointAndApiVersion(); + uri.AppendPath(endpoint, false); + uri.AppendQuery("api-version", apiVersion, true); + return uri; + } + + private (string Endpoint, string ApiVersion) DetermineEndpointAndApiVersion() + { + if (_scopes == null || !_scopes.Any()) + { + throw new ArgumentException($"Scopes validation failed. Ensure all scopes start with either {TeamsExtensionScopePrefix} or {ComunicationClientsScopePrefix}.", nameof(_scopes)); + } + else if (_scopes.All(item => item.StartsWith(TeamsExtensionScopePrefix))) + { + return (TeamsExtensionEndpoint, TeamsExtensionApiVersion); + } + else if (_scopes.All(item => item.StartsWith(ComunicationClientsScopePrefix))) + { + return (ComunicationClientsEndpoint, ComunicationClientsApiVersion); + } + else + { + throw new ArgumentException($"Scopes validation failed. Ensure all scopes start with either {TeamsExtensionScopePrefix} or {ComunicationClientsScopePrefix}.", nameof(_scopes)); + } + } + + private AccessToken ParseAccessTokenFromResponse(Response response) + { + switch (response.Status) + { + case 200: + try + { + var json = JsonDocument.Parse(response.Content); + var accessTokenJson = json.RootElement.GetProperty("accessToken").GetRawText(); + var acsToken = JsonSerializer.Deserialize(accessTokenJson); + return new AccessToken(acsToken.token, acsToken.expiresOn); + } + catch (Exception) + { + throw new RequestFailedException(response); + } + default: + throw new RequestFailedException(response); + } + } + + private class AcsToken + { + public string token { get; set; } + public DateTimeOffset expiresOn { get; set; } + } + } +} diff --git a/sdk/communication/Azure.Communication.Common/src/EntraTokenGuardPolicy.cs b/sdk/communication/Azure.Communication.Common/src/EntraTokenGuardPolicy.cs new file mode 100644 index 000000000000..0ef79ac562fc --- /dev/null +++ b/sdk/communication/Azure.Communication.Common/src/EntraTokenGuardPolicy.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.Pipeline; + +namespace Azure.Communication +{ + internal class EntraTokenGuardPolicy : HttpPipelinePolicy + { + private string _entraTokenCache; + private Response _responseCache; + + public override void Process(HttpMessage message, ReadOnlyMemory pipeline) + { + ProcessAsync(message, pipeline, async: false).EnsureCompleted(); + } + + public override ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline) + { + return ProcessAsync(message, pipeline, async: true); + } + + private async ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline, bool async) + { + var (entraTokenCacheValid, token) = IsEntraTokenCacheValid(message); + if (entraTokenCacheValid && IsAcsTokenCacheValid()) + { + message.Response = _responseCache; + return; + } + else + { + _entraTokenCache = token; + if (async) + { + await ProcessNextAsync(message, pipeline).ConfigureAwait(continueOnCapturedContext: false); + } + else + { + ProcessNext(message, pipeline); + } + _responseCache = message.Response; + } + } + + private (bool CacheValid, string EntraToken) IsEntraTokenCacheValid(HttpMessage message) + { + var currentEntraToken = string.Empty; + message.Request.Headers.TryGetValue("Authorization", out currentEntraToken); + return (!string.IsNullOrEmpty(_entraTokenCache) && currentEntraToken == _entraTokenCache, currentEntraToken); + } + + private bool IsAcsTokenCacheValid() + { + return _responseCache != null && _responseCache.Status == 200 && IsAccessTokenValid(); + } + + private bool IsAccessTokenValid() + { + try + { + var json = JsonDocument.Parse(_responseCache.Content); + var expiresOnString = json.RootElement + .GetProperty("accessToken") + .GetProperty("expiresOn") + .GetString(); + var expiresOn = DateTimeOffset.Parse(expiresOnString); + return DateTimeOffset.UtcNow < expiresOn; + } + catch + { + return false; + } + } + } +} diff --git a/sdk/communication/Azure.Communication.Common/src/ThreadSafeRefreshableAccessTokenCache.cs b/sdk/communication/Azure.Communication.Common/src/ThreadSafeRefreshableAccessTokenCache.cs index c0690951aeeb..64cade4430bf 100644 --- a/sdk/communication/Azure.Communication.Common/src/ThreadSafeRefreshableAccessTokenCache.cs +++ b/sdk/communication/Azure.Communication.Common/src/ThreadSafeRefreshableAccessTokenCache.cs @@ -96,11 +96,17 @@ private void ScheduleRefresher() _scheduledProactiveRefreshing = ScheduleProactiveRefreshing(dueTime); } - public AccessToken GetValue(CancellationToken cancellationToken) - => GetValueAsync(async: false, IsCurrentTokenExpiryingSoon, cancellationToken: cancellationToken).EnsureCompleted(); + public AccessToken GetValue(CancellationToken cancellationToken, Func? shouldRefresh = null) + { + shouldRefresh ??= IsCurrentTokenExpiryingSoon; + return GetValueAsync(async: false, shouldRefresh, cancellationToken: cancellationToken).EnsureCompleted(); + } - public ValueTask GetValueAsync(CancellationToken cancellationToken) - => GetValueAsync(async: true, IsCurrentTokenExpiryingSoon, cancellationToken: cancellationToken); + public ValueTask GetValueAsync(CancellationToken cancellationToken, Func? shouldRefresh = null) + { + shouldRefresh ??= IsCurrentTokenExpiryingSoon; + return GetValueAsync(async: true, shouldRefresh ??= IsCurrentTokenExpiryingSoon, cancellationToken: cancellationToken); + } private async ValueTask GetValueAsync(bool async, Func shouldRefresh, CancellationToken cancellationToken) { diff --git a/sdk/communication/Azure.Communication.Common/tests/CommunicationIdentifierTest.cs b/sdk/communication/Azure.Communication.Common/tests/CommunicationIdentifierTest.cs index 45a82931cd21..c4cf1183287c 100644 --- a/sdk/communication/Azure.Communication.Common/tests/CommunicationIdentifierTest.cs +++ b/sdk/communication/Azure.Communication.Common/tests/CommunicationIdentifierTest.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Collections.Generic; using NUnit.Framework; -using System.Runtime.InteropServices; namespace Azure.Communication { diff --git a/sdk/communication/Azure.Communication.Common/tests/Identity/EntraTokenCredentialTest.cs b/sdk/communication/Azure.Communication.Common/tests/Identity/EntraTokenCredentialTest.cs new file mode 100644 index 000000000000..f07ef406b4f6 --- /dev/null +++ b/sdk/communication/Azure.Communication.Common/tests/Identity/EntraTokenCredentialTest.cs @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Core.TestFramework; +using Moq; +using NUnit.Framework; + +namespace Azure.Communication.Identity +{ + [TestFixture] + public class EntraTokenCredentialTest + { + private const string SampleToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjMyNTAzNjgwMDAwfQ.9i7FNNHHJT8cOzo-yrAUJyBSfJ-tPPk2emcHavOEpWc"; + private const string SampleTokenExpiry = "2034-10-04T10:21:29.4729393+00:00"; + private const string ExpiredToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEwMH0.1h_scYkNp-G98-O4cW6KvfJZwiz54uJMyeDACE4nypg"; + protected const string TokenResponseTemplate = "{{\"identity\":{{\"id\":\"8:acs:52a5e676-39a3-4f45-a8ed-5a162dbbd7eb_cdc5aeea-15c5-4db6-b079-fcadd2505dc2_cab309e5-a2e7-4ac8-b04e-5fadc3aa90fa\"}},\n" + + "\"accessToken\":{{\"token\":\"{0}\",\n" + + "\"expiresOn\":\"{1}\"}}}}"; + protected string TokenResponse = string.Format(TokenResponseTemplate, SampleToken, SampleTokenExpiry); + + private Mock _mockTokenCredential = null!; + private const string comunicationClientsEndpoint = "/access/entra/:exchangeAccessToken"; + private const string communicationClientsScope = "https://communication.azure.com/clients/VoIP"; + private const string teamsExtensionEndpoint = "/access/teamsPhone/:exchangeAccessToken"; + private const string teamsExtensionScope = "https://auth.msft.communication.azure.com/TeamsExtension.ManageCalls"; + private string _resourceEndpoint = "https://myResource.communication.azure.com"; + + private static readonly object[] validScopes = + { + new object[] { new string[] { communicationClientsScope }}, + new object[] { new string[] { teamsExtensionScope } } + }; + private static readonly object[] invalidScopes = + { + new object[] { new string[] { communicationClientsScope, teamsExtensionScope } }, + new object[] { new string[] { teamsExtensionScope, communicationClientsScope } }, + new object[] { new string[] { "invalidScope" } }, + new object[] { new string[] { "" } }, + new object[] { new string[] { } } + }; + + [SetUp] + public void Setup() + { + _mockTokenCredential = new Mock(); + var expiryTime = DateTimeOffset.Parse(SampleTokenExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind); + _mockTokenCredential + .Setup(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(SampleToken, expiryTime)); + } + + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_Init_ThrowsErrorWithNulls(string[] scopes) + { + Assert.Throws(() => new EntraCommunicationTokenCredentialOptions( + null, + _mockTokenCredential.Object) + { + Scopes = scopes + }); + + Assert.Throws(() => new EntraCommunicationTokenCredentialOptions( + "", + _mockTokenCredential.Object) + { + Scopes = scopes + }); + + Assert.Throws(() => new EntraCommunicationTokenCredentialOptions( + _resourceEndpoint, + null) + { + Scopes = scopes + }); + } + + [Test] + public void EntraTokenCredential_InitWithoutScopes_InitsWithDefaultScope() + { + var credential = new EntraCommunicationTokenCredentialOptions( + _resourceEndpoint, + _mockTokenCredential.Object); + var scopes = new[] { "https://communication.azure.com/clients/.default" }; + Assert.AreEqual(credential.Scopes, scopes); + } + + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_Init_FetchesTokenImmediately(string[] scopes) + { + // Arrange + var expiryTime = DateTimeOffset.Parse(SampleTokenExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind); + var options = CreateEntraTokenCredentialOptions(scopes); + var mockTransport = CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse) }); + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + // Assert + _mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test, TestCaseSource(nameof(validScopes))] + public async Task EntraTokenCredential_GetToken_ReturnsToken(string[] scopes) + { + // Arrange + var expiryTime = DateTimeOffset.Parse(SampleTokenExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind); + var options = CreateEntraTokenCredentialOptions(scopes); + var mockTransport = (MockTransport) CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse) }); + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + + // Act + var token = await entraTokenCredential.GetTokenAsync(CancellationToken.None); + + // Assert + Assert.AreEqual(SampleToken, token.Token); + Assert.AreEqual(token.ExpiresOn, expiryTime); + if (scopes.Contains(teamsExtensionScope)) + { + Assert.AreEqual(teamsExtensionEndpoint, mockTransport.SingleRequest.Uri.Path); + } + else + { + Assert.AreEqual(comunicationClientsEndpoint, mockTransport.SingleRequest.Uri.Path); + } + _mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task EntraTokenCredential_InitWithoutScopes_ReturnsComunicationClientsToken() + { + // Arrange + var expiryTime = DateTimeOffset.Parse(SampleTokenExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind); + var options = new EntraCommunicationTokenCredentialOptions(_resourceEndpoint, _mockTokenCredential.Object); + var mockTransport = (MockTransport)CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse) }); + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + + // Act + var token = await entraTokenCredential.GetTokenAsync(CancellationToken.None); + + // Assert + Assert.AreEqual(SampleToken, token.Token); + Assert.AreEqual(token.ExpiresOn, expiryTime); + Assert.AreEqual(comunicationClientsEndpoint, mockTransport.SingleRequest.Uri.Path); + _mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test, TestCaseSource(nameof(validScopes))] + public async Task EntraTokenCredential_GetToken_InternalEntraTokenChangeInvalidatesCachedToken(string[] scopes) + { + // Arrange + var expiryTime = DateTimeOffset.Parse(SampleTokenExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind); + var newToken = "newToken"; + var refreshOn = DateTimeOffset.Now; + _mockTokenCredential + .SetupSequence(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken("Entra token for call from constructor", refreshOn)) + .ReturnsAsync(new AccessToken("Entra token for the first getToken call token", expiryTime)); + + var options = CreateEntraTokenCredentialOptions(scopes); + var latestTokenResponse = string.Format(TokenResponseTemplate, newToken, SampleTokenExpiry); + var mockTransport = CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse), CreateMockResponse(200, latestTokenResponse) }); + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + // Act + var token = await entraTokenCredential.GetTokenAsync(CancellationToken.None); + + // Assert for cached tokens are updated + Assert.AreEqual(newToken, token.Token); + _mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test, TestCaseSource(nameof(validScopes))] + public async Task EntraTokenCredential_GetToken_MultipleCallsReturnsCachedToken(string[] scopes) + { + // Arrange + var options = CreateEntraTokenCredentialOptions(scopes); + var mockTransport = CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse) }); + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + + // Act + var token = await entraTokenCredential.GetTokenAsync(CancellationToken.None); + for (var i = 0; i < 10; i++) + { + token = await entraTokenCredential.GetTokenAsync(CancellationToken.None); + } + // Assert + Assert.AreEqual(SampleToken, token.Token); + _mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_GetToken_ThrowsFailedResponse(string[] scopes) + { + // Arrange + var options = CreateEntraTokenCredentialOptions(scopes); + var errorMessage = "{\"error\":{\"code\":\"BadRequest\",\"message\":\"Invalid request.\"}}"; + var mockResponses = new[] + { + CreateMockResponse(400, errorMessage), + CreateMockResponse(400, errorMessage), + }; + var mockTransport = CreateMockTransport(mockResponses); + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + + // Act & Assert + Assert.ThrowsAsync(async () => await entraTokenCredential.GetTokenAsync(CancellationToken.None)); + } + + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_GetToken_ThrowsInvalidJson(string[] scopes) + { + // Arrange + var options = CreateEntraTokenCredentialOptions(scopes); + var errorMessage = "{\"error\":{\"code\":\"BadRequest\",\"message\":\"Invalid request.\"}}"; + var mockResponses = new[] + { + CreateMockResponse(200, errorMessage), + CreateMockResponse(200, errorMessage), + }; + var mockTransport = CreateMockTransport(mockResponses); + + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + + // Act & Assert + Assert.ThrowsAsync(async () => await entraTokenCredential.GetTokenAsync(CancellationToken.None)); + } + + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_GetToken_RetriesThreeTimesOnTransientError(string[] scopes) + { + // Arrange + var options = CreateEntraTokenCredentialOptions(scopes); + var lastRetryErrorMessage = "Last Retry Error Message"; + var mockResponses = new MockResponse[] + { + CreateMockResponse(500, "First Retry Error Message"), + CreateMockResponse(500, "Second Retry Error Message"), + CreateMockResponse(500, "Third Retry Error Message"), + CreateMockResponse(500, "Last retry for the pre-warm fetch"), + CreateMockResponse(500, "First Retry Error Message"), + CreateMockResponse(500, "Second Retry Error Message"), + CreateMockResponse(500, "Third Retry Error Message"), + CreateMockResponse(500, lastRetryErrorMessage), + CreateMockResponse(500, "Shouldn't reach here"), + }; + + var mockTransport = CreateMockTransport(mockResponses); + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => await entraTokenCredential.GetTokenAsync(CancellationToken.None)); + StringAssert.Contains(lastRetryErrorMessage, ex?.Message); + } + + [Test, TestCaseSource(nameof(invalidScopes))] + public void EntraTokenCredential_GetToken_ThrowsForInvalidScopes(string[] scopes) + { + // Arrange + var options = CreateEntraTokenCredentialOptions(scopes); + var mockResponses = new MockResponse[] + { + CreateMockResponse(200, TokenResponse) + }; + + var mockTransport = CreateMockTransport(mockResponses); + var entraTokenCredential = new EntraTokenCredential(options, mockTransport); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => await entraTokenCredential.GetTokenAsync(CancellationToken.None)); + StringAssert.Contains("Scopes validation failed. Ensure all scopes start with either", ex?.Message); + } + + private EntraCommunicationTokenCredentialOptions CreateEntraTokenCredentialOptions(string[] scopes) + { + return new EntraCommunicationTokenCredentialOptions(_resourceEndpoint, _mockTokenCredential.Object) + { + Scopes = scopes + }; + } + + private MockResponse CreateMockResponse(int statusCode, string body) + { + return new MockResponse(statusCode).WithJson(body); + } + + private HttpPipelineTransport CreateMockTransport(MockResponse[] mockResponses) + { + return new MockTransport(mockResponses); + } + } +} diff --git a/sdk/communication/Azure.Communication.Common/tests/Pipeline/EntraTokenGuardPolicyTests.cs b/sdk/communication/Azure.Communication.Common/tests/Pipeline/EntraTokenGuardPolicyTests.cs new file mode 100644 index 000000000000..cb2d42d129cc --- /dev/null +++ b/sdk/communication/Azure.Communication.Common/tests/Pipeline/EntraTokenGuardPolicyTests.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Core.TestFramework; +using Moq; +using NUnit.Framework; +using System; +using System.Threading.Tasks; + +namespace Azure.Communication.Pipeline +{ + [TestFixture] + public class EntraTokenGuardPolicyTests + { + private HttpMessage _httpMessage = null!; + private CustomRequest _httpRequest = null!; + private Mock _httpPipelinePolicyMock = null!; + private string _authHeader = "Bearer Token"; + protected const string TokenResponseTemplate = "{{\"identity\":{{\"id\":\"8:acs:52a5e676-39a3-4f45-a8ed-5a162dbbd7eb_cdc5aeea-15c5-4db6-b079-fcadd2505dc2_cab309e5-a2e7-4ac8-b04e-5fadc3aa90fa\"}},\n" + + "\"accessToken\":{{\"token\":\"{0}\",\n" + + "\"expiresOn\":\"{1}\"}}}}"; + protected string TokenResponse = string.Format(TokenResponseTemplate, "token", "2034-10-04T10:21:29.4729393+00:00"); + + [SetUp] + public void SetUp() + { + _httpRequest = new CustomRequest(_authHeader); + _httpMessage = new HttpMessage(_httpRequest, new ResponseClassifier()); + _httpPipelinePolicyMock = new Mock(); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task ProcessAsync_WithNoToken_CallsProcessNextAsync(bool async) + { + // Arrange + var policy = new EntraTokenGuardPolicy(); + var pipelines = new ReadOnlyMemory(new HttpPipelinePolicy[] { _httpPipelinePolicyMock.Object }); + _httpPipelinePolicyMock.Setup(m => m.ProcessAsync(It.IsAny(), It.IsAny>())).Returns(new ValueTask(Task.CompletedTask)); + _httpPipelinePolicyMock.Setup(m => m.Process(It.IsAny(), It.IsAny>())).Verifiable(); + _httpMessage.Response = new MockResponse(200); + + // Act & Assert + if (async) + { + await policy.ProcessAsync(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.ProcessAsync(_httpMessage, It.IsAny>()), Times.Once); + } + else + { + policy.Process(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.Process(_httpMessage, It.IsAny>()), Times.Once); + } + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task Process_WithDifferentToken_CallsProcessNext(bool async) + { + // Arrange + var policy = new EntraTokenGuardPolicy(); + var pipelines = new ReadOnlyMemory(new HttpPipelinePolicy[] { _httpPipelinePolicyMock.Object }); + _httpPipelinePolicyMock.Setup(m => m.ProcessAsync(It.IsAny(), It.IsAny>())).Returns(new ValueTask(Task.CompletedTask)); + _httpPipelinePolicyMock.Setup(m => m.Process(It.IsAny(), It.IsAny>())).Verifiable(); + _httpMessage.Response = new MockResponse(200); + // Act & Assert + if (async) + { + await policy.ProcessAsync(_httpMessage, pipelines); + _httpRequest.UpdateAuthHeader("Bearer Token2"); + await policy.ProcessAsync(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.ProcessAsync(_httpMessage, It.IsAny>()), Times.Exactly(2)); + } + else + { + policy.Process(_httpMessage, pipelines); + _httpRequest.UpdateAuthHeader("Bearer Token2"); + policy.Process(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.Process(_httpMessage, It.IsAny>()), Times.Exactly(2)); + } + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task Process_WithInvalidAcsToken_CallsProcessNext(bool async) + { + // Arrange + var policy = new EntraTokenGuardPolicy(); + var pipelines = new ReadOnlyMemory(new HttpPipelinePolicy[] { _httpPipelinePolicyMock.Object }); + _httpPipelinePolicyMock.Setup(m => m.ProcessAsync(It.IsAny(), It.IsAny>())).Returns(new ValueTask(Task.CompletedTask)); + _httpPipelinePolicyMock.Setup(m => m.Process(It.IsAny(), It.IsAny>())).Verifiable(); + var expired = DateTimeOffset.UtcNow.AddMinutes(-1); + var expiredAcsTokenResponse = string.Format(TokenResponseTemplate, "token", expired); + _httpMessage.Response = new MockResponse(200).SetContent(expiredAcsTokenResponse); + // Act & Assert + if (async) + { + await policy.ProcessAsync(_httpMessage, pipelines); + await policy.ProcessAsync(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.ProcessAsync(_httpMessage, It.IsAny>()), Times.Exactly(2)); + } + else + { + policy.Process(_httpMessage, pipelines); + policy.Process(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.Process(_httpMessage, It.IsAny>()), Times.Exactly(2)); + } + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task Process_PrevExchangeCallFailed_CallsProcessNext(bool async) + { + // Arrange + var policy = new EntraTokenGuardPolicy(); + var pipelines = new ReadOnlyMemory(new HttpPipelinePolicy[] { _httpPipelinePolicyMock.Object }); + _httpPipelinePolicyMock.Setup(m => m.ProcessAsync(It.IsAny(), It.IsAny>())).Returns(new ValueTask(Task.CompletedTask)); + _httpPipelinePolicyMock.Setup(m => m.Process(It.IsAny(), It.IsAny>())).Verifiable(); + _httpMessage.Response = new MockResponse(400); + // Act & Assert + if (async) + { + await policy.ProcessAsync(_httpMessage, pipelines); + await policy.ProcessAsync(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.ProcessAsync(_httpMessage, It.IsAny>()), Times.Exactly(2)); + } + else + { + policy.Process(_httpMessage, pipelines); + policy.Process(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.Process(_httpMessage, It.IsAny>()), Times.Exactly(2)); + } + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task ProcessAsync_WithSameToken_DoesNotCallProcessNextAsync(bool async) + { + // Arrange + var policy = new EntraTokenGuardPolicy(); + var pipelines = new ReadOnlyMemory(new HttpPipelinePolicy[] { _httpPipelinePolicyMock.Object }); + var successfullResponse = new MockResponse(200).SetContent(TokenResponse); + _httpMessage.Response = successfullResponse; + _httpPipelinePolicyMock + .Setup(m => m.ProcessAsync(It.IsAny(), It.IsAny>())) + .Returns(new ValueTask(Task.CompletedTask)); + _httpPipelinePolicyMock + .Setup(m => m.Process(It.IsAny(), It.IsAny>())) + .Verifiable(); + // Act & Assert + if (async) + { + await policy.ProcessAsync(_httpMessage, pipelines); + _httpRequest.UpdateAuthHeader(_authHeader); + _httpPipelinePolicyMock + .Setup(m => m.ProcessAsync(It.IsAny(), It.IsAny>())) + .Callback(() => _httpMessage.Response = new MockResponse(400)) + .Returns(new ValueTask(Task.CompletedTask)); + + await policy.ProcessAsync(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.ProcessAsync(_httpMessage, It.IsAny>()), Times.Once); + } + else + { + policy.Process(_httpMessage, pipelines); + _httpRequest.UpdateAuthHeader(_authHeader); + _httpPipelinePolicyMock + .Setup(m => m.Process(It.IsAny(), It.IsAny>())) + .Callback(() => _httpMessage.Response = new MockResponse(400)) + .Verifiable(); + + policy.Process(_httpMessage, pipelines); + _httpPipelinePolicyMock.Verify(p => p.Process(_httpMessage, It.IsAny>()), Times.Once); + } + Assert.AreEqual(successfullResponse, _httpMessage.Response); + } + + private class CustomRequest: MockRequest + { + public CustomRequest(string authHeader) + { + ClientRequestId = Guid.NewGuid().ToString(); + SetHeader("Authorization", authHeader); + } + + public void UpdateAuthHeader(string value) + { + SetHeader("Authorization", value); + } + } + } +}