// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Net;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Connector.Authentication;
using Microsoft.Bot.Schema;
using Newtonsoft.Json.Linq;
namespace PABot.Bots
{
///
/// OAuth SSO Bot - Demonstrates OAuth Single Sign-On flow with token exchange.
///
/// Flow:
/// 1. User sends any message
/// 2. Bot checks if user has a token
/// 3. If no token, sends OAuth SSO card with TokenExchangeResource
/// 4. Client attempts SSO token exchange by sending invoke activity
/// 5. Bot handles token exchange and responds with success/failure
/// 6. If token exchange fails, user clicks sign-in button for manual auth
///
public class SsoBot(ILogger logger, IConfiguration configuration) : ActivityHandler
{
private readonly IConfiguration _configuration = configuration;
private const string ConnectionName = "graph-sso"; // From launchSettings.json
protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken)
{
// Special test scenario to reproduce NullReferenceException bug
if (turnContext.Activity.Text?.Contains("test oauth card", StringComparison.OrdinalIgnoreCase) == true)
{
await TestOAuthCardSendScenario(turnContext, cancellationToken);
return;
}
UserTokenClient tokenClient = turnContext.TurnState.Get();
// Try to get existing token
TokenResponse token = await tokenClient.GetUserTokenAsync(
turnContext.Activity.From.Id,
ConnectionName,
turnContext.Activity.ChannelId,
null,
cancellationToken);
if (token != null && !string.IsNullOrEmpty(token.Token))
{
// User is authenticated - show token info
logger.LogInformation("User has valid token for connection '{ConnectionName}'", ConnectionName);
await turnContext.SendActivityAsync(
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}"),
cancellationToken);
}
else
{
// No token - send OAuth SSO card
logger.LogInformation("No token found, sending OAuth SSO card");
await SendOAuthCardAsync(turnContext, cancellationToken);
}
}
protected override async Task OnInvokeActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken)
{
logger.LogInformation("Received invoke activity: {Name}", turnContext.Activity.Name);
// Handle token exchange invoke (SSO)
if (turnContext.Activity.Name == SignInConstants.TokenExchangeOperationName)
{
return await OnTokenExchangeInvokeAsync(turnContext, cancellationToken);
}
// Handle signin verification invoke (manual sign-in fallback)
if (turnContext.Activity.Name == SignInConstants.VerifyStateOperationName)
{
return await OnVerifyStateInvokeAsync(turnContext, cancellationToken);
}
// Let base class handle other invokes (like Teams-specific invokes)
return await base.OnInvokeActivityAsync(turnContext, cancellationToken);
}
///
/// Sends an OAuth SSO card with TokenExchangeResource to enable SSO.
///
private async Task SendOAuthCardAsync(ITurnContext turnContext, CancellationToken cancellationToken)
{
UserTokenClient tokenClient = turnContext.TurnState.Get();
// Get sign-in resource from token service (includes TokenExchangeResource for SSO)
SignInResource signInResource = await tokenClient.GetSignInResourceAsync(
ConnectionName,
(Activity)turnContext.Activity,
string.Empty,
cancellationToken);
logger.LogInformation("Got sign-in resource. SignInLink: {SignInLink}", signInResource.SignInLink);
logger.LogInformation("TokenExchangeResource.Id: {Id}, Uri: {Uri}",
signInResource.TokenExchangeResource?.Id,
signInResource.TokenExchangeResource?.Uri);
// Create OAuth SSO card
OAuthCard oAuthCard = new()
{
Text = "Please sign in to continue",
ConnectionName = ConnectionName,
TokenExchangeResource = signInResource.TokenExchangeResource,
TokenPostResource = signInResource.TokenPostResource,
Buttons = new[]
{
new CardAction
{
Title = "Sign In",
Text = "Sign in",
Type = ActionTypes.Signin,
Value = signInResource.SignInLink
}
}
};
IMessageActivity reply = MessageFactory.Attachment(oAuthCard.ToAttachment());
await turnContext.SendActivityAsync(reply, cancellationToken);
}
///
/// Handles token exchange invoke for SSO.
/// Client sends this invoke with a token it obtained, and the bot exchanges it for a token for the configured connection.
///
private async Task OnTokenExchangeInvokeAsync(ITurnContext turnContext, CancellationToken cancellationToken)
{
logger.LogInformation("Processing token exchange invoke");
// Parse token exchange request from invoke value
TokenExchangeInvokeRequest? tokenExchangeRequest = (turnContext.Activity.Value as JObject)?.ToObject();
if (tokenExchangeRequest == null)
{
logger.LogWarning("Token exchange request is null");
return CreateInvokeResponse(
HttpStatusCode.BadRequest,
new TokenExchangeInvokeResponse
{
Id = null,
ConnectionName = ConnectionName,
FailureDetail = "The bot received an InvokeActivity that is missing a TokenExchangeInvokeRequest value."
});
}
logger.LogInformation("Token exchange request - Id: {Id}, ConnectionName: {ConnectionName}",
tokenExchangeRequest.Id, tokenExchangeRequest.ConnectionName);
// Validate connection name matches
if (tokenExchangeRequest.ConnectionName != ConnectionName)
{
logger.LogWarning("Connection name mismatch. Expected: {Expected}, Got: {Got}",
ConnectionName, tokenExchangeRequest.ConnectionName);
return CreateInvokeResponse(
HttpStatusCode.BadRequest,
new TokenExchangeInvokeResponse
{
Id = tokenExchangeRequest.Id,
ConnectionName = ConnectionName,
FailureDetail = "The bot received a TokenExchangeInvokeRequest with a ConnectionName that does not match."
});
}
UserTokenClient tokenClient = turnContext.TurnState.Get();
// Attempt token exchange
TokenResponse? tokenExchangeResponse = null;
try
{
tokenExchangeResponse = await tokenClient.ExchangeTokenAsync(
turnContext.Activity.From.Id,
ConnectionName,
turnContext.Activity.ChannelId,
new TokenExchangeRequest { Token = tokenExchangeRequest.Token },
cancellationToken);
logger.LogInformation("Token exchange result: {Success}",
tokenExchangeResponse != null && !string.IsNullOrEmpty(tokenExchangeResponse.Token) ? "Success" : "Failed");
}
catch (Exception ex)
{
logger.LogError(ex, "Token exchange failed with exception");
// tokenExchangeResponse stays null
}
// Check if token exchange succeeded
if (tokenExchangeResponse == null || string.IsNullOrEmpty(tokenExchangeResponse.Token))
{
logger.LogWarning("Token exchange failed - no token received");
return CreateInvokeResponse(
HttpStatusCode.PreconditionFailed,
new TokenExchangeInvokeResponse
{
Id = tokenExchangeRequest.Id,
ConnectionName = ConnectionName,
FailureDetail = "Token exchange failed. The bot was unable to exchange the token."
});
}
// Success!
logger.LogInformation("✅ Token exchange successful!");
return CreateInvokeResponse(
HttpStatusCode.OK,
new TokenExchangeInvokeResponse
{
Id = tokenExchangeRequest.Id,
ConnectionName = ConnectionName
});
}
///
/// Handles signin verification invoke for manual sign-in fallback.
/// When SSO token exchange fails, user clicks sign-in button and completes auth in browser.
/// Teams then sends this invoke with a "magic code" that the bot must use to get the token.
///
private async Task OnVerifyStateInvokeAsync(ITurnContext turnContext, CancellationToken cancellationToken)
{
logger.LogInformation("Processing signin/verifyState invoke (manual sign-in fallback)");
// Extract magic code from invoke value
JObject? magicCodeObject = turnContext.Activity.Value as JObject;
string? magicCode = magicCodeObject?.GetValue("state", StringComparison.Ordinal)?.ToString();
if (string.IsNullOrEmpty(magicCode))
{
logger.LogWarning("Magic code is missing from signin/verifyState invoke");
return CreateInvokeResponse(HttpStatusCode.BadRequest, null);
}
logger.LogInformation("Magic code received: {MagicCode}", magicCode[..Math.Min(10, magicCode.Length)] + "...");
UserTokenClient tokenClient = turnContext.TurnState.Get();
// Getting the token follows a different flow in Teams. At the signin completion, Teams
// will send the bot an "invoke" activity that contains a "magic" code. This code MUST
// then be used to try fetching the token from Botframework service within some time
// period. We try here. If it succeeds, we return 200 with an empty body. If it fails
// with a retriable error, we return 500. Teams will re-send another invoke in this case.
// If it fails with a non-retriable error, we return 404. Teams will not retry in that case.
try
{
TokenResponse? token = await tokenClient.GetUserTokenAsync(
turnContext.Activity.From.Id,
ConnectionName,
turnContext.Activity.ChannelId,
magicCode,
cancellationToken);
if (token != null && !string.IsNullOrEmpty(token.Token))
{
logger.LogInformation("✅ Magic code verification successful! User is now signed in.");
// Success - return 200 with empty body
return CreateInvokeResponse(HttpStatusCode.OK, null);
}
else
{
logger.LogWarning("Magic code verification failed - token is null or empty");
// Token is null - return 404, Teams will NOT retry
return CreateInvokeResponse(HttpStatusCode.NotFound, null);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Exception during magic code verification");
// Exception occurred - return 500, Teams WILL retry
return CreateInvokeResponse(HttpStatusCode.InternalServerError, null);
}
}
///
/// Creates an InvokeResponse with the specified status code and body.
///
private static InvokeResponse CreateInvokeResponse(HttpStatusCode statusCode, object? body)
{
return new InvokeResponse
{
Status = (int)statusCode,
Body = body
};
}
///
/// Test scenario to reproduce the NullReferenceException issue when sending OAuth SSO cards.
/// This mimics the ProjectAgentBot.SendOAuthCardForSSO scenario.
///
private async Task TestOAuthCardSendScenario(ITurnContext turnContext, CancellationToken cancellationToken)
{
try
{
await turnContext.SendActivityAsync(MessageFactory.Text("Testing OAuth SSO card send scenario..."), cancellationToken);
// Get connector client - this uses TeamsBotAdapter under the hood
IConnectorClient connectorClient = turnContext.TurnState.Get();
if (connectorClient == null)
{
await turnContext.SendActivityAsync(MessageFactory.Text("❌ ERROR: ConnectorClient is null"), cancellationToken);
return;
}
// Get connection name from environment (set in launchSettings.json)
string? connectionName = _configuration.GetValue("ConnectionName");
if (string.IsNullOrEmpty(connectionName))
{
await turnContext.SendActivityAsync(MessageFactory.Text("❌ ERROR: ConnectionName not configured in launch profile"), cancellationToken);
return;
}
logger.LogInformation($"Creating SSO OAuth card with ConnectionName: {connectionName}");
// Get UserTokenClient from TurnState (like the existing code does on line 29)
UserTokenClient userTokenClient = turnContext.TurnState.Get();
// Get sign-in resource from token service
SignInResource signInResource = await userTokenClient.GetSignInResourceAsync(
connectionName,
(Activity)turnContext.Activity,
string.Empty,
cancellationToken
).ConfigureAwait(false);
logger.LogInformation($"Got sign-in resource from token service. SignInLink: {signInResource.SignInLink}");
logger.LogInformation($"TokenExchangeResource: {signInResource.TokenExchangeResource?.Uri}");
// Create proper SSO OAuth card exactly like OAuthPrompt does
OAuthCard oAuthSsoCard = new()
{
Text = "Please sign in to continue",
ConnectionName = connectionName,
TokenExchangeResource = signInResource.TokenExchangeResource,
TokenPostResource = signInResource.TokenPostResource,
Buttons = new[]
{
new CardAction
{
Title = "Sign in",
Text = "Please sign in to continue",
Type = ActionTypes.Signin,
Value = signInResource.SignInLink
}
}
};
// Create activity using MessageFactory.Attachment - this is what ProjectAgentBot does
IMessageActivity activity = MessageFactory.Attachment(oAuthSsoCard.ToAttachment());
((Microsoft.Bot.Schema.Activity)activity).AttachmentLayout = null;
// Set properties like ProjectAgentBot does (lines 1255-1256)
activity.Recipient = turnContext.Activity.From; // Send to the user who messaged us
activity.Conversation = turnContext.Activity.Conversation;
logger.LogInformation("Sending OAuth card via connectorClient.Conversations.SendToConversationAsync");
logger.LogInformation($"ConversationId: {activity.Conversation.Id}");
logger.LogInformation($"Recipient: {activity.Recipient.Id}");
// This is the call that causes NullReferenceException when APX returns 202 with empty body
ResourceResponse response = await connectorClient.Conversations.SendToConversationAsync(
(Microsoft.Bot.Schema.Activity)activity,
cancellationToken
);
// If we get here, the call succeeded
await turnContext.SendActivityAsync(MessageFactory.Text($"✅ SUCCESS! Response ID: {response?.Id ?? "NULL"}"), cancellationToken);
logger.LogInformation($"OAuth card sent successfully. Response ID: {response?.Id}");
}
catch (NullReferenceException nre)
{
string errorMsg = $"❌ NullReferenceException caught! This is the bug we're investigating.\n" +
$"Message: {nre.Message}\n" +
$"StackTrace: {nre.StackTrace}";
await turnContext.SendActivityAsync(MessageFactory.Text(errorMsg), cancellationToken);
logger.LogError(nre, "NullReferenceException when sending OAuth card");
}
catch (Exception ex)
{
string errorMsg = $"❌ Unexpected exception: {ex.GetType().Name}\n" +
$"Message: {ex.Message}\n" +
$"StackTrace: {ex.StackTrace}";
await turnContext.SendActivityAsync(MessageFactory.Text(errorMsg), cancellationToken);
logger.LogError(ex, "Exception when sending OAuth card");
}
}
}
}