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 7771a624e260..7308319eb436 100644 --- a/sdk/communication/Azure.Communication.Common/README.md +++ b/sdk/communication/Azure.Communication.Common/README.md @@ -105,24 +105,46 @@ 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: - +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(""), - AuthorityHost = new Uri("https://login.microsoftonline.com/") + RedirectUri = new Uri("") }; var entraTokenCredential = new InteractiveBrowserCredential(options); var entraTokenCredentialOptions = new EntraCommunicationTokenCredentialOptions( resourceEndpoint: "https://.communication.azure.com", - entraTokenCredential: entraTokenCredential -){ + 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); diff --git a/sdk/communication/Azure.Communication.Common/src/EntraTokenCredential.cs b/sdk/communication/Azure.Communication.Common/src/EntraTokenCredential.cs index 9660851235db..90567bb6e524 100644 --- a/sdk/communication/Azure.Communication.Common/src/EntraTokenCredential.cs +++ b/sdk/communication/Azure.Communication.Common/src/EntraTokenCredential.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -15,8 +16,16 @@ namespace Azure.Communication /// 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; /// @@ -27,6 +36,7 @@ internal sealed class EntraTokenCredential : ICommunicationTokenCredential public EntraTokenCredential(EntraCommunicationTokenCredentialOptions options, HttpPipelineTransport pipelineTransport = null) { this._resourceEndpoint = options.ResourceEndpoint; + this._scopes = options.Scopes; _pipeline = CreatePipelineFromOptions(options, pipelineTransport); _accessTokenCache = new ThreadSafeRefreshableAccessTokenCache( ExchangeEntraToken, @@ -99,14 +109,9 @@ private async ValueTask ExchangeEntraTokenAsync(bool async, Cancell private HttpMessage CreateRequestMessage() { - var uri = new RequestUriBuilder(); - uri.Reset(new Uri(_resourceEndpoint)); - uri.AppendPath("/access/entra/:exchangeAccessToken", false); - uri.AppendQuery("api-version", "2024-04-01-preview", true); - var message = _pipeline.CreateMessage(); var request = message.Request; - request.Uri = uri; + request.Uri = CreateRequestUri(); request.Method = RequestMethod.Post; request.Headers.Add("Accept", "application/json"); request.Headers.Add("Content-Type", "application/json"); @@ -115,6 +120,37 @@ private HttpMessage CreateRequestMessage() 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) diff --git a/sdk/communication/Azure.Communication.Common/tests/Identity/EntraTokenCredentialTest.cs b/sdk/communication/Azure.Communication.Common/tests/Identity/EntraTokenCredentialTest.cs index c7cea3fb737d..f07ef406b4f6 100644 --- a/sdk/communication/Azure.Communication.Common/tests/Identity/EntraTokenCredentialTest.cs +++ b/sdk/communication/Azure.Communication.Common/tests/Identity/EntraTokenCredentialTest.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Azure.Core; @@ -24,9 +25,26 @@ public class EntraTokenCredentialTest protected string TokenResponse = string.Format(TokenResponseTemplate, SampleToken, SampleTokenExpiry); private Mock _mockTokenCredential = null!; - private string[] _scopes = new string[] { "https://communication.azure.com/clients/VoIP" }; + 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() { @@ -37,28 +55,28 @@ public void Setup() .ReturnsAsync(new AccessToken(SampleToken, expiryTime)); } - [Test] - public void EntraTokenCredential_Init_ThrowsErrorWithNulls() + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_Init_ThrowsErrorWithNulls(string[] scopes) { Assert.Throws(() => new EntraCommunicationTokenCredentialOptions( null, _mockTokenCredential.Object) { - Scopes = _scopes + Scopes = scopes }); Assert.Throws(() => new EntraCommunicationTokenCredentialOptions( "", _mockTokenCredential.Object) { - Scopes = _scopes + Scopes = scopes }); Assert.Throws(() => new EntraCommunicationTokenCredentialOptions( _resourceEndpoint, null) { - Scopes = _scopes + Scopes = scopes }); } @@ -72,25 +90,25 @@ public void EntraTokenCredential_InitWithoutScopes_InitsWithDefaultScope() Assert.AreEqual(credential.Scopes, scopes); } - [Test] - public void EntraTokenCredential_Init_FetchesTokenImmediately() + [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(); + 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] - public async Task EntraTokenCredential_GetToken_ReturnsToken() + [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(); - var mockTransport = CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse) }); + var options = CreateEntraTokenCredentialOptions(scopes); + var mockTransport = (MockTransport) CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse) }); var entraTokenCredential = new EntraTokenCredential(options, mockTransport); // Act @@ -99,11 +117,38 @@ public async Task EntraTokenCredential_GetToken_ReturnsToken() // 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_GetToken_InternalEntraTokenChangeInvalidatesCachedToken() + 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); @@ -114,7 +159,7 @@ public async Task EntraTokenCredential_GetToken_InternalEntraTokenChangeInvalida .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(); + 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); @@ -126,11 +171,11 @@ public async Task EntraTokenCredential_GetToken_InternalEntraTokenChangeInvalida _mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); } - [Test] - public async Task EntraTokenCredential_GetToken_MultipleCallsReturnsCachedToken() + [Test, TestCaseSource(nameof(validScopes))] + public async Task EntraTokenCredential_GetToken_MultipleCallsReturnsCachedToken(string[] scopes) { // Arrange - var options = CreateEntraTokenCredentialOptions(); + var options = CreateEntraTokenCredentialOptions(scopes); var mockTransport = CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse) }); var entraTokenCredential = new EntraTokenCredential(options, mockTransport); @@ -145,11 +190,11 @@ public async Task EntraTokenCredential_GetToken_MultipleCallsReturnsCachedToken( _mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); } - [Test] - public void EntraTokenCredential_GetToken_ThrowsFailedResponse() + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_GetToken_ThrowsFailedResponse(string[] scopes) { // Arrange - var options = CreateEntraTokenCredentialOptions(); + var options = CreateEntraTokenCredentialOptions(scopes); var errorMessage = "{\"error\":{\"code\":\"BadRequest\",\"message\":\"Invalid request.\"}}"; var mockResponses = new[] { @@ -163,11 +208,11 @@ public void EntraTokenCredential_GetToken_ThrowsFailedResponse() Assert.ThrowsAsync(async () => await entraTokenCredential.GetTokenAsync(CancellationToken.None)); } - [Test] - public void EntraTokenCredential_GetToken_ThrowsInvalidJson() + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_GetToken_ThrowsInvalidJson(string[] scopes) { // Arrange - var options = CreateEntraTokenCredentialOptions(); + var options = CreateEntraTokenCredentialOptions(scopes); var errorMessage = "{\"error\":{\"code\":\"BadRequest\",\"message\":\"Invalid request.\"}}"; var mockResponses = new[] { @@ -182,11 +227,11 @@ public void EntraTokenCredential_GetToken_ThrowsInvalidJson() Assert.ThrowsAsync(async () => await entraTokenCredential.GetTokenAsync(CancellationToken.None)); } - [Test] - public void EntraTokenCredential_GetToken_RetriesThreeTimesOnTransientError() + [Test, TestCaseSource(nameof(validScopes))] + public void EntraTokenCredential_GetToken_RetriesThreeTimesOnTransientError(string[] scopes) { // Arrange - var options = CreateEntraTokenCredentialOptions(); + var options = CreateEntraTokenCredentialOptions(scopes); var lastRetryErrorMessage = "Last Retry Error Message"; var mockResponses = new MockResponse[] { @@ -205,21 +250,39 @@ public void EntraTokenCredential_GetToken_RetriesThreeTimesOnTransientError() var entraTokenCredential = new EntraTokenCredential(options, mockTransport); // Act & Assert - var exception = Assert.ThrowsAsync(async () => await entraTokenCredential.GetTokenAsync(CancellationToken.None)); - Assert.AreEqual(lastRetryErrorMessage, lastRetryErrorMessage); + 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() + private EntraCommunicationTokenCredentialOptions CreateEntraTokenCredentialOptions(string[] scopes) { return new EntraCommunicationTokenCredentialOptions(_resourceEndpoint, _mockTokenCredential.Object) { - Scopes = _scopes + Scopes = scopes }; } private MockResponse CreateMockResponse(int statusCode, string body) { - return new MockResponse(statusCode).WithContent(body); + return new MockResponse(statusCode).WithJson(body); } private HttpPipelineTransport CreateMockTransport(MockResponse[] mockResponses)