diff --git a/Libraries/Microsoft.Teams.Api/Auth/AnonymousToken.cs b/Libraries/Microsoft.Teams.Api/Auth/AnonymousToken.cs new file mode 100644 index 00000000..3c4fbd4a --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Auth/AnonymousToken.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Api.Auth; + +/// +/// A fallback token used when no authentication is provided (e.g., skipAuth mode). +/// Mirrors the behavior of Python and TypeScript SDKs. +/// +public class AnonymousToken : IToken +{ + public string? AppId => string.Empty; + + public string? AppDisplayName => string.Empty; + + public string? TenantId => string.Empty; + + public string ServiceUrl { get; } + + public CallerType From => CallerType.Azure; + + public string FromId => string.Empty; + + public DateTime? Expiration => null; + + public bool IsExpired => false; + + public IEnumerable Scopes => []; + + public AnonymousToken(string serviceUrl) + { + // Ensure serviceUrl has trailing slash for consistency + ServiceUrl = serviceUrl.EndsWith('/') ? serviceUrl : serviceUrl + '/'; + } + + public override string ToString() => string.Empty; +} diff --git a/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs index 4963f629..ce651f1f 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ActivityClient.cs @@ -14,22 +14,22 @@ public class ActivityClient : Client public ActivityClient(string serviceUrl, CancellationToken cancellationToken = default) : base(cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public ActivityClient(string serviceUrl, IHttpClient client, CancellationToken cancellationToken = default) : base(client, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public ActivityClient(string serviceUrl, IHttpClientOptions options, CancellationToken cancellationToken = default) : base(options, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public ActivityClient(string serviceUrl, IHttpClientFactory factory, CancellationToken cancellationToken = default) : base(factory, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public async Task CreateAsync(string conversationId, IActivity activity) diff --git a/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs index 3a7d9269..2acfcb6a 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs @@ -16,42 +16,42 @@ public class ApiClient : Client public ApiClient(string serviceUrl, CancellationToken cancellationToken = default) : base(cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); Bots = new BotClient(_http, cancellationToken); - Conversations = new ConversationClient(serviceUrl, _http, cancellationToken); + Conversations = new ConversationClient(ServiceUrl, _http, cancellationToken); Users = new UserClient(_http, cancellationToken); - Teams = new TeamClient(serviceUrl, _http, cancellationToken); - Meetings = new MeetingClient(serviceUrl, _http, cancellationToken); + Teams = new TeamClient(ServiceUrl, _http, cancellationToken); + Meetings = new MeetingClient(ServiceUrl, _http, cancellationToken); } public ApiClient(string serviceUrl, IHttpClient client, CancellationToken cancellationToken = default) : base(client, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); Bots = new BotClient(_http, cancellationToken); - Conversations = new ConversationClient(serviceUrl, _http, cancellationToken); + Conversations = new ConversationClient(ServiceUrl, _http, cancellationToken); Users = new UserClient(_http, cancellationToken); - Teams = new TeamClient(serviceUrl, _http, cancellationToken); - Meetings = new MeetingClient(serviceUrl, _http, cancellationToken); + Teams = new TeamClient(ServiceUrl, _http, cancellationToken); + Meetings = new MeetingClient(ServiceUrl, _http, cancellationToken); } public ApiClient(string serviceUrl, IHttpClientOptions options, CancellationToken cancellationToken = default) : base(options, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); Bots = new BotClient(_http, cancellationToken); - Conversations = new ConversationClient(serviceUrl, _http, cancellationToken); + Conversations = new ConversationClient(ServiceUrl, _http, cancellationToken); Users = new UserClient(_http, cancellationToken); - Teams = new TeamClient(serviceUrl, _http, cancellationToken); - Meetings = new MeetingClient(serviceUrl, _http, cancellationToken); + Teams = new TeamClient(ServiceUrl, _http, cancellationToken); + Meetings = new MeetingClient(ServiceUrl, _http, cancellationToken); } public ApiClient(string serviceUrl, IHttpClientFactory factory, CancellationToken cancellationToken = default) : base(factory, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); Bots = new BotClient(_http, cancellationToken); - Conversations = new ConversationClient(serviceUrl, _http, cancellationToken); + Conversations = new ConversationClient(ServiceUrl, _http, cancellationToken); Users = new UserClient(_http, cancellationToken); - Teams = new TeamClient(serviceUrl, _http, cancellationToken); - Meetings = new MeetingClient(serviceUrl, _http, cancellationToken); + Teams = new TeamClient(ServiceUrl, _http, cancellationToken); + Meetings = new MeetingClient(ServiceUrl, _http, cancellationToken); } public ApiClient(ApiClient client) : base() diff --git a/Libraries/Microsoft.Teams.Api/Clients/Client.cs b/Libraries/Microsoft.Teams.Api/Clients/Client.cs index 4c145577..659da9ea 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/Client.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/Client.cs @@ -10,6 +10,9 @@ public abstract class Client protected IHttpClient _http; protected CancellationToken _cancellationToken; + protected static string NormalizeServiceUrl(string serviceUrl) => + serviceUrl.EndsWith('/') ? serviceUrl : serviceUrl + '/'; + public Client(CancellationToken cancellationToken = default) { _http = new Common.Http.HttpClient(); diff --git a/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs index 8523faae..a66677e9 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ConversationClient.cs @@ -16,30 +16,30 @@ public class ConversationClient : Client public ConversationClient(string serviceUrl, CancellationToken cancellationToken = default) : base(cancellationToken) { - ServiceUrl = serviceUrl; - Activities = new ActivityClient(serviceUrl, _http, cancellationToken); - Members = new MemberClient(serviceUrl, _http, cancellationToken); + ServiceUrl = NormalizeServiceUrl(serviceUrl); + Activities = new ActivityClient(ServiceUrl, _http, cancellationToken); + Members = new MemberClient(ServiceUrl, _http, cancellationToken); } public ConversationClient(string serviceUrl, IHttpClient client, CancellationToken cancellationToken = default) : base(client, cancellationToken) { - ServiceUrl = serviceUrl; - Activities = new ActivityClient(serviceUrl, _http, cancellationToken); - Members = new MemberClient(serviceUrl, _http, cancellationToken); + ServiceUrl = NormalizeServiceUrl(serviceUrl); + Activities = new ActivityClient(ServiceUrl, _http, cancellationToken); + Members = new MemberClient(ServiceUrl, _http, cancellationToken); } public ConversationClient(string serviceUrl, IHttpClientOptions options, CancellationToken cancellationToken = default) : base(options, cancellationToken) { - ServiceUrl = serviceUrl; - Activities = new ActivityClient(serviceUrl, _http, cancellationToken); - Members = new MemberClient(serviceUrl, _http, cancellationToken); + ServiceUrl = NormalizeServiceUrl(serviceUrl); + Activities = new ActivityClient(ServiceUrl, _http, cancellationToken); + Members = new MemberClient(ServiceUrl, _http, cancellationToken); } public ConversationClient(string serviceUrl, IHttpClientFactory factory, CancellationToken cancellationToken = default) : base(factory, cancellationToken) { - ServiceUrl = serviceUrl; - Activities = new ActivityClient(serviceUrl, _http, cancellationToken); - Members = new MemberClient(serviceUrl, _http, cancellationToken); + ServiceUrl = NormalizeServiceUrl(serviceUrl); + Activities = new ActivityClient(ServiceUrl, _http, cancellationToken); + Members = new MemberClient(ServiceUrl, _http, cancellationToken); } public async Task CreateAsync(CreateRequest request) diff --git a/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs b/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs index cd14260a..23eef179 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/MeetingClient.cs @@ -14,22 +14,22 @@ public class MeetingClient : Client public MeetingClient(string serviceUrl, CancellationToken cancellationToken = default) : base(cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public MeetingClient(string serviceUrl, IHttpClient client, CancellationToken cancellationToken = default) : base(client, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public MeetingClient(string serviceUrl, IHttpClientOptions options, CancellationToken cancellationToken = default) : base(options, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public MeetingClient(string serviceUrl, IHttpClientFactory factory, CancellationToken cancellationToken = default) : base(factory, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public async Task GetByIdAsync(string id) diff --git a/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs b/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs index d69c96a2..974cb34a 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/MemberClient.cs @@ -11,22 +11,22 @@ public class MemberClient : Client public MemberClient(string serviceUrl, CancellationToken cancellationToken = default) : base(cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public MemberClient(string serviceUrl, IHttpClient client, CancellationToken cancellationToken = default) : base(client, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public MemberClient(string serviceUrl, IHttpClientOptions options, CancellationToken cancellationToken = default) : base(options, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public MemberClient(string serviceUrl, IHttpClientFactory factory, CancellationToken cancellationToken = default) : base(factory, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public async Task> GetAsync(string conversationId) diff --git a/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs b/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs index 4ee05622..55c9c704 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/TeamClient.cs @@ -11,22 +11,22 @@ public class TeamClient : Client public TeamClient(string serviceUrl, CancellationToken cancellationToken = default) : base(cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public TeamClient(string serviceUrl, IHttpClient client, CancellationToken cancellationToken = default) : base(client, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public TeamClient(string serviceUrl, IHttpClientOptions options, CancellationToken cancellationToken = default) : base(options, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public TeamClient(string serviceUrl, IHttpClientFactory factory, CancellationToken cancellationToken = default) : base(factory, cancellationToken) { - ServiceUrl = serviceUrl; + ServiceUrl = NormalizeServiceUrl(serviceUrl); } public async Task GetByIdAsync(string id) diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index 5f7768bc..f2a1c925 100644 --- a/Libraries/Microsoft.Teams.Apps/App.cs +++ b/Libraries/Microsoft.Teams.Apps/App.cs @@ -312,7 +312,7 @@ private async Task Process(ISenderPlugin sender, ActivityEvent @event, var reference = new ConversationReference() { - ServiceUrl = @event.Activity.ServiceUrl ?? @event.Token.ServiceUrl, + ServiceUrl = @event.Activity.ServiceUrl ?? @event.Token?.ServiceUrl ?? string.Empty, ChannelId = @event.Activity.ChannelId, Bot = @event.Activity.Recipient, User = @event.Activity.From, @@ -338,8 +338,8 @@ private async Task Process(ISenderPlugin sender, ActivityEvent @event, var stream = sender.CreateStream(reference, cancellationToken); var context = new Context(sender, stream) { - AppId = @event.Token.AppId ?? Id ?? string.Empty, - TenantId = @event.Token.TenantId ?? string.Empty, + AppId = @event.Token?.AppId ?? Id ?? string.Empty, + TenantId = @event.Token?.TenantId ?? string.Empty, Log = Logger.Child(path), Storage = Storage, Api = api, diff --git a/Libraries/Microsoft.Teams.Apps/Events/ActivityEvent.cs b/Libraries/Microsoft.Teams.Apps/Events/ActivityEvent.cs index 3612dbc8..7f8365bb 100644 --- a/Libraries/Microsoft.Teams.Apps/Events/ActivityEvent.cs +++ b/Libraries/Microsoft.Teams.Apps/Events/ActivityEvent.cs @@ -9,7 +9,7 @@ namespace Microsoft.Teams.Apps.Events; public class ActivityEvent : Event { - public required IToken Token { get; set; } + public IToken? Token { get; set; } public required IActivity Activity { get; set; } public IServiceProvider? Services { get; set; } public IDictionary? Extra { get; set; } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs index 169ec146..199a1d22 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs @@ -184,6 +184,10 @@ public async Task Do(HttpContext httpContext, CancellationToken cancell return Results.BadRequest("Missing activity"); } + // If no token was extracted, create an anonymous token with serviceUrl from the activity (or default) + // This matches Python/TypeScript SDK behavior for skipAuth scenarios + IToken resolvedToken = (IToken?)token ?? new AnonymousToken(activity.ServiceUrl ?? "https://smba.trafficmanager.net/teams"); + var data = new Dictionary { ["Request.TraceId"] = httpContext.TraceIdentifier @@ -200,7 +204,7 @@ public async Task Do(HttpContext httpContext, CancellationToken cancell var res = await Do(new ActivityEvent() { - Token = token, + Token = resolvedToken, Activity = activity, Extra = data, Services = httpContext.RequestServices @@ -235,9 +239,13 @@ await Events( } } - public JsonWebToken ExtractToken(HttpRequest httpRequest) + public JsonWebToken? ExtractToken(HttpRequest httpRequest) { - var authHeader = httpRequest.Headers.Authorization.FirstOrDefault() ?? throw new UnauthorizedAccessException(); + var authHeader = httpRequest.Headers.Authorization.FirstOrDefault(); + if (string.IsNullOrEmpty(authHeader)) + { + return null; + } return new JsonWebToken(authHeader.Replace("Bearer ", "")); } diff --git a/Tests/Microsoft.Teams.Api.Tests/Clients/ApiClientTests.cs b/Tests/Microsoft.Teams.Api.Tests/Clients/ApiClientTests.cs index fc5e02b1..44f8d455 100644 --- a/Tests/Microsoft.Teams.Api.Tests/Clients/ApiClientTests.cs +++ b/Tests/Microsoft.Teams.Api.Tests/Clients/ApiClientTests.cs @@ -11,7 +11,8 @@ public void ApiClient_Default() var serviceUrl = "https://api.botframework.com"; var apiClient = new ApiClient(serviceUrl); - Assert.Equal(serviceUrl, apiClient.ServiceUrl); + // ServiceUrl is normalized to have trailing slash + Assert.Equal(serviceUrl + "/", apiClient.ServiceUrl); } [Fact] @@ -20,6 +21,7 @@ public void ApiClient_Users_Default() var serviceUrl = "https://api.botframework.com"; var apiClient = new ApiClient(serviceUrl); - Assert.Equal(serviceUrl, apiClient.ServiceUrl); + // ServiceUrl is normalized to have trailing slash + Assert.Equal(serviceUrl + "/", apiClient.ServiceUrl); } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginTests.cs b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginTests.cs index a8370c4d..91f15f24 100644 --- a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginTests.cs +++ b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginTests.cs @@ -47,6 +47,18 @@ private static DefaultHttpContext CreateHttpContext(IActivity activity, string b return ctx; } + private static DefaultHttpContext CreateHttpContextWithoutAuth(IActivity activity) + { + var ctx = new DefaultHttpContext(); + ctx.TraceIdentifier = Guid.NewGuid().ToString(); + // No Authorization header + var json = JsonSerializer.Serialize(activity, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); + var bytes = Encoding.UTF8.GetBytes(json); + ctx.Request.Body = new MemoryStream(bytes); + ctx.Request.ContentLength = bytes.Length; + return ctx; + } + private static MessageActivity CreateMessageActivity() { return new MessageActivity("hi") @@ -196,4 +208,49 @@ public async Task Test_Do_Core_ReturnsResponseAndLogs() Assert.Same(response, res); logger.Verify(l => l.Debug(It.IsAny()), Times.AtLeastOnce); } + + [Fact] + public void Test_ExtractToken_ReturnsNull_WhenNoAuthHeader() + { + // Arrange + var plugin = CreatePlugin(); + var ctx = CreateHttpContextWithoutAuth(CreateMessageActivity()); + + // Act + var token = plugin.ExtractToken(ctx.Request); + + // Assert + Assert.Null(token); + } + + [Fact] + public async Task Test_Do_Http_WorksWithoutAuthHeader() + { + // Arrange - simulates skipAuth scenario where no Authorization header is present + var activity = CreateMessageActivity(); + var coreResponse = new Response(HttpStatusCode.OK, new { ok = true }); + EventFunction events = (plugin, name, payload, ct) => + { + if (name == "activity") + { + var activityEvent = (ActivityEvent)payload; + // Token should be an AnonymousToken when no auth header (matches Python/TypeScript behavior) + Assert.NotNull(activityEvent.Token); + Assert.IsType(activityEvent.Token); + Assert.Equal(string.Empty, activityEvent.Token.AppId); + return Task.FromResult(coreResponse); + } + return Task.FromResult(null); + }; + + var plugin = CreatePlugin(new Mock(), events); + var ctx = CreateHttpContextWithoutAuth(activity); + + // Act + var result = await plugin.Do(ctx); + + // Assert + var jsonResult = Assert.IsType>(result); + Assert.Equal(200, jsonResult.StatusCode); + } } \ No newline at end of file