Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[OPS Common SDK Alfa] Add auth credential for Teams Phone #48449

Merged
merged 4 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 49 additions & 0 deletions sdk/communication/Azure.Communication.Common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 = "<your-tenant-id>",
ClientId = "<your-client-id>",
RedirectUri = new Uri("<your-redirect-uri>")
};
var entraTokenCredential = new InteractiveBrowserCredential(options);

var entraTokenCredentialOptions = new EntraCommunicationTokenCredentialOptions(
resourceEndpoint: "https://<your-resource>.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 = "<your-tenant-id>",
ClientId = "<your-client-id>",
RedirectUri = new Uri("<your-redirect-uri>")
};
var entraTokenCredential = new InteractiveBrowserCredential(options);

var entraTokenCredentialOptions = new EntraCommunicationTokenCredentialOptions(
resourceEndpoint: "https://<your-resource>.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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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?)) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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?)) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ public CommunicationTokenCredential(CommunicationTokenRefreshOptions options)
_tokenCredential = new AutoRefreshTokenCredential(options);
}

/// <summary>
/// Initializes a new instance of <see cref="CommunicationTokenCredential"/>.
/// </summary>
/// <param name="options">The options for how the token will be fetched</param>
public CommunicationTokenCredential(EntraCommunicationTokenCredentialOptions options)
{
Argument.AssertNotNull(options, nameof(options));
_tokenCredential = new EntraTokenCredential(options);
}

/// <summary>
/// Gets an <see cref="AccessToken"/> for the user.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using Azure.Core;

namespace Azure.Communication
{
/// <summary>
/// The Entra Communication Token Options.
/// </summary>
public class EntraCommunicationTokenCredentialOptions
{
private static string[] DefaultScopes = { "https://communication.azure.com/clients/.default" };
/// <summary>
/// The URI of the Azure Communication Services resource.
/// </summary>
public string ResourceEndpoint { get; }

/// <summary>
/// The credential capable of fetching an Entra user token. You can provide any implementation of <see cref="Azure.Core.TokenCredential"/>.
/// </summary>
public TokenCredential TokenCredential { get; }

/// <summary>
/// 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"].
/// </summary>
public string[] Scopes { get; set; }

/// <summary>
/// Initializes a new instance of <see cref="EntraCommunicationTokenCredentialOptions"/>.
/// </summary>
/// <param name="resourceEndpoint">The URI of the Azure Communication Services resource.For example, https://myResource.communication.azure.com.</param>
/// <param name="entraTokenCredential">The credential capable of fetching an Entra user token.</param>
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Represents a credential that exchanges an Entra token for an Azure Communication Services (ACS) token, enabling access to ACS resources.
/// </summary>
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;

/// <summary>
/// Initializes a new instance of <see cref="EntraTokenCredential"/>.
/// </summary>
/// <param name="options">The options for how the token will be fetched</param>
/// <param name="pipelineTransport">Only for testing.</param>
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);
}

/// <inheritdoc />
public void Dispose()
{
_pipeline = default;
_accessTokenCache.Dispose();
}

/// <summary>
/// Gets an <see cref="AccessToken"/>.
/// </summary>
/// <param name="cancellationToken">The cancellation token for the task.</param>
/// <returns> Contains the access token.</returns>
public AccessToken GetToken(CancellationToken cancellationToken = default)
=> _accessTokenCache.GetValue(cancellationToken, () => true);

/// <summary>
/// Gets an <see cref="AccessToken"/>.
/// </summary>
/// <param name="cancellationToken">The cancellation token for the task.</param>
/// <returns>
/// A task that represents the asynchronous get token operation. The value of its <see cref="ValueTask{AccessToken}.Result"/> property contains the access token.
/// </returns>
public ValueTask<AccessToken> GetTokenAsync(CancellationToken cancellationToken = default)
=> _accessTokenCache.GetValueAsync(cancellationToken, () => true);

private AccessToken ExchangeEntraToken(CancellationToken cancellationToken)
{
return ExchangeEntraTokenAsync(false, cancellationToken).EnsureCompleted();
}

private async ValueTask<AccessToken> ExchangeEntraTokenAsync(CancellationToken cancellationToken)
{
var result = await ExchangeEntraTokenAsync(true, cancellationToken).ConfigureAwait(false);
return result;
}

private async ValueTask<AccessToken> 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<AcsToken>(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; }
}
}
}
Loading