microsoft/teams.net

Public

mirrored from https://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

295lines · 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("/", TeamsActivityTypes.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("/", TeamsActivityTypes.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 // Pick the most recently initiated flow to avoid firing duplicate
107 // failure callbacks when multiple flows have pending sign-ins.
108 // Fall back to all flows only when no flow reports a pending sign-in
109 // (e.g., multi-instance deployment without distributed state).
110 IEnumerable<OAuthFlow> allFlows = registry.GetAllFlows();
111 OAuthFlow? mostRecent = null;
112 DateTimeOffset mostRecentTs = DateTimeOffset.MinValue;
113
114 if (userId is not null)
115 {
116 foreach (OAuthFlow f in allFlows)
117 {
118 DateTimeOffset? ts = f.GetPendingSignInTimestamp(ctx);
119 if (ts is not null && ts.Value > mostRecentTs)
120 {
121 mostRecent = f;
122 mostRecentTs = ts.Value;
123 }
124 }
125 }
126
127 if (mostRecent is not null)
128 {
129 await mostRecent.HandleSignInFailureAsync(ctx, failureValue, cancellationToken).ConfigureAwait(false);
130 }
131 else
132 {
133 // No pending flow found — fall back to notifying all flows.
134 foreach (OAuthFlow flow in allFlows)
135 {
136 await flow.HandleSignInFailureAsync(ctx, failureValue, cancellationToken).ConfigureAwait(false);
137 }
138 }
139
140 return new InvokeResponse(200);
141 }
142 });
143
144 // signin/verifyState
145 app.Router.Register(new Route<InvokeActivity>
146 {
147 Name = string.Join("/", TeamsActivityTypes.Invoke, InvokeNames.SignInVerifyState),
148 Selector = activity => activity.Name == InvokeNames.SignInVerifyState,
149 HandlerWithReturn = async (ctx, cancellationToken) =>
150 {
151 InvokeActivity<SignInVerifyStateValue> typedActivity = new(ctx.Activity);
152 SignInVerifyStateValue? verifyValue = typedActivity.Value;
153
154 if (verifyValue is null)
155 {
156 return new InvokeResponse(404);
157 }
158
159 // verifyState doesn't carry a connection name.
160 // Try the most recently initiated flow first to avoid O(N) token service calls.
161 string? userId = ctx.Activity.From?.Id;
162 OAuthFlow? mostRecent = null;
163 DateTimeOffset mostRecentTs = DateTimeOffset.MinValue;
164
165 if (userId is not null)
166 {
167 foreach (OAuthFlow f in registry.GetAllFlows())
168 {
169 DateTimeOffset? ts = f.GetPendingSignInTimestamp(ctx);
170 if (ts is not null && ts.Value > mostRecentTs)
171 {
172 mostRecent = f;
173 mostRecentTs = ts.Value;
174 }
175 }
176 }
177
178 if (mostRecent is not null)
179 {
180 InvokeResponse response = await mostRecent.HandleVerifyStateAsync(ctx, verifyValue, cancellationToken).ConfigureAwait(false);
181 if (response.Status == 200)
182 {
183 return response;
184 }
185 }
186
187 // Fall back to trying all flows (skipping the one we already tried)
188 foreach (OAuthFlow flow in registry.GetAllFlows())
189 {
190 if (flow == mostRecent)
191 {
192 continue;
193 }
194
195 InvokeResponse response = await flow.HandleVerifyStateAsync(ctx, verifyValue, cancellationToken).ConfigureAwait(false);
196 if (response.Status == 200)
197 {
198 return response;
199 }
200 }
201
202 return new InvokeResponse(404);
203 }
204 });
205 }
206
207 private static ILogger GetLogger(TeamsBotApplication app)
208 {
209 return app.Logger;
210 }
211}
212
213/// <summary>
214/// Internal registry that maps connection names to <see cref="OAuthFlow"/> instances.
215/// Handles multi-connection dispatch for shared invoke routes.
216/// </summary>
217internal sealed class OAuthFlowRegistry
218{
219 private readonly Dictionary<string, OAuthFlow> _flows = new(StringComparer.OrdinalIgnoreCase);
220
221 internal void Register(string connectionName, OAuthFlow flow)
222 {
223 if (!_flows.TryAdd(connectionName, flow))
224 {
225 throw new InvalidOperationException($"An OAuthFlow is already registered for connection '{connectionName}'.");
226 }
227 }
228
229 /// <summary>
230 /// Resolve the OAuthFlow for a given connection name from a token exchange invoke.
231 /// </summary>
232 internal OAuthFlow? Resolve(string? connectionName)
233 {
234 if (connectionName is not null && _flows.TryGetValue(connectionName, out OAuthFlow? flow))
235 {
236 return flow;
237 }
238
239 // If there's exactly one named flow, use it
240 if (_flows.Count == 1)
241 {
242 return _flows.Values.First();
243 }
244
245 return null;
246 }
247
248 /// <summary>
249 /// Returns all registered flows.
250 /// </summary>
251 internal IEnumerable<OAuthFlow> GetAllFlows() => _flows.Values;
252
253 /// <summary>
254 /// Resolve when there's no connection name in the payload (e.g., verifyState).
255 /// Returns the single registered flow, or null if zero or multiple flows exist.
256 /// </summary>
257 internal OAuthFlow? ResolveSingle()
258 {
259 if (_flows.Count == 1)
260 {
261 return _flows.Values.First();
262 }
263
264 return null;
265 }
266
267 /// <summary>
268 /// Like <see cref="ResolveSingle"/> but when multiple flows are registered,
269 /// returns the first one and logs a warning instead of returning null.
270 /// Used by <c>Context.IsSignedIn</c> for backwards compatibility.
271 /// </summary>
272 internal OAuthFlow? ResolveSingleWithWarning()
273 {
274 if (_flows.Count == 1)
275 {
276 return _flows.Values.First();
277 }
278
279 if (_flows.Count > 1)
280 {
281 OAuthFlow first = _flows.Values.First();
282 System.Diagnostics.Trace.TraceWarning(
283 $"IsSignedIn: multiple OAuthFlow connections registered. " +
284 $"Checking '{first.ConnectionName}' only. Use IsSignedInAsync(connectionName) for explicit control.");
285 return first;
286 }
287
288 return null;
289 }
290
291 /// <summary>
292 /// Returns all registered connection names, for use in error messages.
293 /// </summary>
294 internal IEnumerable<string> GetRegisteredConnectionNames() => _flows.Keys;
295}
296