diff --git a/core/src/Microsoft.Teams.Core/UserTokenClient.cs b/core/src/Microsoft.Teams.Core/UserTokenClient.cs index 80270fb7..8bcbaef4 100644 --- a/core/src/Microsoft.Teams.Core/UserTokenClient.cs +++ b/core/src/Microsoft.Teams.Core/UserTokenClient.cs @@ -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; } - /// /// Gets the token status for each connection for the given user. /// @@ -39,6 +37,18 @@ public class UserTokenClient(HttpClient httpClient, IConfiguration configuration /// The cancellation token. /// A task that represents the asynchronous operation. The result contains an array of token status results for each connection. public virtual async Task GetTokenStatusAsync(string userId, string channelId, string? include = null, CancellationToken cancellationToken = default) + => await GetTokenStatusAsync(userId, channelId, include, requestContext: null, cancellationToken).ConfigureAwait(false); + + /// + /// Gets the token status for each connection for the given user. + /// + /// The user ID. + /// The channel ID. + /// The optional include parameter. + /// Optional per-request properties used for authentication. + /// The cancellation token. + /// A task that represents the asynchronous operation. The result contains an array of token status results for each connection. + public virtual async Task GetTokenStatusAsync(string userId, string channelId, string? include, BotRequestContext? requestContext, CancellationToken cancellationToken = default) { Dictionary queryParams = new() { @@ -56,7 +66,7 @@ public virtual async Task 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) @@ -77,6 +87,19 @@ public virtual async Task GetTokenStatusAsync(string use /// The cancellation token. /// A task that represents the asynchronous operation. The result contains the token, or null if no token is available. public virtual async Task GetTokenAsync(string userId, string connectionName, string channelId, string? code = null, CancellationToken cancellationToken = default) + => await GetTokenAsync(userId, connectionName, channelId, code, requestContext: null, cancellationToken).ConfigureAwait(false); + + /// + /// Gets the user token for a particular connection. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The optional code. + /// Optional per-request properties used for authentication. + /// The cancellation token. + /// A task that represents the asynchronous operation. The result contains the token, or null if no token is available. + public virtual async Task GetTokenAsync(string userId, string connectionName, string channelId, string? code, BotRequestContext? requestContext, CancellationToken cancellationToken = default) { Dictionary queryParams = new() { @@ -96,7 +119,7 @@ public virtual async Task GetTokenStatusAsync(string use "api/usertoken/GetToken", queryParams, body: null, - CreateRequestOptions("getting token", returnNullOnNotFound: true), + CreateRequestOptions("getting token", returnNullOnNotFound: true, requestContext: requestContext), cancellationToken).ConfigureAwait(false); } @@ -111,6 +134,20 @@ public virtual async Task GetTokenStatusAsync(string use /// The cancellation token. /// A task that represents the asynchronous operation. The result contains the sign-in resource with the sign-in link and token exchange information. public virtual Task GetSignInResourceAsync(string userId, string connectionName, string channelId, string? finalRedirect = null, CancellationToken cancellationToken = default) + => GetSignInResourceAsync(userId, connectionName, channelId, finalRedirect, requestContext: null, cancellationToken); + + /// + /// 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. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The optional final redirect URL. + /// Optional per-request properties used for authentication. + /// The cancellation token. + /// A task that represents the asynchronous operation. The result contains the sign-in resource with the sign-in link and token exchange information. + public virtual Task GetSignInResourceAsync(string userId, string connectionName, string channelId, string? finalRedirect, BotRequestContext? requestContext, CancellationToken cancellationToken = default) { var tokenExchangeState = new { @@ -124,7 +161,7 @@ public virtual Task 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); } /// @@ -137,6 +174,19 @@ public virtual Task GetSignInResourceAsync(string userI /// The cancellation token. /// The sign-in URL, or null if not available. public virtual async Task 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); + + /// + /// Gets the sign-in URL for the given state. + /// + /// The encoded state parameter. + /// The optional code challenge for PKCE. + /// The optional emulator URL. + /// The optional final redirect URL. + /// Optional per-request properties used for authentication. + /// The cancellation token. + /// The sign-in URL, or null if not available. + public virtual async Task GetSignInUrlAsync(string state, string? codeChallenge, Uri? emulatorUrl, Uri? finalRedirect, BotRequestContext? requestContext, CancellationToken cancellationToken = default) { Dictionary queryParams = new() { { "state", state } }; @@ -153,7 +203,7 @@ public virtual Task GetSignInResourceAsync(string userI "api/botsignin/GetSignInUrl", queryParams, body: null, - CreateRequestOptions("getting sign-in URL"), + CreateRequestOptions("getting sign-in URL", requestContext: requestContext), cancellationToken).ConfigureAwait(false); } @@ -167,6 +217,19 @@ public virtual Task GetSignInResourceAsync(string userI /// The cancellation token. /// The sign-in resource result. public virtual async Task 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); + + /// + /// Gets the sign-in resource for the given state. + /// + /// The encoded state parameter. + /// The optional code challenge for PKCE. + /// The optional emulator URL. + /// The optional final redirect URL. + /// Optional per-request properties used for authentication. + /// The cancellation token. + /// The sign-in resource result. + public virtual async Task GetSignInResourceAsync(string state, string? codeChallenge, Uri? emulatorUrl, Uri? finalRedirect, BotRequestContext? requestContext, CancellationToken cancellationToken = default) { Dictionary queryParams = new() { { "state", state } }; @@ -183,7 +246,7 @@ public virtual async Task GetSignInResourceAsync(string "api/botsignin/GetSignInResource", queryParams, body: null, - CreateRequestOptions("getting sign-in resource"), + CreateRequestOptions("getting sign-in resource", requestContext: requestContext), cancellationToken).ConfigureAwait(false))!; } @@ -196,6 +259,18 @@ public virtual async Task GetSignInResourceAsync(string /// The token to exchange. /// The cancellation token. public virtual async Task ExchangeTokenAsync(string userId, string connectionName, string channelId, string? exchangeToken, CancellationToken cancellationToken = default) + => await ExchangeTokenAsync(userId, connectionName, channelId, exchangeToken, requestContext: null, cancellationToken).ConfigureAwait(false); + + /// + /// Exchanges a token for another token. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The token to exchange. + /// Optional per-request properties used for authentication. + /// The cancellation token. + public virtual async Task ExchangeTokenAsync(string userId, string connectionName, string channelId, string? exchangeToken, BotRequestContext? requestContext, CancellationToken cancellationToken = default) { Dictionary queryParams = new() { @@ -215,7 +290,7 @@ public virtual async Task ExchangeTokenAsync(string userId, stri "api/usertoken/exchange", queryParams, JsonSerializer.Serialize(tokenExchangeRequest), - CreateRequestOptions("exchanging token"), + CreateRequestOptions("exchanging token", requestContext: requestContext), cancellationToken).ConfigureAwait(false))!; } @@ -228,6 +303,18 @@ public virtual async Task ExchangeTokenAsync(string userId, stri /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that represents the asynchronous sign-out operation. 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); + + /// + /// Signs the user out of a connection, revoking their OAuth token. + /// + /// The unique identifier of the user to sign out. Cannot be null or empty. + /// Optional name of the OAuth connection to sign out from. If null, signs out from all connections. + /// Optional channel identifier. If provided, limits sign-out to tokens for this channel. + /// Optional per-request properties used for authentication. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous sign-out operation. + public virtual async Task SignOutUserAsync(string userId, string? connectionName, string? channelId, BotRequestContext? requestContext, CancellationToken cancellationToken = default) { Dictionary queryParams = new() { @@ -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); } @@ -264,6 +351,19 @@ await _botHttpClient.SendAsync( /// The cancellation token. /// A task that represents the asynchronous operation. The result contains a dictionary mapping resource URLs to their token results. public virtual async Task> GetAadTokensAsync(string userId, string connectionName, string channelId, string[]? resourceUrls = null, CancellationToken cancellationToken = default) + => await GetAadTokensAsync(userId, connectionName, channelId, resourceUrls, requestContext: null, cancellationToken).ConfigureAwait(false); + + /// + /// Gets AAD tokens for a user. + /// + /// The user ID. + /// The connection name. + /// The channel ID. + /// The resource URLs. + /// Optional per-request properties used for authentication. + /// The cancellation token. + /// A task that represents the asynchronous operation. The result contains a dictionary mapping resource URLs to their token results. + public virtual async Task> GetAadTokensAsync(string userId, string connectionName, string channelId, string[]? resourceUrls, BotRequestContext? requestContext, CancellationToken cancellationToken = default) { var body = new { @@ -279,14 +379,14 @@ public virtual async Task> 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 }; diff --git a/core/test/Microsoft.Teams.Core.UnitTests/UserTokenClientTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/UserTokenClientTests.cs new file mode 100644 index 00000000..92899b1d --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/UserTokenClientTests.cs @@ -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(BotRequestContext.AgenticIdentityKey), out object? identityValue)); + Assert.Same(identity, identityValue); + Assert.True(handler.Request.Options.TryGetValue(new HttpRequestOptionsKey(BotRequestContext.BotAppIdKey), out object? botAppIdValue)); + Assert.Equal("bot-app", botAppIdValue); + } + + private static UserTokenClient CreateClient(HttpMessageHandler handler) + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["UserTokenApiEndpoint"] = "https://token.test" + }) + .Build(); + + return new UserTokenClient(new HttpClient(handler), configuration, NullLogger.Instance); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + + protected override Task 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); + } + } +}