microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/update-release-process

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/src/Microsoft.Teams.Apps/OAuth/OAuthFlowExtensions.cs

243lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using Microsoft.Extensions.Logging;
5using Microsoft.Extensions.Logging.Abstractions;
6using Microsoft.Teams.Apps.Handlers;
7using Microsoft.Teams.Apps.Routing;
8using Microsoft.Teams.Apps.Schema;
9
10namespace Microsoft.Teams.Apps.OAuth;
11
12/// <summary>
13/// Extension methods for registering <see cref="OAuthFlow"/> instances on a <see cref="TeamsBotApplication"/>.
14/// </summary>
15public static class OAuthFlowExtensions
16{
17
18 /// <summary>
19 /// Register an <see cref="OAuthFlow"/> with an explicit OAuth connection name.
20 /// </summary>
21 /// <param name="app">The Teams bot application.</param>
22 /// <param name="connectionName">The OAuth connection name configured on the bot.</param>
23 /// <returns>The <see cref="OAuthFlow"/> instance for configuring callbacks.</returns>
24 public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName)
25 => AddOAuthFlow(app, new OAuthOptions { ConnectionName = connectionName });
26
27 /// <summary>
28 /// Register an <see cref="OAuthFlow"/> with <see cref="OAuthOptions"/> that configure both the
29 /// connection name and the default OAuthCard text shown during sign-in.
30 /// Per-call options passed to <see cref="OAuthFlow.SignInAsync{TActivity}(Context{TActivity}, OAuthOptions?, CancellationToken)"/>
31 /// override these defaults.
32 /// </summary>
33 /// <param name="app">The Teams bot application.</param>
34 /// <param name="options">OAuth options. <see cref="OAuthOptions.ConnectionName"/> is required.</param>
35 /// <returns>The <see cref="OAuthFlow"/> instance for configuring callbacks.</returns>
36 public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, OAuthOptions options)
37 {
38 ArgumentNullException.ThrowIfNull(app);
39 ArgumentNullException.ThrowIfNull(options);
40 ArgumentException.ThrowIfNullOrWhiteSpace(options.ConnectionName, nameof(options.ConnectionName));
41
42 string connectionName = options.ConnectionName;
43 OAuthFlowRegistry registry = GetOrCreateRegistry(app);
44 ILogger logger = GetLogger(app);
45
46 OAuthFlow flow = new(app, connectionName, options, logger);
47 registry.Register(connectionName, flow);
48
49 return flow;
50 }
51
52 private static OAuthFlowRegistry GetOrCreateRegistry(TeamsBotApplication app)
53 {
54 if (app.OAuthRegistry is not null)
55 {
56 return app.OAuthRegistry;
57 }
58
59 OAuthFlowRegistry registry = new();
60 app.OAuthRegistry = registry;
61
62 // Register shared routes once per app
63 RegisterRoutes(app, registry);
64 return registry;
65 }
66
67 private static void RegisterRoutes(TeamsBotApplication app, OAuthFlowRegistry registry)
68 {
69 // signin/tokenExchange
70 app.Router.Register(new Route<InvokeActivity>
71 {
72 Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInTokenExchange),
73 Selector = activity => activity.Name == InvokeNames.SignInTokenExchange,
74 HandlerWithReturn = async (ctx, cancellationToken) =>
75 {
76 InvokeActivity<SignInTokenExchangeValue> typedActivity = new(ctx.Activity);
77 SignInTokenExchangeValue? exchangeValue = typedActivity.Value;
78
79 if (exchangeValue is null)
80 {
81 return new InvokeResponse(400);
82 }
83
84 OAuthFlow? flow = registry.Resolve(exchangeValue.ConnectionName);
85 if (flow is null)
86 {
87 return new InvokeResponse(400);
88 }
89
90 return await flow.HandleTokenExchangeAsync(ctx, exchangeValue, cancellationToken).ConfigureAwait(false);
91 }
92 });
93
94 // signin/failure - Teams client-side SSO failure notification
95 app.Router.Register(new Route<InvokeActivity>
96 {
97 Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInFailure),
98 Selector = activity => activity.Name == InvokeNames.SignInFailure,
99 HandlerWithReturn = async (ctx, cancellationToken) =>
100 {
101 InvokeActivity<SignInFailureValue> typedActivity = new(ctx.Activity);
102 SignInFailureValue failureValue = typedActivity.Value ?? new SignInFailureValue();
103 string? userId = ctx.Activity.From?.Id;
104
105 // signin/failure doesn't carry a connection name.
106 // Scope to flows that have an active sign-in for this user;
107 // fall back to all flows if none report a pending sign-in
108 // (e.g., multi-instance deployment where the OAuthCard was sent by another node).
109 IEnumerable<OAuthFlow> allFlows = registry.GetAllFlows();
110 List<OAuthFlow> activeFlows = userId is not null
111 ? allFlows.Where(f => f.HasPendingSignIn(userId)).ToList()
112 : [];
113 IEnumerable<OAuthFlow> targetFlows = activeFlows.Count > 0 ? activeFlows : allFlows;
114
115 foreach (OAuthFlow flow in targetFlows)
116 {
117 await flow.HandleSignInFailureAsync(ctx, failureValue, cancellationToken).ConfigureAwait(false);
118 }
119
120 return new InvokeResponse(200);
121 }
122 });
123
124 // signin/verifyState
125 app.Router.Register(new Route<InvokeActivity>
126 {
127 Name = string.Join("/", TeamsActivityType.Invoke, InvokeNames.SignInVerifyState),
128 Selector = activity => activity.Name == InvokeNames.SignInVerifyState,
129 HandlerWithReturn = async (ctx, cancellationToken) =>
130 {
131 InvokeActivity<SignInVerifyStateValue> typedActivity = new(ctx.Activity);
132 SignInVerifyStateValue? verifyValue = typedActivity.Value;
133
134 if (verifyValue is null)
135 {
136 return new InvokeResponse(404);
137 }
138
139 // verifyState doesn't carry a connection name, so try each registered flow
140 foreach (OAuthFlow flow in registry.GetAllFlows())
141 {
142 InvokeResponse response = await flow.HandleVerifyStateAsync(ctx, verifyValue, cancellationToken).ConfigureAwait(false);
143 if (response.Status == 200)
144 {
145 return response;
146 }
147 }
148
149 return new InvokeResponse(400);
150 }
151 });
152 }
153
154 private static NullLogger GetLogger(TeamsBotApplication app)
155 {
156 _ = app; // Reserved for future use (e.g., resolving ILoggerFactory from DI)
157 return NullLogger.Instance;
158 }
159}
160
161/// <summary>
162/// Internal registry that maps connection names to <see cref="OAuthFlow"/> instances.
163/// Handles multi-connection dispatch for shared invoke routes.
164/// </summary>
165internal sealed class OAuthFlowRegistry
166{
167 private readonly Dictionary<string, OAuthFlow> _flows = new(StringComparer.OrdinalIgnoreCase);
168
169 internal void Register(string connectionName, OAuthFlow flow)
170 {
171 if (!_flows.TryAdd(connectionName, flow))
172 {
173 throw new InvalidOperationException($"An OAuthFlow is already registered for connection '{connectionName}'.");
174 }
175 }
176
177 /// <summary>
178 /// Resolve the OAuthFlow for a given connection name from a token exchange invoke.
179 /// </summary>
180 internal OAuthFlow? Resolve(string? connectionName)
181 {
182 if (connectionName is not null && _flows.TryGetValue(connectionName, out OAuthFlow? flow))
183 {
184 return flow;
185 }
186
187 // If there's exactly one named flow, use it
188 if (_flows.Count == 1)
189 {
190 return _flows.Values.First();
191 }
192
193 return null;
194 }
195
196 /// <summary>
197 /// Returns all registered flows.
198 /// </summary>
199 internal IEnumerable<OAuthFlow> GetAllFlows() => _flows.Values;
200
201 /// <summary>
202 /// Resolve when there's no connection name in the payload (e.g., verifyState).
203 /// Returns the single registered flow, or null if zero or multiple flows exist.
204 /// </summary>
205 internal OAuthFlow? ResolveSingle()
206 {
207 if (_flows.Count == 1)
208 {
209 return _flows.Values.First();
210 }
211
212 return null;
213 }
214
215 /// <summary>
216 /// Like <see cref="ResolveSingle"/> but when multiple flows are registered,
217 /// returns the first one and logs a warning instead of returning null.
218 /// Used by <c>Context.IsSignedIn</c> for backwards compatibility.
219 /// </summary>
220 internal OAuthFlow? ResolveSingleWithWarning()
221 {
222 if (_flows.Count == 1)
223 {
224 return _flows.Values.First();
225 }
226
227 if (_flows.Count > 1)
228 {
229 OAuthFlow first = _flows.Values.First();
230 System.Diagnostics.Trace.TraceWarning(
231 $"IsSignedIn: multiple OAuthFlow connections registered. " +
232 $"Checking '{first.ConnectionName}' only. Use IsSignedInAsync(connectionName) for explicit control.");
233 return first;
234 }
235
236 return null;
237 }
238
239 /// <summary>
240 /// Returns all registered connection names, for use in error messages.
241 /// </summary>
242 internal IEnumerable<string> GetRegisteredConnectionNames() => _flows.Keys;
243}
244