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
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Represents a signin/failure invoke activity sent by Teams when SSO token exchange fails.
/// </summary>
public class FailureActivity() : SignInActivity(Name.SignIn.Failure)
{
/// <summary>
/// A value that is associated with the activity.
/// </summary>
[JsonPropertyName("value")]
[JsonPropertyOrder(32)]
public new required Api.SignIn.Failure Value
{
get => (Api.SignIn.Failure)base.Value!;
set => base.Value = value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -56,6 +58,7 @@ public override bool CanConvert(Type typeToConvert)
{
"signin/tokenExchange" => JsonSerializer.Deserialize<SignIn.TokenExchangeActivity>(element.ToString(), options),
"signin/verifyState" => JsonSerializer.Deserialize<SignIn.VerifyStateActivity>(element.ToString(), options),
"signin/failure" => JsonSerializer.Deserialize<SignIn.FailureActivity>(element.ToString(), options),
_ => throw new JsonException($"failed to deserialize signin activity '{name}' doesn't match any known types.")
};
}
Expand All @@ -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);
}
}
Expand Down
26 changes: 26 additions & 0 deletions Libraries/Microsoft.Teams.Api/SignIn/Failure.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Sign-in failure information sent by Teams when SSO token exchange fails.
/// </summary>
public class Failure
{
/// <summary>
/// The error code for the sign-in failure (e.g., "resourcematchfailed").
/// </summary>
[JsonPropertyName("code")]
[JsonPropertyOrder(0)]
public string? Code { get; set; }

/// <summary>
/// The error message for the sign-in failure.
/// </summary>
[JsonPropertyName("message")]
[JsonPropertyOrder(1)]
public string? Message { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Attribute for handling signin/failure invoke activities sent when SSO token exchange fails.
/// </summary>
[AttributeUsage(AttributeTargets.Method, Inherited = true)]
public class FailureAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.SignIn.Failure, typeof(SignIn.FailureActivity))
{
public override object Coerce(IContext<IActivity> context) => context.ToActivityType<SignIn.FailureActivity>();
}

public static partial class AppInvokeActivityExtensions
{
/// <summary>
/// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails.
/// </summary>
public static App OnSignInFailure(this App app, Func<IContext<SignIn.FailureActivity>, 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<SignIn.FailureActivity>());
return null;
},
Selector = activity => activity is SignIn.FailureActivity
});

return app;
}

/// <summary>
/// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails.
/// </summary>
public static App OnSignInFailure(this App app, Func<IContext<SignIn.FailureActivity>, Task<object?>> 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<SignIn.FailureActivity>()),
Selector = activity => activity is SignIn.FailureActivity
});

return app;
}

/// <summary>
/// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails.
/// </summary>
public static App OnSignInFailure(this App app, Func<IContext<SignIn.FailureActivity>, Task<Response?>> 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<SignIn.FailureActivity>()),
Selector = activity => activity is SignIn.FailureActivity
});

return app;
}

/// <summary>
/// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails, with cancellation token support.
/// </summary>
public static App OnSignInFailure(this App app, Func<IContext<SignIn.FailureActivity>, 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<SignIn.FailureActivity>(), context.CancellationToken);
return null;
},
Selector = activity => activity is SignIn.FailureActivity
});

return app;
}

/// <summary>
/// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails, with cancellation token support.
/// </summary>
public static App OnSignInFailure(this App app, Func<IContext<SignIn.FailureActivity>, CancellationToken, Task<object?>> 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<SignIn.FailureActivity>(), context.CancellationToken),
Selector = activity => activity is SignIn.FailureActivity
});

return app;
}

/// <summary>
/// Registers a handler for signin/failure invoke activities sent when SSO token exchange fails, with cancellation token support.
/// </summary>
public static App OnSignInFailure(this App app, Func<IContext<SignIn.FailureActivity>, CancellationToken, Task<Response?>> 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<SignIn.FailureActivity>(), context.CancellationToken),
Selector = activity => activity is SignIn.FailureActivity
});

return app;
}
}
1 change: 1 addition & 0 deletions Libraries/Microsoft.Teams.Apps/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions Libraries/Microsoft.Teams.Apps/AppRouting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,51 @@ await Events.Emit(
}
}

/// <summary>
/// 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):
/// <list type="bullet">
/// <item><term>installappfailed</term><description>Failed to install the app in the user's personal scope (non-silent).</description></item>
/// <item><term>authrequestfailed</term><description>The SSO auth request failed after app installation (non-silent).</description></item>
/// <item><term>installedappnotfound</term><description>The bot app is not installed for the user or group chat.</description></item>
/// <item><term>invokeerror</term><description>A generic error occurred during the SSO invoke flow.</description></item>
/// <item><term>resourcematchfailed</term><description>The token exchange resource URI on the OAuthCard does not match the Application ID URI in the Entra app registration's "Expose an API" section.</description></item>
/// <item><term>oauthcardnotvalid</term><description>The bot's OAuthCard could not be parsed.</description></item>
/// <item><term>tokenmissing</term><description>AAD token acquisition failed.</description></item>
/// <item><term>userconsentrequired</term><description>The user needs to consent (handled via OAuth card fallback, does not typically reach the bot).</description></item>
/// <item><term>interactionrequired</term><description>User interaction is required (handled via OAuth card fallback, does not typically reach the bot).</description></item>
/// </list>
/// </summary>
protected async Task<object?> OnFailureActivity(IContext<Api.Activities.Invokes.SignIn.FailureActivity> 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<IActivity>()
},
context.CancellationToken
);

return new Response(HttpStatusCode.OK);
}

/// <summary>
/// Register a middleware.
/// </summary>
Expand Down
8 changes: 8 additions & 0 deletions Samples/Samples.Graph/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Loading