Skip to content
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
124 changes: 112 additions & 12 deletions core/src/Microsoft.Teams.Core/UserTokenClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ public class UserTokenClient(HttpClient httpClient, IConfiguration configuration
private readonly string _apiEndpoint = configuration["UserTokenApiEndpoint"] ?? "https://token.botframework.com";
private readonly JsonSerializerOptions _defaultOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

internal AgenticIdentity? AgenticIdentity { get; set; }

/// <summary>
/// Gets the token status for each connection for the given user.
/// </summary>
Expand All @@ -39,6 +37,18 @@ public class UserTokenClient(HttpClient httpClient, IConfiguration configuration
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The result contains an array of token status results for each connection.</returns>
public virtual async Task<GetTokenStatusResult[]> GetTokenStatusAsync(string userId, string channelId, string? include = null, CancellationToken cancellationToken = default)
=> await GetTokenStatusAsync(userId, channelId, include, requestContext: null, cancellationToken).ConfigureAwait(false);

/// <summary>
/// Gets the token status for each connection for the given user.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="include">The optional include parameter.</param>
/// <param name="requestContext">Optional per-request properties used for authentication.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The result contains an array of token status results for each connection.</returns>
public virtual async Task<GetTokenStatusResult[]> GetTokenStatusAsync(string userId, string channelId, string? include, BotRequestContext? requestContext, CancellationToken cancellationToken = default)
{
Dictionary<string, string?> queryParams = new()
{
Expand All @@ -56,7 +66,7 @@ public virtual async Task<GetTokenStatusResult[]> GetTokenStatusAsync(string use
"api/usertoken/GetTokenStatus",
queryParams,
body: null,
CreateRequestOptions("getting token status"),
CreateRequestOptions("getting token status", requestContext: requestContext),
cancellationToken).ConfigureAwait(false);

if (result == null || result.Count == 0)
Expand All @@ -77,6 +87,19 @@ public virtual async Task<GetTokenStatusResult[]> GetTokenStatusAsync(string use
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The result contains the token, or null if no token is available.</returns>
public virtual async Task<GetTokenResult?> GetTokenAsync(string userId, string connectionName, string channelId, string? code = null, CancellationToken cancellationToken = default)
=> await GetTokenAsync(userId, connectionName, channelId, code, requestContext: null, cancellationToken).ConfigureAwait(false);

/// <summary>
/// Gets the user token for a particular connection.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="connectionName">The connection name.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="code">The optional code.</param>
/// <param name="requestContext">Optional per-request properties used for authentication.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The result contains the token, or null if no token is available.</returns>
public virtual async Task<GetTokenResult?> GetTokenAsync(string userId, string connectionName, string channelId, string? code, BotRequestContext? requestContext, CancellationToken cancellationToken = default)
{
Dictionary<string, string?> queryParams = new()
{
Expand All @@ -96,7 +119,7 @@ public virtual async Task<GetTokenStatusResult[]> GetTokenStatusAsync(string use
"api/usertoken/GetToken",
queryParams,
body: null,
CreateRequestOptions("getting token", returnNullOnNotFound: true),
CreateRequestOptions("getting token", returnNullOnNotFound: true, requestContext: requestContext),
cancellationToken).ConfigureAwait(false);
}

Expand All @@ -111,6 +134,20 @@ public virtual async Task<GetTokenStatusResult[]> GetTokenStatusAsync(string use
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The result contains the sign-in resource with the sign-in link and token exchange information.</returns>
public virtual Task<GetSignInResourceResult> GetSignInResourceAsync(string userId, string connectionName, string channelId, string? finalRedirect = null, CancellationToken cancellationToken = default)
=> GetSignInResourceAsync(userId, connectionName, channelId, finalRedirect, requestContext: null, cancellationToken);

/// <summary>
/// Get the token or raw signin link to be sent to the user for signin for a connection.
/// Builds the state parameter internally from the userId and connectionName.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="connectionName">The connection name.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="finalRedirect">The optional final redirect URL.</param>
/// <param name="requestContext">Optional per-request properties used for authentication.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The result contains the sign-in resource with the sign-in link and token exchange information.</returns>
public virtual Task<GetSignInResourceResult> GetSignInResourceAsync(string userId, string connectionName, string channelId, string? finalRedirect, BotRequestContext? requestContext, CancellationToken cancellationToken = default)
{
var tokenExchangeState = new
{
Expand All @@ -124,7 +161,7 @@ public virtual Task<GetSignInResourceResult> GetSignInResourceAsync(string userI
string state = Convert.ToBase64String(Encoding.UTF8.GetBytes(tokenExchangeStateJson));

Uri? finalRedirectUri = finalRedirect is not null ? new Uri(finalRedirect) : null;
return GetSignInResourceAsync(state, finalRedirect: finalRedirectUri, cancellationToken: cancellationToken);
return GetSignInResourceAsync(state, codeChallenge: null, emulatorUrl: null, finalRedirect: finalRedirectUri, requestContext: requestContext, cancellationToken: cancellationToken);
}

/// <summary>
Expand All @@ -137,6 +174,19 @@ public virtual Task<GetSignInResourceResult> GetSignInResourceAsync(string userI
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The sign-in URL, or null if not available.</returns>
public virtual async Task<string?> GetSignInUrlAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default)
=> await GetSignInUrlAsync(state, codeChallenge, emulatorUrl, finalRedirect, requestContext: null, cancellationToken).ConfigureAwait(false);

/// <summary>
/// Gets the sign-in URL for the given state.
/// </summary>
/// <param name="state">The encoded state parameter.</param>
/// <param name="codeChallenge">The optional code challenge for PKCE.</param>
/// <param name="emulatorUrl">The optional emulator URL.</param>
/// <param name="finalRedirect">The optional final redirect URL.</param>
/// <param name="requestContext">Optional per-request properties used for authentication.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The sign-in URL, or null if not available.</returns>
public virtual async Task<string?> GetSignInUrlAsync(string state, string? codeChallenge, Uri? emulatorUrl, Uri? finalRedirect, BotRequestContext? requestContext, CancellationToken cancellationToken = default)
{
Dictionary<string, string?> queryParams = new() { { "state", state } };

Expand All @@ -153,7 +203,7 @@ public virtual Task<GetSignInResourceResult> GetSignInResourceAsync(string userI
"api/botsignin/GetSignInUrl",
queryParams,
body: null,
CreateRequestOptions("getting sign-in URL"),
CreateRequestOptions("getting sign-in URL", requestContext: requestContext),
cancellationToken).ConfigureAwait(false);
}

Expand All @@ -167,6 +217,19 @@ public virtual Task<GetSignInResourceResult> GetSignInResourceAsync(string userI
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The sign-in resource result.</returns>
public virtual async Task<GetSignInResourceResult> GetSignInResourceAsync(string state, string? codeChallenge = null, Uri? emulatorUrl = null, Uri? finalRedirect = null, CancellationToken cancellationToken = default)
=> await GetSignInResourceAsync(state, codeChallenge, emulatorUrl, finalRedirect, requestContext: null, cancellationToken).ConfigureAwait(false);

/// <summary>
/// Gets the sign-in resource for the given state.
/// </summary>
/// <param name="state">The encoded state parameter.</param>
/// <param name="codeChallenge">The optional code challenge for PKCE.</param>
/// <param name="emulatorUrl">The optional emulator URL.</param>
/// <param name="finalRedirect">The optional final redirect URL.</param>
/// <param name="requestContext">Optional per-request properties used for authentication.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The sign-in resource result.</returns>
public virtual async Task<GetSignInResourceResult> GetSignInResourceAsync(string state, string? codeChallenge, Uri? emulatorUrl, Uri? finalRedirect, BotRequestContext? requestContext, CancellationToken cancellationToken = default)
{
Dictionary<string, string?> queryParams = new() { { "state", state } };

Expand All @@ -183,7 +246,7 @@ public virtual async Task<GetSignInResourceResult> GetSignInResourceAsync(string
"api/botsignin/GetSignInResource",
queryParams,
body: null,
CreateRequestOptions("getting sign-in resource"),
CreateRequestOptions("getting sign-in resource", requestContext: requestContext),
cancellationToken).ConfigureAwait(false))!;
}

Expand All @@ -196,6 +259,18 @@ public virtual async Task<GetSignInResourceResult> GetSignInResourceAsync(string
/// <param name="exchangeToken">The token to exchange.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public virtual async Task<GetTokenResult> ExchangeTokenAsync(string userId, string connectionName, string channelId, string? exchangeToken, CancellationToken cancellationToken = default)
=> await ExchangeTokenAsync(userId, connectionName, channelId, exchangeToken, requestContext: null, cancellationToken).ConfigureAwait(false);

/// <summary>
/// Exchanges a token for another token.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="connectionName">The connection name.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="exchangeToken">The token to exchange.</param>
/// <param name="requestContext">Optional per-request properties used for authentication.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public virtual async Task<GetTokenResult> ExchangeTokenAsync(string userId, string connectionName, string channelId, string? exchangeToken, BotRequestContext? requestContext, CancellationToken cancellationToken = default)
{
Dictionary<string, string?> queryParams = new()
{
Expand All @@ -215,7 +290,7 @@ public virtual async Task<GetTokenResult> ExchangeTokenAsync(string userId, stri
"api/usertoken/exchange",
queryParams,
JsonSerializer.Serialize(tokenExchangeRequest),
CreateRequestOptions("exchanging token"),
CreateRequestOptions("exchanging token", requestContext: requestContext),
cancellationToken).ConfigureAwait(false))!;
}

Expand All @@ -228,6 +303,18 @@ public virtual async Task<GetTokenResult> ExchangeTokenAsync(string userId, stri
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous sign-out operation.</returns>
public virtual async Task SignOutUserAsync(string userId, string? connectionName = null, string? channelId = null, CancellationToken cancellationToken = default)
=> await SignOutUserAsync(userId, connectionName, channelId, requestContext: null, cancellationToken).ConfigureAwait(false);

/// <summary>
/// Signs the user out of a connection, revoking their OAuth token.
/// </summary>
/// <param name="userId">The unique identifier of the user to sign out. Cannot be null or empty.</param>
/// <param name="connectionName">Optional name of the OAuth connection to sign out from. If null, signs out from all connections.</param>
/// <param name="channelId">Optional channel identifier. If provided, limits sign-out to tokens for this channel.</param>
/// <param name="requestContext">Optional per-request properties used for authentication.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous sign-out operation.</returns>
public virtual async Task SignOutUserAsync(string userId, string? connectionName, string? channelId, BotRequestContext? requestContext, CancellationToken cancellationToken = default)
{
Dictionary<string, string?> queryParams = new()
{
Expand All @@ -250,7 +337,7 @@ await _botHttpClient.SendAsync(
"api/usertoken/SignOut",
queryParams,
body: null,
CreateRequestOptions("signing out user"),
CreateRequestOptions("signing out user", requestContext: requestContext),
cancellationToken).ConfigureAwait(false);
}

Expand All @@ -264,6 +351,19 @@ await _botHttpClient.SendAsync(
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The result contains a dictionary mapping resource URLs to their token results.</returns>
public virtual async Task<IDictionary<string, GetTokenResult>> GetAadTokensAsync(string userId, string connectionName, string channelId, string[]? resourceUrls = null, CancellationToken cancellationToken = default)
=> await GetAadTokensAsync(userId, connectionName, channelId, resourceUrls, requestContext: null, cancellationToken).ConfigureAwait(false);

/// <summary>
/// Gets AAD tokens for a user.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="connectionName">The connection name.</param>
/// <param name="channelId">The channel ID.</param>
/// <param name="resourceUrls">The resource URLs.</param>
/// <param name="requestContext">Optional per-request properties used for authentication.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The result contains a dictionary mapping resource URLs to their token results.</returns>
public virtual async Task<IDictionary<string, GetTokenResult>> GetAadTokensAsync(string userId, string connectionName, string channelId, string[]? resourceUrls, BotRequestContext? requestContext, CancellationToken cancellationToken = default)
{
var body = new
{
Expand All @@ -279,14 +379,14 @@ public virtual async Task<IDictionary<string, GetTokenResult>> GetAadTokensAsync
"api/usertoken/GetAadTokens",
queryParams: null,
JsonSerializer.Serialize(body),
CreateRequestOptions("getting AAD tokens"),
CreateRequestOptions("getting AAD tokens", requestContext: requestContext),
cancellationToken).ConfigureAwait(false))!;
}

private BotRequestOptions CreateRequestOptions(string operationDescription, bool returnNullOnNotFound = false) =>
private static BotRequestOptions CreateRequestOptions(string operationDescription, bool returnNullOnNotFound = false, BotRequestContext? requestContext = null) =>
new()
{
RequestContext = BotRequestContext.FromAgenticIdentity(AgenticIdentity),
RequestContext = requestContext,
OperationDescription = operationDescription,
ReturnNullOnNotFound = returnNullOnNotFound
};
Expand Down
67 changes: 67 additions & 0 deletions core/test/Microsoft.Teams.Core.UnitTests/UserTokenClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Teams.Core.Http;
using Microsoft.Teams.Core.Schema;

namespace Microsoft.Teams.Core.UnitTests;

public class UserTokenClientTests
{
[Fact]
public async Task GetTokenAsync_WithRequestContext_StampsRequestOptions()
{
CapturingHandler handler = new();
UserTokenClient client = CreateClient(handler);
AgenticIdentity identity = new()
{
AgenticAppId = "agentic-app",
AgenticUserId = "agentic-user",
AgenticAppBlueprintId = "agentic-blueprint",
};
BotRequestContext requestContext = new()
{
AgenticIdentity = identity,
BotAppId = "bot-app",
};

await client.GetTokenAsync("user", "connection", "msteams", code: null, requestContext);

Assert.NotNull(handler.Request);
Assert.True(handler.Request.Options.TryGetValue(new HttpRequestOptionsKey<object?>(BotRequestContext.AgenticIdentityKey), out object? identityValue));
Assert.Same(identity, identityValue);
Assert.True(handler.Request.Options.TryGetValue(new HttpRequestOptionsKey<object?>(BotRequestContext.BotAppIdKey), out object? botAppIdValue));
Assert.Equal("bot-app", botAppIdValue);
}

private static UserTokenClient CreateClient(HttpMessageHandler handler)
{
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["UserTokenApiEndpoint"] = "https://token.test"
})
.Build();

return new UserTokenClient(new HttpClient(handler), configuration, NullLogger<UserTokenClient>.Instance);
}

private sealed class CapturingHandler : HttpMessageHandler
{
public HttpRequestMessage? Request { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
HttpResponseMessage response = new(HttpStatusCode.OK)
{
Content = new StringContent("""{"connectionName":"connection","token":"token"}""", Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
}