// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Apps.Handlers; using Microsoft.Teams.Apps.Routing; using Microsoft.Teams.Apps.Schema; namespace Microsoft.Teams.Apps.OAuth; /// /// Extension methods for registering instances on a . /// public static class OAuthFlowExtensions { /// /// Register an with an explicit OAuth connection name. /// /// The Teams bot application. /// The OAuth connection name configured on the bot. /// The instance for configuring callbacks. public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName) => AddOAuthFlow(app, new OAuthOptions { ConnectionName = connectionName }); /// /// Register an with that configure both the /// connection name and the default OAuthCard text shown during sign-in. /// Per-call options passed to /// override these defaults. /// /// The Teams bot application. /// OAuth options. is required. /// The instance for configuring callbacks. public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, OAuthOptions options) { ArgumentNullException.ThrowIfNull(app); ArgumentNullException.ThrowIfNull(options); ArgumentException.ThrowIfNullOrWhiteSpace(options.ConnectionName, nameof(options.ConnectionName)); string connectionName = options.ConnectionName; OAuthFlowRegistry registry = GetOrCreateRegistry(app); ILogger logger = GetLogger(app); OAuthFlow flow = new(app, connectionName, options, logger); registry.Register(connectionName, flow); return flow; } private static OAuthFlowRegistry GetOrCreateRegistry(TeamsBotApplication app) { if (app.OAuthRegistry is not null) { return app.OAuthRegistry; } OAuthFlowRegistry registry = new(); app.OAuthRegistry = registry; // Register shared routes once per app RegisterRoutes(app, registry); return registry; } private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry registry) { // signin/tokenExchange app.Router.Register(new Route { Name = string.Join("/", TeamsActivityTypes.Invoke, InvokeNames.SignInTokenExchange), Selector = activity => activity.Name == InvokeNames.SignInTokenExchange, HandlerWithReturn = async (ctx, cancellationToken) => { InvokeActivity typedActivity = new(ctx.Activity); SignInTokenExchangeValue? exchangeValue = typedActivity.Value; if (exchangeValue is null) { return new InvokeResponse(400); } OAuthFlow? flow = registry.Resolve(exchangeValue.ConnectionName); if (flow is null) { return new InvokeResponse(400); } return await flow.HandleTokenExchangeAsync(ctx, exchangeValue, cancellationToken).ConfigureAwait(false); } }); // signin/failure - Teams client-side SSO failure notification app.Router.Register(new Route { Name = string.Join("/", TeamsActivityTypes.Invoke, InvokeNames.SignInFailure), Selector = activity => activity.Name == InvokeNames.SignInFailure, HandlerWithReturn = async (ctx, cancellationToken) => { InvokeActivity typedActivity = new(ctx.Activity); SignInFailureValue failureValue = typedActivity.Value ?? new SignInFailureValue(); string? userId = ctx.Activity.From?.Id; // signin/failure doesn't carry a connection name. // Pick the most recently initiated flow to avoid firing duplicate // failure callbacks when multiple flows have pending sign-ins. // Fall back to all flows only when no flow reports a pending sign-in // (e.g., multi-instance deployment without distributed state). IEnumerable allFlows = registry.GetAllFlows(); OAuthFlow? mostRecent = null; DateTimeOffset mostRecentTs = DateTimeOffset.MinValue; if (userId is not null) { foreach (OAuthFlow f in allFlows) { DateTimeOffset? ts = f.GetPendingSignInTimestamp(ctx); if (ts is not null && ts.Value > mostRecentTs) { mostRecent = f; mostRecentTs = ts.Value; } } } if (mostRecent is not null) { await mostRecent.HandleSignInFailureAsync(ctx, failureValue, cancellationToken).ConfigureAwait(false); } else { // No pending flow found — fall back to notifying all flows. foreach (OAuthFlow flow in allFlows) { await flow.HandleSignInFailureAsync(ctx, failureValue, cancellationToken).ConfigureAwait(false); } } return new InvokeResponse(200); } }); // signin/verifyState app.Router.Register(new Route { Name = string.Join("/", TeamsActivityTypes.Invoke, InvokeNames.SignInVerifyState), Selector = activity => activity.Name == InvokeNames.SignInVerifyState, HandlerWithReturn = async (ctx, cancellationToken) => { InvokeActivity typedActivity = new(ctx.Activity); SignInVerifyStateValue? verifyValue = typedActivity.Value; if (verifyValue is null) { return new InvokeResponse(404); } // verifyState doesn't carry a connection name. // Try the most recently initiated flow first to avoid O(N) token service calls. string? userId = ctx.Activity.From?.Id; OAuthFlow? mostRecent = null; DateTimeOffset mostRecentTs = DateTimeOffset.MinValue; if (userId is not null) { foreach (OAuthFlow f in registry.GetAllFlows()) { DateTimeOffset? ts = f.GetPendingSignInTimestamp(ctx); if (ts is not null && ts.Value > mostRecentTs) { mostRecent = f; mostRecentTs = ts.Value; } } } if (mostRecent is not null) { InvokeResponse response = await mostRecent.HandleVerifyStateAsync(ctx, verifyValue, cancellationToken).ConfigureAwait(false); if (response.Status == 200) { return response; } } // Fall back to trying all flows (skipping the one we already tried) foreach (OAuthFlow flow in registry.GetAllFlows()) { if (flow == mostRecent) { continue; } InvokeResponse response = await flow.HandleVerifyStateAsync(ctx, verifyValue, cancellationToken).ConfigureAwait(false); if (response.Status == 200) { return response; } } return new InvokeResponse(404); } }); } private static ILogger GetLogger(TeamsBotApplication app) { return app.Logger; } } /// /// Internal registry that maps connection names to instances. /// Handles multi-connection dispatch for shared invoke routes. /// internal sealed class OAuthFlowRegistry { private readonly Dictionary _flows = new(StringComparer.OrdinalIgnoreCase); internal void Register(string connectionName, OAuthFlow flow) { if (!_flows.TryAdd(connectionName, flow)) { throw new InvalidOperationException($"An OAuthFlow is already registered for connection '{connectionName}'."); } } /// /// Resolve the OAuthFlow for a given connection name from a token exchange invoke. /// internal OAuthFlow? Resolve(string? connectionName) { if (connectionName is not null && _flows.TryGetValue(connectionName, out OAuthFlow? flow)) { return flow; } // If there's exactly one named flow, use it if (_flows.Count == 1) { return _flows.Values.First(); } return null; } /// /// Returns all registered flows. /// internal IEnumerable GetAllFlows() => _flows.Values; /// /// Resolve when there's no connection name in the payload (e.g., verifyState). /// Returns the single registered flow, or null if zero or multiple flows exist. /// internal OAuthFlow? ResolveSingle() { if (_flows.Count == 1) { return _flows.Values.First(); } return null; } /// /// Like but when multiple flows are registered, /// returns the first one and logs a warning instead of returning null. /// Used by Context.IsSignedIn for backwards compatibility. /// internal OAuthFlow? ResolveSingleWithWarning() { if (_flows.Count == 1) { return _flows.Values.First(); } if (_flows.Count > 1) { OAuthFlow first = _flows.Values.First(); System.Diagnostics.Trace.TraceWarning( $"IsSignedIn: multiple OAuthFlow connections registered. " + $"Checking '{first.ConnectionName}' only. Use IsSignedInAsync(connectionName) for explicit control."); return first; } return null; } /// /// Returns all registered connection names, for use in error messages. /// internal IEnumerable GetRegisteredConnectionNames() => _flows.Keys; }