From d9f09337f9a789f5f6d019c78fe52388c42a2cd6 Mon Sep 17 00:00:00 2001 From: Alex Acebo Date: Mon, 20 Oct 2025 09:23:58 -0700 Subject: [PATCH 1/9] add account linking url and tests --- .../Auth/ClientCredentials.cs | 4 + .../Auth/TokenCredentials.cs | 8 +- .../Clients/ActivityClient.cs | 8 +- .../Microsoft.Teams.Api/Clients/BotClient.cs | 2 +- .../Clients/BotSignInClient.cs | 4 +- .../Clients/BotTokenClient.cs | 4 +- .../Clients/ConversationClient.cs | 6 +- .../Clients/MeetingClient.cs | 4 +- .../Clients/MemberClient.cs | 6 +- .../Microsoft.Teams.Api/Clients/TeamClient.cs | 4 +- .../Microsoft.Teams.Api/Clients/UserClient.cs | 7 +- .../Clients/UserTokenClient.cs | 15 +- Libraries/Microsoft.Teams.Apps/AppBuilder.cs | 9 +- Libraries/Microsoft.Teams.Apps/AppOptions.cs | 37 ++++- Libraries/Microsoft.Teams.Apps/AppRouting.cs | 6 +- .../Microsoft.Teams.Apps/Contexts/Context.cs | 7 + .../Microsoft.Teams.Apps/OAuthSettings.cs | 17 ++- .../Microsoft.Teams.Apps/Routing/Router.cs | 1 + .../Http/HttpCredentials.cs | 3 + .../TeamsSettings.cs | 32 +++- .../SignIn/VerifyStateActivityTests.cs | 138 ++++++++++++++++++ 21 files changed, 287 insertions(+), 35 deletions(-) create mode 100644 Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs diff --git a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs index 7ae6d974..31935e9b 100644 --- a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs +++ b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs @@ -5,6 +5,10 @@ namespace Microsoft.Teams.Api.Auth; +/// +/// ClientId / ClientSecret based credentials +/// https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/azure-bot-create-single-secret +/// public class ClientCredentials : IHttpCredentials { public string ClientId { get; set; } diff --git a/Libraries/Microsoft.Teams.Api/Auth/TokenCredentials.cs b/Libraries/Microsoft.Teams.Api/Auth/TokenCredentials.cs index 238e6f0a..2a2686f2 100644 --- a/Libraries/Microsoft.Teams.Api/Auth/TokenCredentials.cs +++ b/Libraries/Microsoft.Teams.Api/Auth/TokenCredentials.cs @@ -7,6 +7,12 @@ namespace Microsoft.Teams.Api.Auth; public delegate Task TokenFactory(string? tenantId, params string[] scopes); +/// +/// Provide a TokenFactory that will be invoked whenever +/// the application needs a token. +/// TokenCredentials should be used with 3rd party packages like MSAL/Azure.Identity +/// to authenticate for any Federated/Managed Identity scenarios. +/// public class TokenCredentials : IHttpCredentials { public string ClientId { get; set; } @@ -26,7 +32,7 @@ public TokenCredentials(string clientId, string tenantId, TokenFactory token) Token = token; } - public async Task Resolve(IHttpClient _client, string[] scopes, CancellationToken cancellationToken = default) + public async Task Resolve(IHttpClient _, string[] scopes, CancellationToken cancellationToken = default) { return await Token(TenantId, scopes); } diff --git a/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs index b8cd2a58..7011397b 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs @@ -32,7 +32,7 @@ public ActivityClient(string serviceUrl, IHttpClientFactory factory, Cancellatio ServiceUrl = serviceUrl; } - public async Task CreateAsync(string conversationId, IActivity activity, bool isTargeted = false) + public virtual async Task CreateAsync(string conversationId, IActivity activity, bool isTargeted = false) { var url = $"{ServiceUrl}v3/conversations/{conversationId}/activities"; if (isTargeted) @@ -50,7 +50,7 @@ public ActivityClient(string serviceUrl, IHttpClientFactory factory, Cancellatio return body; } - public async Task UpdateAsync(string conversationId, string id, IActivity activity, bool isTargeted = false) + public virtual async Task UpdateAsync(string conversationId, string id, IActivity activity, bool isTargeted = false) { var url = $"{ServiceUrl}v3/conversations/{conversationId}/activities/{id}"; if (isTargeted) @@ -68,7 +68,7 @@ public ActivityClient(string serviceUrl, IHttpClientFactory factory, Cancellatio return body; } - public async Task ReplyAsync(string conversationId, string id, IActivity activity, bool isTargeted = false) + public virtual async Task ReplyAsync(string conversationId, string id, IActivity activity, bool isTargeted = false) { activity.ReplyToId = id; @@ -88,7 +88,7 @@ public ActivityClient(string serviceUrl, IHttpClientFactory factory, Cancellatio return body; } - public async Task DeleteAsync(string conversationId, string id, bool isTargeted = false) + public virtual async Task DeleteAsync(string conversationId, string id, bool isTargeted = false) { var url = $"{ServiceUrl}v3/conversations/{conversationId}/activities/{id}"; if (isTargeted) diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotClient.cs index ea7fda43..62e8f83e 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotClient.cs @@ -8,7 +8,7 @@ namespace Microsoft.Teams.Api.Clients; public class BotClient : Client { public virtual BotTokenClient Token { get; } - public BotSignInClient SignIn { get; } + public virtual BotSignInClient SignIn { get; } public BotClient() : this(default) { diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs index 52217361..4d98c5cc 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs @@ -27,7 +27,7 @@ public BotSignInClient(IHttpClientFactory factory, CancellationToken cancellatio } - public async Task GetUrlAsync(GetUrlRequest request) + public virtual async Task GetUrlAsync(GetUrlRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Get( @@ -38,7 +38,7 @@ public async Task GetUrlAsync(GetUrlRequest request) return res.Body; } - public async Task GetResourceAsync(GetResourceRequest request) + public virtual async Task GetResourceAsync(GetResourceRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Get( diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs index 8255d89c..037b8d65 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs @@ -10,7 +10,7 @@ public class BotTokenClient : Client public static readonly string BotScope = "https://api.botframework.com/.default"; public static readonly string GraphScope = "https://graph.microsoft.com/.default"; - public BotTokenClient() : this(default) + public BotTokenClient() : base() { } @@ -40,7 +40,7 @@ public virtual async Task GetAsync(IHttpCredentials credentials, return await credentials.Resolve(http ?? _http, [BotScope], _cancellationToken); } - public async Task GetGraphAsync(IHttpCredentials credentials, IHttpClient? http = null) + public virtual async Task GetGraphAsync(IHttpCredentials credentials, IHttpClient? http = null) { return await credentials.Resolve(http ?? _http, [GraphScope], _cancellationToken); } diff --git a/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs index 8523faae..dc1b8dfa 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs @@ -11,8 +11,8 @@ namespace Microsoft.Teams.Api.Clients; public class ConversationClient : Client { public readonly string ServiceUrl; - public readonly ActivityClient Activities; - public readonly MemberClient Members; + public virtual ActivityClient Activities { get; } + public virtual MemberClient Members { get; } public ConversationClient(string serviceUrl, CancellationToken cancellationToken = default) : base(cancellationToken) { @@ -42,7 +42,7 @@ public ConversationClient(string serviceUrl, IHttpClientFactory factory, Cancell Members = new MemberClient(serviceUrl, _http, cancellationToken); } - public async Task CreateAsync(CreateRequest request) + public virtual async Task CreateAsync(CreateRequest request) { var req = HttpRequest.Post($"{ServiceUrl}v3/conversations", body: request); var res = await _http.SendAsync(req, _cancellationToken); diff --git a/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs b/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs index 9c8ef2da..fbc7125d 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs @@ -32,14 +32,14 @@ public MeetingClient(string serviceUrl, IHttpClientFactory factory, Cancellation ServiceUrl = serviceUrl; } - public async Task GetByIdAsync(string id) + public virtual async Task GetByIdAsync(string id) { var request = HttpRequest.Get($"{ServiceUrl}v1/meetings/{id}"); var response = await _http.SendAsync(request, _cancellationToken); return response.Body; } - public async Task GetParticipantAsync(string meetingId, string id) + public virtual async Task GetParticipantAsync(string meetingId, string id) { var request = HttpRequest.Get($"{ServiceUrl}v1/meetings/{meetingId}/participants/{id}"); var response = await _http.SendAsync(request, _cancellationToken); diff --git a/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs b/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs index d69c96a2..330afb28 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs @@ -29,21 +29,21 @@ public MemberClient(string serviceUrl, IHttpClientFactory factory, CancellationT ServiceUrl = serviceUrl; } - public async Task> GetAsync(string conversationId) + public virtual async Task> GetAsync(string conversationId) { var request = HttpRequest.Get($"{ServiceUrl}v3/conversations/{conversationId}/members"); var response = await _http.SendAsync>(request, _cancellationToken); return response.Body; } - public async Task GetByIdAsync(string conversationId, string memberId) + public virtual async Task GetByIdAsync(string conversationId, string memberId) { var request = HttpRequest.Get($"{ServiceUrl}v3/conversations/{conversationId}/members/{memberId}"); var response = await _http.SendAsync(request, _cancellationToken); return response.Body; } - public async Task DeleteAsync(string conversationId, string memberId) + public virtual async Task DeleteAsync(string conversationId, string memberId) { var request = HttpRequest.Delete($"{ServiceUrl}v3/conversations/{conversationId}/members/{memberId}"); await _http.SendAsync(request, _cancellationToken); diff --git a/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs b/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs index 4ee05622..0d834d4f 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs @@ -29,14 +29,14 @@ public TeamClient(string serviceUrl, IHttpClientFactory factory, CancellationTok ServiceUrl = serviceUrl; } - public async Task GetByIdAsync(string id) + public virtual async Task GetByIdAsync(string id) { var request = HttpRequest.Get($"{ServiceUrl}v3/teams/{id}"); var response = await _http.SendAsync(request, _cancellationToken); return response.Body; } - public async Task> GetConversationsAsync(string id) + public virtual async Task> GetConversationsAsync(string id) { var request = HttpRequest.Get($"{ServiceUrl}v3/teams/{id}/conversations"); var response = await _http.SendAsync>(request, _cancellationToken); diff --git a/Libraries/Microsoft.Teams.Api/Clients/UserClient.cs b/Libraries/Microsoft.Teams.Api/Clients/UserClient.cs index caf49aab..ae5a1d3b 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/UserClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/UserClient.cs @@ -7,7 +7,12 @@ namespace Microsoft.Teams.Api.Clients; public class UserClient : Client { - public UserTokenClient Token { get; } + public virtual UserTokenClient Token { get; } + + public UserClient() : base() + { + Token = new UserTokenClient(_http, _cancellationToken); + } public UserClient(CancellationToken cancellationToken = default) : base(cancellationToken) { diff --git a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs index cf264d6a..4e703acf 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs @@ -15,6 +15,11 @@ public class UserTokenClient : Client DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + public UserTokenClient() : base() + { + + } + public UserTokenClient(CancellationToken cancellationToken = default) : base(cancellationToken) { @@ -35,7 +40,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio } - public async Task GetAsync(GetTokenRequest request) + public virtual async Task GetAsync(GetTokenRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Get($"https://token.botframework.com/api/usertoken/GetToken?{query}"); @@ -43,7 +48,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio return res.Body; } - public async Task> GetAadAsync(GetAadTokenRequest request) + public virtual async Task> GetAadAsync(GetAadTokenRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Post($"https://token.botframework.com/api/usertoken/GetAadTokens?{query}", body: request); @@ -51,7 +56,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio return res.Body; } - public async Task> GetStatusAsync(GetTokenStatusRequest request) + public virtual async Task> GetStatusAsync(GetTokenStatusRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Get($"https://token.botframework.com/api/usertoken/GetTokenStatus?{query}"); @@ -59,14 +64,14 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio return res.Body; } - public async Task SignOutAsync(SignOutRequest request) + public virtual async Task SignOutAsync(SignOutRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Delete($"https://token.botframework.com/api/usertoken/SignOut?{query}"); await _http.SendAsync(req, _cancellationToken); } - public async Task ExchangeAsync(ExchangeTokenRequest request) + public virtual async Task ExchangeAsync(ExchangeTokenRequest request) { var query = QueryString.Serialize(new { diff --git a/Libraries/Microsoft.Teams.Apps/AppBuilder.cs b/Libraries/Microsoft.Teams.Apps/AppBuilder.cs index 63e5d6f5..3c4b485b 100644 --- a/Libraries/Microsoft.Teams.Apps/AppBuilder.cs +++ b/Libraries/Microsoft.Teams.Apps/AppBuilder.cs @@ -92,9 +92,16 @@ public AppBuilder AddPlugin(Func> @delegate) return this; } + public AppBuilder AddOAuth(OAuthSettings oauthSettings) + { + _options.OAuth = oauthSettings; + return this; + } + public AppBuilder AddOAuth(string defaultConnectionName) { - _options.OAuth = new OAuthSettings(defaultConnectionName); + _options.OAuth ??= new(); + _options.OAuth.DefaultConnectionName = defaultConnectionName; return this; } diff --git a/Libraries/Microsoft.Teams.Apps/AppOptions.cs b/Libraries/Microsoft.Teams.Apps/AppOptions.cs index b923afa2..1a08bfac 100644 --- a/Libraries/Microsoft.Teams.Apps/AppOptions.cs +++ b/Libraries/Microsoft.Teams.Apps/AppOptions.cs @@ -7,14 +7,49 @@ namespace Microsoft.Teams.Apps; public class AppOptions { + /// + /// The applications optional storage provider that allows + /// the application to access shared dependencies. + /// public IServiceProvider? Provider { get; set; } + + /// + /// The applications optional ILogger instance. + /// public Common.Logging.ILogger? Logger { get; set; } + + /// + /// The applications optional IStorage instance. + /// public Common.Storage.IStorage? Storage { get; set; } + + /// + /// When provided, the application will use this IHttpClient instance + /// to send all http requests. + /// public Common.Http.IHttpClient? Client { get; set; } + + /// + /// When provided, the application will use this IHttpClientFactory to + /// initialize a new client whenever needed. + /// public Common.Http.IHttpClientFactory? ClientFactory { get; set; } + + /// + /// When provided, the application will use these credentials to resolve tokens it + /// uses to make API requests. + /// public Common.Http.IHttpCredentials? Credentials { get; set; } + + /// + /// A list of plugins to import into the application. + /// public IList Plugins { get; set; } = []; - public OAuthSettings OAuth { get; set; } = new OAuthSettings(); + + /// + /// User OAuth settings for the deferred (User) auth flows. + /// + public OAuthSettings OAuth { get; set; } = new(); public AppOptions() { diff --git a/Libraries/Microsoft.Teams.Apps/AppRouting.cs b/Libraries/Microsoft.Teams.Apps/AppRouting.cs index add489de..4a60352c 100644 --- a/Libraries/Microsoft.Teams.Apps/AppRouting.cs +++ b/Libraries/Microsoft.Teams.Apps/AppRouting.cs @@ -154,7 +154,11 @@ await Events.Emit( Token = res } ); - return new Response(HttpStatusCode.OK); + + return new Response(HttpStatusCode.OK, OAuth.AccountLinkingUrl is null ? null : new + { + accountLinkingUrl = OAuth.AccountLinkingUrl + }); } catch (HttpException ex) { diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs index 74baa25d..75f90b4c 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs @@ -112,6 +112,12 @@ public partial interface IContext where TActivity : IActivity /// public Task Next(); + /// + /// called to continue the chain of route handlers, + /// if not called no other handlers in the sequence will be executed + /// + public Task Next(IContext context); + /// /// convert the context to that of another activity type /// @@ -168,6 +174,7 @@ public void Deconstruct(out string appId, out ILogger log, out ApiClient api, ou } public Task Next() => OnNext(ToActivityType()); + public Task Next(IContext context) => OnNext(context.ToActivityType()); public IContext ToActivityType() => ToActivityType(); public IContext ToActivityType() where TToActivity : IActivity { diff --git a/Libraries/Microsoft.Teams.Apps/OAuthSettings.cs b/Libraries/Microsoft.Teams.Apps/OAuthSettings.cs index 830cc04e..0e71f9f6 100644 --- a/Libraries/Microsoft.Teams.Apps/OAuthSettings.cs +++ b/Libraries/Microsoft.Teams.Apps/OAuthSettings.cs @@ -1,7 +1,20 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -public class OAuthSettings(string? connectionName = "graph") +namespace Microsoft.Teams.Apps; + +/// +/// Settings for Deferred (User) auth flows +/// +public class OAuthSettings { - public string DefaultConnectionName { get; set; } = connectionName; + /// + /// The default connection name to use + /// + public string DefaultConnectionName { get; set; } = "graph"; + + /// + /// URL used for client side combined authentication flow. + /// + public string? AccountLinkingUrl { get; set; } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Apps/Routing/Router.cs b/Libraries/Microsoft.Teams.Apps/Routing/Router.cs index ded89fc9..2481c2a9 100644 --- a/Libraries/Microsoft.Teams.Apps/Routing/Router.cs +++ b/Libraries/Microsoft.Teams.Apps/Routing/Router.cs @@ -24,6 +24,7 @@ public class Router : IRouter public IList Select(IActivity activity) { return _routes + .OrderBy(route => route.Type == RouteType.User ? 0 : 1) .Where(route => route.Select(activity)) .ToList(); } diff --git a/Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs b/Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs index 3f347f8b..722ee15b 100644 --- a/Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs +++ b/Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs @@ -3,6 +3,9 @@ namespace Microsoft.Teams.Common.Http; +/// +/// Http Credential resolver used to fetch some access token. +/// public interface IHttpCredentials { public Task Resolve(IHttpClient client, string[] scopes, CancellationToken cancellationToken = default); diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index 11a4e532..039c2f01 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -7,15 +7,34 @@ namespace Microsoft.Teams.Apps.Extensions; public class TeamsSettings { + /// + /// The ID assigned to your application. + /// public string? ClientId { get; set; } + + /// + /// The secret (ie password) for your application. + /// public string? ClientSecret { get; set; } + + /// + /// The Tenant ID assigned to your application (for single tenant apps only) + /// public string? TenantId { get; set; } - public bool Empty - { - get { return ClientId == "" || ClientSecret == ""; } - } + /// + /// URL used for client side combined authentication flow. + /// + public string? AccountLinkingUrl { get; set; } + /// + /// true when ClientId OR ClientSecret are empty + /// + public bool Empty => string.IsNullOrEmpty(ClientId) || string.IsNullOrEmpty(ClientSecret); + + /// + /// Apply settings to app options. + /// public AppOptions Apply(AppOptions? options = null) { options ??= new AppOptions(); @@ -25,6 +44,11 @@ public AppOptions Apply(AppOptions? options = null) options.Credentials = new ClientCredentials(ClientId, ClientSecret, TenantId); } + if (AccountLinkingUrl is null) + { + options.OAuth.AccountLinkingUrl = AccountLinkingUrl; + } + return options; } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs new file mode 100644 index 00000000..1ebaee95 --- /dev/null +++ b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs @@ -0,0 +1,138 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +using Microsoft.IdentityModel.Tokens; +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Activities.Invokes; +using Microsoft.Teams.Api.Auth; +using Microsoft.Teams.Api.Clients; +using Microsoft.Teams.Apps.Activities; +using Microsoft.Teams.Apps.Activities.Invokes; +using Microsoft.Teams.Apps.Testing.Plugins; + +using Moq; + +namespace Microsoft.Teams.Apps.Tests.Activities; + +public class VerifyStateActivityTests +{ + private readonly App _app = new(new() + { + OAuth = new() + { + AccountLinkingUrl = "https://my-website.com/accounts/link" + } + }); + + private readonly IToken _token = Globals.Token; + + public VerifyStateActivityTests() + { + _app.AddPlugin(new TestPlugin()); + } + + [Fact] + public async Task Should_Return_AccountLinkingUrl() + { + var calls = 0; + var api = new Mock("https://api.com", new CancellationToken()); + var userClient = new Mock(); + var userTokenClient = new Mock(); + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = new ClaimsIdentity(), + Expires = DateTime.UtcNow.AddHours(1), // Token expiration + Issuer = "test", + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test_test_test_test_test_test_test_test")), + SecurityAlgorithms.HmacSha256 + ) + }; + + api.SetupGet(_ => _.Users).Returns(userClient.Object); + userClient.SetupGet(_ => _.Token).Returns(userTokenClient.Object); + userTokenClient.Setup(_ => _.GetAsync(It.IsAny())).ReturnsAsync(new Api.Token.Response() + { + ConnectionName = "graph", + Token = tokenHandler.WriteToken(new JwtSecurityTokenHandler().CreateToken(tokenDescriptor)) + }); + + _app.Api = api.Object; + _app.OnActivity(context => + { + calls++; + Assert.True(context.Activity.Type.IsInvoke); + Assert.True(((Activity)context.Activity).ToInvoke().Name.IsSignIn); + Assert.True(((Activity)context.Activity).ToInvoke().ToSignIn() is SignIn.VerifyStateActivity); + context.Api = api.Object; + return context.Next(context); + }); + + var res = await _app.Process(_token, new SignIn.VerifyStateActivity() + { + From = new Api.Account() { Id = "test_user_id" }, + Value = new() { State = "test_state" } + }); + + Assert.Equal(System.Net.HttpStatusCode.OK, res.Status); + Assert.Equal(1, calls); + Assert.Equal(2, res.Meta.Routes); + Assert.Equivalent(res.Body, new { accountLinkingUrl = _app.OAuth.AccountLinkingUrl }); + } + + [Fact] + public async Task Should_Override_AccountLinkingUrl() + { + var calls = 0; + var api = new Mock("https://api.com", new CancellationToken()); + var userClient = new Mock(); + var userTokenClient = new Mock(); + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = new ClaimsIdentity(), + Expires = DateTime.UtcNow.AddHours(1), // Token expiration + Issuer = "test", + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test_test_test_test_test_test_test_test")), + SecurityAlgorithms.HmacSha256 + ) + }; + + api.SetupGet(_ => _.Users).Returns(userClient.Object); + userClient.SetupGet(_ => _.Token).Returns(userTokenClient.Object); + userTokenClient.Setup(_ => _.GetAsync(It.IsAny())).Returns(Task.FromResult(new Api.Token.Response() + { + ConnectionName = "test", + Token = tokenHandler.WriteToken(new JwtSecurityTokenHandler().CreateToken(tokenDescriptor)) + })); + + _app.Api = api.Object; + _app.OnActivity(context => + { + calls++; + Assert.True(context.Activity.Type.IsInvoke); + Assert.True(((Activity)context.Activity).ToInvoke().Name.IsSignIn); + Assert.True(((Activity)context.Activity).ToInvoke().ToSignIn() is SignIn.VerifyStateActivity); + return context.Next(); + }); + + _app.OnVerifyState(context => + { + calls++; + return Task.FromResult(new { accountLinkingUrl = "test_linking_url" }); + }); + + var res = await _app.Process(_token, new SignIn.VerifyStateActivity() + { + Value = new() { State = "test_state" } + }); + + Assert.Equal(System.Net.HttpStatusCode.OK, res.Status); + Assert.Equal(2, calls); + Assert.Equal(2, res.Meta.Routes); + Assert.Equal(res.Body, new { accountLinkingUrl = "test_linking_url" }); + } +} \ No newline at end of file From de6948350aea98a2b8752f128d1eb1781aa6f026 Mon Sep 17 00:00:00 2001 From: Alex Acebo Date: Mon, 20 Oct 2025 09:24:58 -0700 Subject: [PATCH 2/9] run fmt --- Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs | 2 +- .../Activities/Invokes/SignIn/VerifyStateActivityTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs index 4e703acf..50394b36 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs @@ -17,7 +17,7 @@ public class UserTokenClient : Client public UserTokenClient() : base() { - + } public UserTokenClient(CancellationToken cancellationToken = default) : base(cancellationToken) diff --git a/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs index 1ebaee95..69effbf1 100644 --- a/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs +++ b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs @@ -24,7 +24,7 @@ public class VerifyStateActivityTests AccountLinkingUrl = "https://my-website.com/accounts/link" } }); - + private readonly IToken _token = Globals.Token; public VerifyStateActivityTests() @@ -58,7 +58,7 @@ public async Task Should_Return_AccountLinkingUrl() ConnectionName = "graph", Token = tokenHandler.WriteToken(new JwtSecurityTokenHandler().CreateToken(tokenDescriptor)) }); - + _app.Api = api.Object; _app.OnActivity(context => { @@ -108,7 +108,7 @@ public async Task Should_Override_AccountLinkingUrl() ConnectionName = "test", Token = tokenHandler.WriteToken(new JwtSecurityTokenHandler().CreateToken(tokenDescriptor)) })); - + _app.Api = api.Object; _app.OnActivity(context => { From 857a402c0973842696d8610297360c730cd0f63a Mon Sep 17 00:00:00 2001 From: Alex Acebo Date: Mon, 20 Oct 2025 09:38:20 -0700 Subject: [PATCH 3/9] update comments --- Libraries/Microsoft.Teams.Apps/OAuthSettings.cs | 2 +- .../Microsoft.Teams.Apps.Extensions/TeamsSettings.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Libraries/Microsoft.Teams.Apps/OAuthSettings.cs b/Libraries/Microsoft.Teams.Apps/OAuthSettings.cs index 0e71f9f6..30b5fd2c 100644 --- a/Libraries/Microsoft.Teams.Apps/OAuthSettings.cs +++ b/Libraries/Microsoft.Teams.Apps/OAuthSettings.cs @@ -14,7 +14,7 @@ public class OAuthSettings public string DefaultConnectionName { get; set; } = "graph"; /// - /// URL used for client side combined authentication flow. + /// Url used for client to perform tab auth and link the NAA account to the bot login account. /// public string? AccountLinkingUrl { get; set; } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index 039c2f01..a51b8482 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -23,7 +23,7 @@ public class TeamsSettings public string? TenantId { get; set; } /// - /// URL used for client side combined authentication flow. + /// Url used for client to perform tab auth and link the NAA account to the bot login account. /// public string? AccountLinkingUrl { get; set; } From 153f7c46010c2e43d9a96fc4d5d71a4784fa3d51 Mon Sep 17 00:00:00 2001 From: Rajan Date: Fri, 9 Jan 2026 11:39:52 -0500 Subject: [PATCH 4/9] Update Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Microsoft.Teams.Apps.Extensions/TeamsSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index a51b8482..0f2cfdb4 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -44,7 +44,7 @@ public AppOptions Apply(AppOptions? options = null) options.Credentials = new ClientCredentials(ClientId, ClientSecret, TenantId); } - if (AccountLinkingUrl is null) + if (!string.IsNullOrEmpty(AccountLinkingUrl)) { options.OAuth.AccountLinkingUrl = AccountLinkingUrl; } From b1c59b4c45fe8741f576d7be19663304e246af77 Mon Sep 17 00:00:00 2001 From: Rajan Date: Fri, 9 Jan 2026 11:40:34 -0500 Subject: [PATCH 5/9] Update Libraries/Microsoft.Teams.Apps/Contexts/Context.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Libraries/Microsoft.Teams.Apps/Contexts/Context.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs index 75f90b4c..0b07a96e 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs @@ -113,9 +113,12 @@ public partial interface IContext where TActivity : IActivity public Task Next(); /// - /// called to continue the chain of route handlers, - /// if not called no other handlers in the sequence will be executed + /// Called to continue the chain of route handlers using the specified context instance. + /// Use this overload when you want to invoke the next handler with a different or wrapped + /// than the current one; if not called, no other handlers + /// in the sequence will be executed. /// + /// The context to pass to the next handler in the chain. public Task Next(IContext context); /// From b8f3dec9e304212705ea059d6378e8422c4d5ac3 Mon Sep 17 00:00:00 2001 From: Rajan Date: Fri, 9 Jan 2026 11:40:48 -0500 Subject: [PATCH 6/9] Update Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Microsoft.Teams.Apps.Extensions/TeamsSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index 0f2cfdb4..d4d10d39 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -13,7 +13,7 @@ public class TeamsSettings public string? ClientId { get; set; } /// - /// The secret (ie password) for your application. + /// The secret (i.e. password) for your application. /// public string? ClientSecret { get; set; } From a38bbea80c551a15a23cb8373cf9753e1e86dbe7 Mon Sep 17 00:00:00 2001 From: Rajan Date: Fri, 9 Jan 2026 11:41:04 -0500 Subject: [PATCH 7/9] Update Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs b/Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs index 722ee15b..2daf7b39 100644 --- a/Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs +++ b/Libraries/Microsoft.Teams.Common/Http/HttpCredentials.cs @@ -4,7 +4,7 @@ namespace Microsoft.Teams.Common.Http; /// -/// Http Credential resolver used to fetch some access token. +/// Http Credential resolver used to fetch some access token. /// public interface IHttpCredentials { From 92bfe06f65280c00059fbf98a227ec0f96b9eb44 Mon Sep 17 00:00:00 2001 From: Rajan Date: Fri, 9 Jan 2026 11:41:57 -0500 Subject: [PATCH 8/9] Update Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Microsoft.Teams.Apps.Extensions/TeamsSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index d4d10d39..424d056b 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -28,7 +28,7 @@ public class TeamsSettings public string? AccountLinkingUrl { get; set; } /// - /// true when ClientId OR ClientSecret are empty + /// True when ClientId OR ClientSecret are empty /// public bool Empty => string.IsNullOrEmpty(ClientId) || string.IsNullOrEmpty(ClientSecret); From 711fb51f6a0bad71887249d5807ae5c2c066826c Mon Sep 17 00:00:00 2001 From: Rajan Date: Fri, 9 Jan 2026 11:49:28 -0500 Subject: [PATCH 9/9] Address Copilot review comments - Fix redundant JwtSecurityTokenHandler creation in tests (reuse existing tokenHandler variable) - Fix inconsistent assertion method (use Assert.Equivalent for anonymous object comparison) All 561 tests pass Co-Authored-By: Claude Sonnet 4.5 --- .../Activities/Invokes/SignIn/VerifyStateActivityTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs index 69effbf1..cc006cce 100644 --- a/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs +++ b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/SignIn/VerifyStateActivityTests.cs @@ -56,7 +56,7 @@ public async Task Should_Return_AccountLinkingUrl() userTokenClient.Setup(_ => _.GetAsync(It.IsAny())).ReturnsAsync(new Api.Token.Response() { ConnectionName = "graph", - Token = tokenHandler.WriteToken(new JwtSecurityTokenHandler().CreateToken(tokenDescriptor)) + Token = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)) }); _app.Api = api.Object; @@ -106,7 +106,7 @@ public async Task Should_Override_AccountLinkingUrl() userTokenClient.Setup(_ => _.GetAsync(It.IsAny())).Returns(Task.FromResult(new Api.Token.Response() { ConnectionName = "test", - Token = tokenHandler.WriteToken(new JwtSecurityTokenHandler().CreateToken(tokenDescriptor)) + Token = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)) })); _app.Api = api.Object; @@ -133,6 +133,6 @@ public async Task Should_Override_AccountLinkingUrl() Assert.Equal(System.Net.HttpStatusCode.OK, res.Status); Assert.Equal(2, calls); Assert.Equal(2, res.Meta.Routes); - Assert.Equal(res.Body, new { accountLinkingUrl = "test_linking_url" }); + Assert.Equivalent(res.Body, new { accountLinkingUrl = "test_linking_url" }); } } \ No newline at end of file