Skip to content

Commit de7abb8

Browse files
Update token exchange API routes to be called, prevent update of scop… (Azure#49099)
* Update token exchange API routes to be called, prevent update of scopes through options Update route and api version in token exchange API called by credential routes to be consistent by REST API review. Make sure that update of scopes in EntraCommunicationTokenCredential won't affect already constructed EntraTokenCredential. * Fixed typos
1 parent 349ed73 commit de7abb8

File tree

2 files changed

+56
-20
lines changed

2 files changed

+56
-20
lines changed

sdk/communication/Azure.Communication.Common/src/EntraTokenCredential.cs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.Linq;
67
using System.Text.Json;
78
using System.Threading;
@@ -17,15 +18,15 @@ namespace Azure.Communication
1718
internal sealed class EntraTokenCredential : ICommunicationTokenCredential
1819
{
1920
private const string TeamsExtensionScopePrefix = "https://auth.msft.communication.azure.com/";
20-
private const string ComunicationClientsScopePrefix = "https://communication.azure.com/clients/";
21-
private const string TeamsExtensionEndpoint = "/access/teamsPhone/:exchangeAccessToken";
21+
private const string CommunicationClientsScopePrefix = "https://communication.azure.com/clients/";
22+
private const string TeamsExtensionEndpoint = "/access/teamsExtension/:exchangeAccessToken";
2223
private const string TeamsExtensionApiVersion = "2025-03-02-preview";
23-
private const string ComunicationClientsEndpoint = "/access/entra/:exchangeAccessToken";
24-
private const string ComunicationClientsApiVersion = "2024-04-01-preview";
24+
private const string CommunicationClientsEndpoint = "/access/entra/:exchangeAccessToken";
25+
private const string CommunicationClientsApiVersion = "2025-03-02-preview";
2526

2627
private HttpPipeline _pipeline;
2728
private string _resourceEndpoint;
28-
private string[] _scopes { get; set; }
29+
private ICollection<string> _scopes { get; }
2930
private readonly ThreadSafeRefreshableAccessTokenCache _accessTokenCache;
3031

3132
/// <summary>
@@ -36,7 +37,7 @@ internal sealed class EntraTokenCredential : ICommunicationTokenCredential
3637
public EntraTokenCredential(EntraCommunicationTokenCredentialOptions options, HttpPipelineTransport pipelineTransport = null)
3738
{
3839
this._resourceEndpoint = options.ResourceEndpoint;
39-
this._scopes = options.Scopes;
40+
this._scopes = (ICollection<string>)options.Scopes.Clone();
4041
_pipeline = CreatePipelineFromOptions(options, pipelineTransport);
4142
_accessTokenCache = new ThreadSafeRefreshableAccessTokenCache(
4243
ExchangeEntraToken,
@@ -47,7 +48,7 @@ public EntraTokenCredential(EntraCommunicationTokenCredentialOptions options, Ht
4748

4849
private HttpPipeline CreatePipelineFromOptions(EntraCommunicationTokenCredentialOptions options, HttpPipelineTransport pipelineTransport)
4950
{
50-
var authenticationPolicy = new BearerTokenAuthenticationPolicy(options.TokenCredential, options.Scopes);
51+
var authenticationPolicy = new BearerTokenAuthenticationPolicy(options.TokenCredential, _scopes);
5152
var entraTokenGuardPolicy = new EntraTokenGuardPolicy();
5253
var clientOptions = ClientOptions.Default;
5354
if (pipelineTransport != null)
@@ -135,19 +136,19 @@ private RequestUriBuilder CreateRequestUri()
135136
{
136137
if (_scopes == null || !_scopes.Any())
137138
{
138-
throw new ArgumentException($"Scopes validation failed. Ensure all scopes start with either {TeamsExtensionScopePrefix} or {ComunicationClientsScopePrefix}.", nameof(_scopes));
139+
throw new ArgumentException($"Scopes validation failed. Ensure all scopes start with either {TeamsExtensionScopePrefix} or {CommunicationClientsScopePrefix}.", nameof(_scopes));
139140
}
140141
else if (_scopes.All(item => item.StartsWith(TeamsExtensionScopePrefix)))
141142
{
142143
return (TeamsExtensionEndpoint, TeamsExtensionApiVersion);
143144
}
144-
else if (_scopes.All(item => item.StartsWith(ComunicationClientsScopePrefix)))
145+
else if (_scopes.All(item => item.StartsWith(CommunicationClientsScopePrefix)))
145146
{
146-
return (ComunicationClientsEndpoint, ComunicationClientsApiVersion);
147+
return (CommunicationClientsEndpoint, CommunicationClientsApiVersion);
147148
}
148149
else
149150
{
150-
throw new ArgumentException($"Scopes validation failed. Ensure all scopes start with either {TeamsExtensionScopePrefix} or {ComunicationClientsScopePrefix}.", nameof(_scopes));
151+
throw new ArgumentException($"Scopes validation failed. Ensure all scopes start with either {TeamsExtensionScopePrefix} or {CommunicationClientsScopePrefix}.", nameof(_scopes));
151152
}
152153
}
153154

sdk/communication/Azure.Communication.Common/tests/Identity/EntraTokenCredentialTest.cs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ public class EntraTokenCredentialTest
2525
protected string TokenResponse = string.Format(TokenResponseTemplate, SampleToken, SampleTokenExpiry);
2626

2727
private Mock<TokenCredential> _mockTokenCredential = null!;
28-
private const string comunicationClientsEndpoint = "/access/entra/:exchangeAccessToken";
29-
private const string communicationClientsScope = "https://communication.azure.com/clients/VoIP";
30-
private const string teamsExtensionEndpoint = "/access/teamsPhone/:exchangeAccessToken";
28+
private const string communicationClientsEndpoint = "/access/entra/:exchangeAccessToken";
29+
private const string communicationClientsPrefix = "https://communication.azure.com/clients/";
30+
private const string communicationClientsScope = communicationClientsPrefix + "VoIP";
31+
private const string teamsExtensionEndpoint = "/access/teamsExtension/:exchangeAccessToken";
3132
private const string teamsExtensionScope = "https://auth.msft.communication.azure.com/TeamsExtension.ManageCalls";
3233
private string _resourceEndpoint = "https://myResource.communication.azure.com";
3334

@@ -123,13 +124,13 @@ public async Task EntraTokenCredential_GetToken_ReturnsToken(string[] scopes)
123124
}
124125
else
125126
{
126-
Assert.AreEqual(comunicationClientsEndpoint, mockTransport.SingleRequest.Uri.Path);
127+
Assert.AreEqual(communicationClientsEndpoint, mockTransport.SingleRequest.Uri.Path);
127128
}
128129
_mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()), Times.Once);
129130
}
130131

131132
[Test]
132-
public async Task EntraTokenCredential_InitWithoutScopes_ReturnsComunicationClientsToken()
133+
public async Task EntraTokenCredential_InitWithoutScopes_ReturnsCommunicationClientsToken()
133134
{
134135
// Arrange
135136
var expiryTime = DateTimeOffset.Parse(SampleTokenExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind);
@@ -143,7 +144,7 @@ public async Task EntraTokenCredential_InitWithoutScopes_ReturnsComunicationClie
143144
// Assert
144145
Assert.AreEqual(SampleToken, token.Token);
145146
Assert.AreEqual(token.ExpiresOn, expiryTime);
146-
Assert.AreEqual(comunicationClientsEndpoint, mockTransport.SingleRequest.Uri.Path);
147+
Assert.AreEqual(communicationClientsEndpoint, mockTransport.SingleRequest.Uri.Path);
147148
_mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()), Times.Once);
148149
}
149150

