microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
next/oauth-card-null-ref-bug

Branches

Tags

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

Clone

HTTPS

Download ZIP

core/samples/PABot/Bots/SsoBot.cs

394lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using Microsoft.Bot.Builder;
5using Microsoft.Bot.Builder.Dialogs;
6using Microsoft.Bot.Connector;
7using Microsoft.Bot.Connector.Authentication;
8using Microsoft.Bot.Schema;
9using Newtonsoft.Json.Linq;
10using System.Net;
11
12namespace PABot.Bots
13{
14 /// <summary>
15 /// OAuth SSO Bot - Demonstrates OAuth Single Sign-On flow with token exchange.
16 ///
17 /// Flow:
18 /// 1. User sends any message
19 /// 2. Bot checks if user has a token
20 /// 3. If no token, sends OAuth SSO card with TokenExchangeResource
21 /// 4. Client attempts SSO token exchange by sending invoke activity
22 /// 5. Bot handles token exchange and responds with success/failure
23 /// 6. If token exchange fails, user clicks sign-in button for manual auth
24 /// </summary>
25 public class SsoBot(ILogger<SsoBot> logger, IConfiguration configuration) : ActivityHandler
26 {
27 private readonly IConfiguration _configuration = configuration;
28 private const string ConnectionName = "graph-sso"; // From launchSettings.json
29
30 protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
31 {
32 // Special test scenario to reproduce NullReferenceException bug
33 if (turnContext.Activity.Text?.Contains("test oauth card", StringComparison.OrdinalIgnoreCase) == true)
34 {
35 await TestOAuthCardSendScenario(turnContext, cancellationToken);
36 return;
37 }
38
39 UserTokenClient tokenClient = turnContext.TurnState.Get<UserTokenClient>();
40
41 // Try to get existing token
42 TokenResponse token = await tokenClient.GetUserTokenAsync(
43 turnContext.Activity.From.Id,
44 ConnectionName,
45 turnContext.Activity.ChannelId,
46 null,
47 cancellationToken);
48
49 if (token != null && !string.IsNullOrEmpty(token.Token))
50 {
51 // User is authenticated - show token info
52 logger.LogInformation("User has valid token for connection '{ConnectionName}'", ConnectionName);
53 await turnContext.SendActivityAsync(
54 MessageFactory.Text($"✅ You are signed in!\n\nToken (first 20 chars): {token.Token[..Math.Min(20, token.Token.Length)]}...\n\nYou said: {turnContext.Activity.Text}"),
55 cancellationToken);
56 }
57 else
58 {
59 // No token - send OAuth SSO card
60 logger.LogInformation("No token found, sending OAuth SSO card");
61 await SendOAuthCardAsync(turnContext, cancellationToken);
62 }
63 }
64
65 protected override async Task<InvokeResponse> OnInvokeActivityAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken)
66 {
67 logger.LogInformation("Received invoke activity: {Name}", turnContext.Activity.Name);
68
69 // Handle token exchange invoke (SSO)
70 if (turnContext.Activity.Name == SignInConstants.TokenExchangeOperationName)
71 {
72 return await OnTokenExchangeInvokeAsync(turnContext, cancellationToken);
73 }
74
75 // Handle signin verification invoke (manual sign-in fallback)
76 if (turnContext.Activity.Name == SignInConstants.VerifyStateOperationName)
77 {
78 return await OnVerifyStateInvokeAsync(turnContext, cancellationToken);
79 }
80
81 // Let base class handle other invokes (like Teams-specific invokes)
82 return await base.OnInvokeActivityAsync(turnContext, cancellationToken);
83 }
84
85 /// <summary>
86 /// Sends an OAuth SSO card with TokenExchangeResource to enable SSO.
87 /// </summary>
88 private async Task SendOAuthCardAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
89 {
90 UserTokenClient tokenClient = turnContext.TurnState.Get<UserTokenClient>();
91
92 // Get sign-in resource from token service (includes TokenExchangeResource for SSO)
93 SignInResource signInResource = await tokenClient.GetSignInResourceAsync(
94 ConnectionName,
95 (Activity)turnContext.Activity,
96 string.Empty,
97 cancellationToken);
98
99 logger.LogInformation("Got sign-in resource. SignInLink: {SignInLink}", signInResource.SignInLink);
100 logger.LogInformation("TokenExchangeResource.Id: {Id}, Uri: {Uri}",
101 signInResource.TokenExchangeResource?.Id,
102 signInResource.TokenExchangeResource?.Uri);
103
104 // Create OAuth SSO card
105 var oAuthCard = new OAuthCard
106 {
107 Text = "Please sign in to continue",
108 ConnectionName = ConnectionName,
109 TokenExchangeResource = signInResource.TokenExchangeResource,
110 TokenPostResource = signInResource.TokenPostResource,
111 Buttons = new[]
112 {
113 new CardAction
114 {
115 Title = "Sign In",
116 Text = "Sign in",
117 Type = ActionTypes.Signin,
118 Value = signInResource.SignInLink
119 }
120 }
121 };
122
123 var reply = MessageFactory.Attachment(oAuthCard.ToAttachment());
124 await turnContext.SendActivityAsync(reply, cancellationToken);
125 }
126
127 /// <summary>
128 /// Handles token exchange invoke for SSO.
129 /// Client sends this invoke with a token it obtained, and the bot exchanges it for a token for the configured connection.
130 /// </summary>
131 private async Task<InvokeResponse> OnTokenExchangeInvokeAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken)
132 {
133 logger.LogInformation("Processing token exchange invoke");
134
135 // Parse token exchange request from invoke value
136 TokenExchangeInvokeRequest? tokenExchangeRequest = (turnContext.Activity.Value as JObject)?.ToObject<TokenExchangeInvokeRequest>();
137
138 if (tokenExchangeRequest == null)
139 {
140 logger.LogWarning("Token exchange request is null");
141 return CreateInvokeResponse(
142 HttpStatusCode.BadRequest,
143 new TokenExchangeInvokeResponse
144 {
145 Id = null,
146 ConnectionName = ConnectionName,
147 FailureDetail = "The bot received an InvokeActivity that is missing a TokenExchangeInvokeRequest value."
148 });
149 }
150
151 logger.LogInformation("Token exchange request - Id: {Id}, ConnectionName: {ConnectionName}",
152 tokenExchangeRequest.Id, tokenExchangeRequest.ConnectionName);
153
154 // Validate connection name matches
155 if (tokenExchangeRequest.ConnectionName != ConnectionName)
156 {
157 logger.LogWarning("Connection name mismatch. Expected: {Expected}, Got: {Got}",
158 ConnectionName, tokenExchangeRequest.ConnectionName);
159 return CreateInvokeResponse(
160 HttpStatusCode.BadRequest,
161 new TokenExchangeInvokeResponse
162 {
163 Id = tokenExchangeRequest.Id,
164 ConnectionName = ConnectionName,
165 FailureDetail = "The bot received a TokenExchangeInvokeRequest with a ConnectionName that does not match."
166 });
167 }
168
169 UserTokenClient tokenClient = turnContext.TurnState.Get<UserTokenClient>();
170
171 // Attempt token exchange
172 TokenResponse? tokenExchangeResponse = null;
173 try
174 {
175 tokenExchangeResponse = await tokenClient.ExchangeTokenAsync(
176 turnContext.Activity.From.Id,
177 ConnectionName,
178 turnContext.Activity.ChannelId,
179 new TokenExchangeRequest { Token = tokenExchangeRequest.Token },
180 cancellationToken);
181
182 logger.LogInformation("Token exchange result: {Success}",
183 tokenExchangeResponse != null && !string.IsNullOrEmpty(tokenExchangeResponse.Token) ? "Success" : "Failed");
184 }
185 catch (Exception ex)
186 {
187 logger.LogError(ex, "Token exchange failed with exception");
188 // tokenExchangeResponse stays null
189 }
190
191 // Check if token exchange succeeded
192 if (tokenExchangeResponse == null || string.IsNullOrEmpty(tokenExchangeResponse.Token))
193 {
194 logger.LogWarning("Token exchange failed - no token received");
195 return CreateInvokeResponse(
196 HttpStatusCode.PreconditionFailed,
197 new TokenExchangeInvokeResponse
198 {
199 Id = tokenExchangeRequest.Id,
200 ConnectionName = ConnectionName,
201 FailureDetail = "Token exchange failed. The bot was unable to exchange the token."
202 });
203 }
204
205 // Success!
206 logger.LogInformation("✅ Token exchange successful!");
207 return CreateInvokeResponse(
208 HttpStatusCode.OK,
209 new TokenExchangeInvokeResponse
210 {
211 Id = tokenExchangeRequest.Id,
212 ConnectionName = ConnectionName
213 });
214 }
215
216 /// <summary>
217 /// Handles signin verification invoke for manual sign-in fallback.
218 /// When SSO token exchange fails, user clicks sign-in button and completes auth in browser.
219 /// Teams then sends this invoke with a "magic code" that the bot must use to get the token.
220 /// </summary>
221 private async Task<InvokeResponse> OnVerifyStateInvokeAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken)
222 {
223 logger.LogInformation("Processing signin/verifyState invoke (manual sign-in fallback)");
224
225 // Extract magic code from invoke value
226 var magicCodeObject = turnContext.Activity.Value as JObject;
227 var magicCode = magicCodeObject?.GetValue("state", StringComparison.Ordinal)?.ToString();
228
229 if (string.IsNullOrEmpty(magicCode))
230 {
231 logger.LogWarning("Magic code is missing from signin/verifyState invoke");
232 return CreateInvokeResponse(HttpStatusCode.BadRequest, null);
233 }
234
235 logger.LogInformation("Magic code received: {MagicCode}", magicCode[..Math.Min(10, magicCode.Length)] + "...");
236
237 UserTokenClient tokenClient = turnContext.TurnState.Get<UserTokenClient>();
238
239 // Getting the token follows a different flow in Teams. At the signin completion, Teams
240 // will send the bot an "invoke" activity that contains a "magic" code. This code MUST
241 // then be used to try fetching the token from Botframework service within some time
242 // period. We try here. If it succeeds, we return 200 with an empty body. If it fails
243 // with a retriable error, we return 500. Teams will re-send another invoke in this case.
244 // If it fails with a non-retriable error, we return 404. Teams will not retry in that case.
245 try
246 {
247 TokenResponse? token = await tokenClient.GetUserTokenAsync(
248 turnContext.Activity.From.Id,
249 ConnectionName,
250 turnContext.Activity.ChannelId,
251 magicCode,
252 cancellationToken);
253
254 if (token != null && !string.IsNullOrEmpty(token.Token))
255 {
256 logger.LogInformation("✅ Magic code verification successful! User is now signed in.");
257
258 // Success - return 200 with empty body
259 return CreateInvokeResponse(HttpStatusCode.OK, null);
260 }
261 else
262 {
263 logger.LogWarning("Magic code verification failed - token is null or empty");
264
265 // Token is null - return 404, Teams will NOT retry
266 return CreateInvokeResponse(HttpStatusCode.NotFound, null);
267 }
268 }
269 catch (Exception ex)
270 {
271 logger.LogError(ex, "Exception during magic code verification");
272
273 // Exception occurred - return 500, Teams WILL retry
274 return CreateInvokeResponse(HttpStatusCode.InternalServerError, null);
275 }
276 }
277
278 /// <summary>
279 /// Creates an InvokeResponse with the specified status code and body.
280 /// </summary>
281 private static InvokeResponse CreateInvokeResponse(HttpStatusCode statusCode, object? body)
282 {
283 return new InvokeResponse
284 {
285 Status = (int)statusCode,
286 Body = body
287 };
288 }
289
290 /// <summary>
291 /// Test scenario to reproduce the NullReferenceException issue when sending OAuth SSO cards.
292 /// This mimics the ProjectAgentBot.SendOAuthCardForSSO scenario.
293 /// </summary>
294 private async Task TestOAuthCardSendScenario(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
295 {
296 try
297 {
298 await turnContext.SendActivityAsync(MessageFactory.Text("Testing OAuth SSO card send scenario..."), cancellationToken);
299
300 // Get connector client - this uses CompatBotAdapter under the hood
301 IConnectorClient connectorClient = turnContext.TurnState.Get<IConnectorClient>();
302
303 if (connectorClient == null)
304 {
305 await turnContext.SendActivityAsync(MessageFactory.Text("❌ ERROR: ConnectorClient is null"), cancellationToken);
306 return;
307 }
308
309 // Get connection name from environment (set in launchSettings.json)
310 string? connectionName = _configuration.GetValue<string>("ConnectionName");
311
312 if (string.IsNullOrEmpty(connectionName))
313 {
314 await turnContext.SendActivityAsync(MessageFactory.Text("❌ ERROR: ConnectionName not configured in launch profile"), cancellationToken);
315 return;
316 }
317
318 logger.LogInformation($"Creating SSO OAuth card with ConnectionName: {connectionName}");
319
320 // Get UserTokenClient from TurnState (like the existing code does on line 29)
321 UserTokenClient userTokenClient = turnContext.TurnState.Get<UserTokenClient>();
322
323 // Get sign-in resource from token service
324 SignInResource signInResource = await userTokenClient.GetSignInResourceAsync(
325 connectionName,
326 (Activity)turnContext.Activity,
327 string.Empty,
328 cancellationToken
329 ).ConfigureAwait(false);
330
331 logger.LogInformation($"Got sign-in resource from token service. SignInLink: {signInResource.SignInLink}");
332 logger.LogInformation($"TokenExchangeResource: {signInResource.TokenExchangeResource?.Uri}");
333
334 // Create proper SSO OAuth card exactly like OAuthPrompt does
335 OAuthCard oAuthSsoCard = new OAuthCard
336 {
337 Text = "Please sign in to continue",
338 ConnectionName = connectionName,
339 TokenExchangeResource = signInResource.TokenExchangeResource,
340 TokenPostResource = signInResource.TokenPostResource,
341 Buttons = new[]
342 {
343 new CardAction
344 {
345 Title = "Sign in",
346 Text = "Please sign in to continue",
347 Type = ActionTypes.Signin,
348 Value = signInResource.SignInLink
349 }
350 }
351 };
352
353 // Create activity using MessageFactory.Attachment - this is what ProjectAgentBot does
354 IMessageActivity activity = MessageFactory.Attachment(oAuthSsoCard.ToAttachment());
355
356 ((Microsoft.Bot.Schema.Activity)activity).AttachmentLayout = null;
357
358 // Set properties like ProjectAgentBot does (lines 1255-1256)
359 activity.Recipient = turnContext.Activity.From; // Send to the user who messaged us
360 activity.Conversation = turnContext.Activity.Conversation;
361
362 logger.LogInformation("Sending OAuth card via connectorClient.Conversations.SendToConversationAsync");
363 logger.LogInformation($"ConversationId: {activity.Conversation.Id}");
364 logger.LogInformation($"Recipient: {activity.Recipient.Id}");
365
366 // This is the call that causes NullReferenceException when APX returns 202 with empty body
367 ResourceResponse response = await connectorClient.Conversations.SendToConversationAsync(
368 (Microsoft.Bot.Schema.Activity)activity,
369 cancellationToken
370 );
371
372 // If we get here, the call succeeded
373 await turnContext.SendActivityAsync(MessageFactory.Text($"✅ SUCCESS! Response ID: {response?.Id ?? "NULL"}"), cancellationToken);
374 logger.LogInformation($"OAuth card sent successfully. Response ID: {response?.Id}");
375 }
376 catch (NullReferenceException nre)
377 {
378 string errorMsg = $"❌ NullReferenceException caught! This is the bug we're investigating.\n" +
379 $"Message: {nre.Message}\n" +
380 $"StackTrace: {nre.StackTrace}";
381 await turnContext.SendActivityAsync(MessageFactory.Text(errorMsg), cancellationToken);
382 logger.LogError(nre, "NullReferenceException when sending OAuth card");
383 }
384 catch (Exception ex)
385 {
386 string errorMsg = $"❌ Unexpected exception: {ex.GetType().Name}\n" +
387 $"Message: {ex.Message}\n" +
388 $"StackTrace: {ex.StackTrace}";
389 await turnContext.SendActivityAsync(MessageFactory.Text(errorMsg), cancellationToken);
390 logger.LogError(ex, "Exception when sending OAuth card");
391 }
392 }
393 }
394}
395