diff --git a/Libraries/Microsoft.Teams.Api/Activities/Invokes/SignIn/FailureActivity.cs b/Libraries/Microsoft.Teams.Api/Activities/Invokes/SignIn/FailureActivity.cs new file mode 100644 index 00000000..bec8f919 --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Activities/Invokes/SignIn/FailureActivity.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +using Microsoft.Teams.Common; + +namespace Microsoft.Teams.Api.Activities.Invokes; + +public partial class Name : StringEnum +{ + public partial class SignIn : StringEnum + { + public static readonly SignIn Failure = new("signin/failure"); + public bool IsFailure => Failure.Equals(Value); + } +} + +public static partial class SignIn +{ + /// + /// Represents a signin/failure invoke activity sent by Teams when SSO token exchange fails. + /// + public class FailureActivity() : SignInActivity(Name.SignIn.Failure) + { + /// + /// A value that is associated with the activity. + /// + [JsonPropertyName("value")] + [JsonPropertyOrder(32)] + public new required Api.SignIn.Failure Value + { + get => (Api.SignIn.Failure)base.Value!; + set => base.Value = value; + } + } +} diff --git a/Libraries/Microsoft.Teams.Api/Activities/Invokes/SignInActivity.cs b/Libraries/Microsoft.Teams.Api/Activities/Invokes/SignInActivity.cs index 6f068fe0..78768308 100644 --- a/Libraries/Microsoft.Teams.Api/Activities/Invokes/SignInActivity.cs +++ b/Libraries/Microsoft.Teams.Api/Activities/Invokes/SignInActivity.cs @@ -21,11 +21,13 @@ public abstract class SignInActivity(Name.SignIn name) : InvokeActivity(new(name { public SignIn.TokenExchangeActivity ToTokenExchange() => (SignIn.TokenExchangeActivity)this; public SignIn.VerifyStateActivity ToVerifyState() => (SignIn.VerifyStateActivity)this; + public SignIn.FailureActivity ToFailure() => (SignIn.FailureActivity)this; public override object ToType(Type type, IFormatProvider? provider) { if (type == typeof(SignIn.TokenExchangeActivity)) return ToTokenExchange(); if (type == typeof(SignIn.VerifyStateActivity)) return ToVerifyState(); + if (type == typeof(SignIn.FailureActivity)) return ToFailure(); return this; } @@ -56,6 +58,7 @@ public override bool CanConvert(Type typeToConvert) { "signin/tokenExchange" => JsonSerializer.Deserialize(element.ToString(), options), "signin/verifyState" => JsonSerializer.Deserialize(element.ToString(), options), + "signin/failure" => JsonSerializer.Deserialize(element.ToString(), options), _ => throw new JsonException($"failed to deserialize signin activity '{name}' doesn't match any known types.") }; } @@ -74,6 +77,12 @@ public override void Write(Utf8JsonWriter writer, SignInActivity value, JsonSeri return; } + if (value is SignIn.FailureActivity failure) + { + JsonSerializer.Serialize(writer, failure, options); + return; + } + JsonSerializer.Serialize(writer, value, options); } } diff --git a/Libraries/Microsoft.Teams.Api/SignIn/Failure.cs b/Libraries/Microsoft.Teams.Api/SignIn/Failure.cs new file mode 100644 index 00000000..320926da --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/SignIn/Failure.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Api.SignIn; + +/// +/// Sign-in failure information sent by Teams when SSO token exchange fails. +/// +public class Failure +{ + /// + /// The error code for the sign-in failure (e.g., "resourcematchfailed"). + /// + [JsonPropertyName("code")] + [JsonPropertyOrder(0)] + public string? Code { get; set; } + + /// + /// The error message for the sign-in failure. + /// + [JsonPropertyName("message")] + [JsonPropertyOrder(1)] + public string? Message { get; set; } +} diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs new file mode 100644 index 00000000..5fed8f77 --- /dev/null +++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/SignIn/FailureActivity.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Activities.Invokes; +using Microsoft.Teams.Apps.Routing; + +namespace Microsoft.Teams.Apps.Activities.Invokes; + +/// +/// Attribute for handling signin/failure invoke activities sent when SSO token exchange fails. +/// +[AttributeUsage(AttributeTargets.Method, Inherited = true)] +public class FailureAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.SignIn.Failure, typeof(SignIn.FailureActivity)) +{ + public override object Coerce(IContext context) => context.ToActivityType(); +} + +public static partial class AppInvokeActivityExtensions +{ + /// + /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails. + /// + public static App OnSignInFailure(this App app, Func, Task> handler) + { + app.Router.Register(new Route() + { + Name = string.Join("/", [ActivityType.Invoke, Name.SignIn.Failure]), + Type = app.Status is null ? RouteType.System : RouteType.User, + Handler = async context => + { + await handler(context.ToActivityType()); + return null; + }, + Selector = activity => activity is SignIn.FailureActivity + }); + + return app; + } + + /// + /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails. + /// + public static App OnSignInFailure(this App app, Func, Task> handler) + { + app.Router.Register(new Route() + { + Name = string.Join("/", [ActivityType.Invoke, Name.SignIn.Failure]), + Type = app.Status is null ? RouteType.System : RouteType.User, + Handler = context => handler(context.ToActivityType()), + Selector = activity => activity is SignIn.FailureActivity + }); + + return app; + } + + /// + /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails. + /// + public static App OnSignInFailure(this App app, Func, Task> handler) + { + app.Router.Register(new Route() + { + Name = string.Join("/", [ActivityType.Invoke, Name.SignIn.Failure]), + Type = app.Status is null ? RouteType.System : RouteType.User, + Handler = async context => await handler(context.ToActivityType()), + Selector = activity => activity is SignIn.FailureActivity + }); + + return app; + } + + /// + /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails, with cancellation token support. + /// + public static App OnSignInFailure(this App app, Func, CancellationToken, Task> handler) + { + app.Router.Register(new Route() + { + Name = string.Join("/", [ActivityType.Invoke, Name.SignIn.Failure]), + Type = app.Status is null ? RouteType.System : RouteType.User, + Handler = async context => + { + await handler(context.ToActivityType(), context.CancellationToken); + return null; + }, + Selector = activity => activity is SignIn.FailureActivity + }); + + return app; + } + + /// + /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails, with cancellation token support. + /// + public static App OnSignInFailure(this App app, Func, CancellationToken, Task> handler) + { + app.Router.Register(new Route() + { + Name = string.Join("/", [ActivityType.Invoke, Name.SignIn.Failure]), + Type = app.Status is null ? RouteType.System : RouteType.User, + Handler = context => handler(context.ToActivityType(), context.CancellationToken), + Selector = activity => activity is SignIn.FailureActivity + }); + + return app; + } + + /// + /// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails, with cancellation token support. + /// + public static App OnSignInFailure(this App app, Func, CancellationToken, Task> handler) + { + app.Router.Register(new Route() + { + Name = string.Join("/", [ActivityType.Invoke, Name.SignIn.Failure]), + Type = app.Status is null ? RouteType.System : RouteType.User, + Handler = async context => await handler(context.ToActivityType(), context.CancellationToken), + Selector = activity => activity is SignIn.FailureActivity + }); + + return app; + } +} diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index 5f7768bc..37b27a85 100644 --- a/Libraries/Microsoft.Teams.Apps/App.cs +++ b/Libraries/Microsoft.Teams.Apps/App.cs @@ -102,6 +102,7 @@ public App(AppOptions? options = null) this.OnTokenExchange(OnTokenExchangeActivity); this.OnVerifyState(OnVerifyStateActivity); + this.OnSignInFailure(OnFailureActivity); this.OnError(OnErrorEvent); this.OnActivitySent(OnActivitySentEvent); this.OnActivityResponse(OnActivityResponseEvent); diff --git a/Libraries/Microsoft.Teams.Apps/AppRouting.cs b/Libraries/Microsoft.Teams.Apps/AppRouting.cs index 664da0d5..d17bbefb 100644 --- a/Libraries/Microsoft.Teams.Apps/AppRouting.cs +++ b/Libraries/Microsoft.Teams.Apps/AppRouting.cs @@ -179,6 +179,51 @@ await Events.Emit( } } + /// + /// Default handler for signin/failure invoke activities. + /// Teams sends this when SSO token exchange fails (e.g., due to a + /// misconfigured Entra app registration). Logs the failure details + /// and emits an error event. + /// + /// Known failure codes (sent by the Teams client): + /// + /// installappfailedFailed to install the app in the user's personal scope (non-silent). + /// authrequestfailedThe SSO auth request failed after app installation (non-silent). + /// installedappnotfoundThe bot app is not installed for the user or group chat. + /// invokeerrorA generic error occurred during the SSO invoke flow. + /// resourcematchfailedThe token exchange resource URI on the OAuthCard does not match the Application ID URI in the Entra app registration's "Expose an API" section. + /// oauthcardnotvalidThe bot's OAuthCard could not be parsed. + /// tokenmissingAAD token acquisition failed. + /// userconsentrequiredThe user needs to consent (handled via OAuth card fallback, does not typically reach the bot). + /// interactionrequiredUser interaction is required (handled via OAuth card fallback, does not typically reach the bot). + /// + /// + protected async Task OnFailureActivity(IContext context) + { + var failure = context.Activity.Value; + + Logger.Warn( + $"sign-in failed for user \"{context.Activity.From.Id}\" in conversation " + + $"\"{context.Ref.Conversation.Id}\": {failure.Code} — {failure.Message}. " + + $"If the code is 'resourcematchfailed', verify that your Entra app registration " + + $"has 'Expose an API' configured with the correct Application ID URI matching " + + $"your OAuth connection's Token Exchange URL." + ); + + await Events.Emit( + context.Sender, + EventType.Error, + new ErrorEvent() + { + Exception = new Exception($"Sign-in failure: {failure.Code} — {failure.Message}"), + Context = context.ToActivityType() + }, + context.CancellationToken + ); + + return new Response(HttpStatusCode.OK); + } + /// /// Register a middleware. /// diff --git a/Samples/Samples.Graph/Program.cs b/Samples/Samples.Graph/Program.cs index 28ee108c..c89f83f8 100644 --- a/Samples/Samples.Graph/Program.cs +++ b/Samples/Samples.Graph/Program.cs @@ -1,5 +1,6 @@ using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Activities; +using Microsoft.Teams.Apps.Activities.Invokes; using Microsoft.Teams.Apps.Events; using Microsoft.Teams.Apps.Extensions; using Microsoft.Teams.Common.Logging; @@ -74,4 +75,11 @@ await context.SignIn(new OAuthOptions() await context.Send($"user \"{me!.DisplayName}\" signed in. Here's the token: {token.Token}", cancellationToken); }); +teams.OnSignInFailure(async (context, cancellationToken) => +{ + var failure = context.Activity.Value; + context.Log.Error($"sign-in failed: {failure?.Code} - {failure?.Message}"); + await context.Send("sign-in failed. please contact your admin.", cancellationToken); +}); + app.Run(); \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/Activities/Invokes/SignIn/FailureSignInActivityTests.cs b/Tests/Microsoft.Teams.Api.Tests/Activities/Invokes/SignIn/FailureSignInActivityTests.cs new file mode 100644 index 00000000..78ec1101 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Activities/Invokes/SignIn/FailureSignInActivityTests.cs @@ -0,0 +1,146 @@ +using System.Text.Json; + +using Microsoft.Teams.Api.Activities; +using Microsoft.Teams.Api.Activities.Invokes; + +using static Microsoft.Teams.Api.Activities.Invokes.SignIn; + +namespace Microsoft.Teams.Api.Tests.Activities.Invokes; + +public class FailureSignInActivityTests +{ + private FailureActivity SetupSignInFailureActivity() + { + return new FailureActivity() + { + Value = new Api.SignIn.Failure() + { + Code = "resourcematchfailed", + Message = "Resource match failed" + }, + Conversation = new Api.Conversation() + { + Id = "conversationId", + Type = ConversationType.GroupChat + }, + From = new Account() + { + Id = "botId", + Name = "User Name", + AadObjectId = "aadObjectId" + }, + Recipient = new Account() + { + Id = "recipientId", + Name = "Recipient Name", + }, + }; + } + + [Fact] + public void SignInFailureActivity_JsonSerialize() + { + var activity = SetupSignInFailureActivity(); + + var json = JsonSerializer.Serialize(activity, new JsonSerializerOptions() + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + string expectedPath = "Activity.Invoke.Signin/failure"; + Assert.Equal(expectedPath, activity.GetPath()); + Assert.NotNull(activity.ToFailure()); + var expectedCastExceptionMessage = "Unable to cast object of type 'FailureActivity' to type 'TokenExchangeActivity'."; + var ex = Assert.Throws(() => activity.ToTokenExchange()); + Assert.Equal(expectedCastExceptionMessage, ex.Message); + Assert.Equal(File.ReadAllText( + @"../../../Json/Activity/Invokes/SignInFailureActivity.json" + ), json); + } + + [Fact] + public void SignInFailureActivity_JsonSerialize_Derived() + { + SignInActivity activity = SetupSignInFailureActivity(); + + var json = JsonSerializer.Serialize(activity, new JsonSerializerOptions() + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + string expectedPath = "Activity.Invoke.Signin/failure"; + Assert.Equal(expectedPath, activity.GetPath()); + Assert.Equal(File.ReadAllText( + @"../../../Json/Activity/Invokes/SignInFailureActivity.json" + ), json); + } + + [Fact] + public void SignInFailureActivity_JsonSerialize_Derived_Interface() + { + InvokeActivity activity = SetupSignInFailureActivity(); + + var json = JsonSerializer.Serialize(activity, new JsonSerializerOptions() + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + string expectedPath = "Activity.Invoke.Signin/failure"; + Assert.Equal(expectedPath, activity.GetPath()); + Assert.Equal(File.ReadAllText( + @"../../../Json/Activity/Invokes/SignInFailureActivity.json" + ), json); + } + + [Fact] + public void SignInFailureActivity_JsonDeserialize() + { + var json = File.ReadAllText(@"../../../Json/Activity/Invokes/SignInFailureActivity.json"); + var activity = JsonSerializer.Deserialize(json); + var expected = SetupSignInFailureActivity(); + + Assert.Equal(expected.ToString(), activity!.ToString()); + Assert.NotNull(activity.ToFailure()); + + var expectedCastExceptionMessage = "Unable to cast object of type 'FailureActivity' to type 'TokenExchangeActivity'."; + var ex = Assert.Throws(() => activity.ToTokenExchange()); + Assert.Equal(expectedCastExceptionMessage, ex.Message); + } + + [Fact] + public void SignInFailureActivity_JsonDeserialize_Derived() + { + var json = File.ReadAllText(@"../../../Json/Activity/Invokes/SignInFailureActivity.json"); + var activity = JsonSerializer.Deserialize(json); + var expected = SetupSignInFailureActivity(); + + Assert.NotNull(activity); + Assert.Equal(expected.ToString(), activity.ToString()); + Assert.NotNull(activity.ToSignIn()); + } + + [Fact] + public void SignInFailureActivity_JsonDeserialize_Derived_Interface() + { + var json = File.ReadAllText(@"../../../Json/Activity/Invokes/SignInFailureActivity.json"); + var activity = JsonSerializer.Deserialize(json); + var expected = SetupSignInFailureActivity(); + + Assert.NotNull(activity); + Assert.Equal(expected.ToString(), activity.ToString()); + } + + [Fact] + public void SignInFailureActivity_JsonDeserialize_Derived_Activity_Interface() + { + var json = File.ReadAllText(@"../../../Json/Activity/Invokes/SignInFailureActivity.json"); + var activity = JsonSerializer.Deserialize(json); + var expected = SetupSignInFailureActivity(); + + Assert.NotNull(activity); + Assert.Equal(expected.ToString(), activity.ToString()); + } +} diff --git a/Tests/Microsoft.Teams.Api.Tests/Json/Activity/Invokes/SignInFailureActivity.json b/Tests/Microsoft.Teams.Api.Tests/Json/Activity/Invokes/SignInFailureActivity.json new file mode 100644 index 00000000..3db31ab8 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Json/Activity/Invokes/SignInFailureActivity.json @@ -0,0 +1,22 @@ +{ + "type": "invoke", + "channelId": "msteams", + "name": "signin/failure", + "value": { + "code": "resourcematchfailed", + "message": "Resource match failed" + }, + "from": { + "id": "botId", + "aadObjectId": "aadObjectId", + "name": "User Name" + }, + "recipient": { + "id": "recipientId", + "name": "Recipient Name" + }, + "conversation": { + "id": "conversationId", + "conversationType": "groupChat" + } +} \ No newline at end of file