@@ -152,18 +153,20 @@ public async Task EntraTokenCredential_GetToken_InternalEntraTokenChangeInvalida
152153
{
153154
// Arrange
154155
var expiryTime = DateTimeOffset.Parse(SampleTokenExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind);
155-
var newToken = "newToken";
156156
var refreshOn = DateTimeOffset.Now;
157+
_mockTokenCredential.Reset();
157158
_mockTokenCredential
158159
.SetupSequence(tc => tc.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
159160
.ReturnsAsync(new AccessToken("Entra token for call from constructor", refreshOn))
160161
.ReturnsAsync(new AccessToken("Entra token for the first getToken call token", expiryTime));
161162

162-
var options = CreateEntraTokenCredentialOptions(scopes);
163+
var newToken = "newToken";
163164
var latestTokenResponse = string.Format(TokenResponseTemplate, newToken, SampleTokenExpiry);
164165
var mockTransport = CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse), CreateMockResponse(200, latestTokenResponse) });
165-
var entraTokenCredential = new EntraTokenCredential(options, mockTransport);
166+
var options = CreateEntraTokenCredentialOptions(scopes);
167+
166168
// Act
169+
var entraTokenCredential = new EntraTokenCredential(options, mockTransport);
167170
var token = await entraTokenCredential.GetTokenAsync(CancellationToken.None);
168171

169172
// Assert for cached tokens are updated
@@ -272,6 +275,38 @@ public void EntraTokenCredential_GetToken_ThrowsForInvalidScopes(string[] scopes
272275
StringAssert.Contains("Scopes validation failed. Ensure all scopes start with either", ex?.Message);
273276
}
274277

278+
[Test]
279+
public async Task EntraTokenCredential_GetToken_NoIssueIfScopesChanged()
280+
{
281+
var scopes = new string[] { communicationClientsScope, communicationClientsPrefix + "Chat" };
282+
283+
// Arrange
284+
_mockTokenCredential.Reset();
285+
var expiryTime = DateTimeOffset.Parse(SampleTokenExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind);
286+
var refreshOn = DateTimeOffset.Now;
287+
_mockTokenCredential
288+
.SetupSequence(tc => tc.GetTokenAsync(It.Is<TokenRequestContext>(c => c.Scopes.SequenceEqual(scopes)), It.IsAny<CancellationToken>()))
289+
.ReturnsAsync(new AccessToken("Entra token for call from constructor", refreshOn))
290+
.ReturnsAsync(new AccessToken("Entra token for the first getToken call token", expiryTime));
291+
292+
var newToken = "newToken";
293+
var latestTokenResponse = string.Format(TokenResponseTemplate, newToken, SampleTokenExpiry);
294+
var mockTransport = CreateMockTransport(new[] { CreateMockResponse(200, TokenResponse), CreateMockResponse(200, latestTokenResponse) });
295+
296+
var options = CreateEntraTokenCredentialOptions((string[])scopes.Clone());
297+
298+
// Act
299+
var entraTokenCredential = new EntraTokenCredential(options, mockTransport);
300+
301+
// Change scopes
302+
options.Scopes[1] = teamsExtensionScope;
303+
var token = await entraTokenCredential.GetTokenAsync(CancellationToken.None);
304+
305+
// Assert for cached tokens are updated
306+
Assert.AreEqual(newToken, token.Token);
307+
_mockTokenCredential.Verify(tc => tc.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
308+
}
309+
275310
private EntraCommunicationTokenCredentialOptions CreateEntraTokenCredentialOptions(string[] scopes)
276311
{
277312
return new EntraCommunicationTokenCredentialOptions(_resourceEndpoint, _mockTokenCredential.Object)

0 commit comments

Comments
 (0)