Skip to content
Merged
34 changes: 34 additions & 0 deletions Lite/Helpers/MfaAuthenticationHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;

namespace PerformanceMonitorLite.Helpers;

/// <summary>
/// Helper utilities for Microsoft Entra MFA authentication.
/// </summary>
public static class MfaAuthenticationHelper
{
/// <summary>
/// Checks if an exception indicates that the user cancelled MFA authentication.
/// </summary>
/// <param name="ex">The exception to check.</param>
/// <returns>True if the exception represents user cancellation, false otherwise.</returns>
public static bool IsMfaCancelledException(Exception ex)
{
var message = ex.Message?.ToLowerInvariant() ?? string.Empty;

// Only treat explicit user cancellation messages as cancellation
// Do NOT treat authentication errors (wrong password, account selection, etc.) as cancellation
return message.Contains("user canceled") ||
message.Contains("user cancelled") ||
message.Contains("authentication was cancelled") ||
message.Contains("authentication was canceled");
}
}
12 changes: 11 additions & 1 deletion Lite/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,23 @@ private async void ConnectToServer(ServerConnection server)
return;
}

// Clear MFA cancellation flag when user explicitly connects
// This gives them a fresh attempt at authentication
var currentStatus = _serverManager.GetConnectionStatus(server.Id);
if (server.AuthenticationType == AuthenticationTypes.EntraMFA && currentStatus.UserCancelledMfa)
{
currentStatus.UserCancelledMfa = false;
StatusText.Text = "Retrying MFA authentication...";
}

// Ensure connection status is populated with UTC offset before opening tab
// This is critical for timezone-correct chart display
var status = _serverManager.GetConnectionStatus(server.Id);
if (!status.UtcOffsetMinutes.HasValue)
{
StatusText.Text = "Checking server connection...";
status = await _serverManager.CheckConnectionAsync(server.Id);
// Allow interactive auth (MFA) when user explicitly opens a server
status = await _serverManager.CheckConnectionAsync(server.Id, allowInteractiveAuth: true);
}

var utcOffset = status.UtcOffsetMinutes ?? 0;
Expand Down
30 changes: 30 additions & 0 deletions Lite/Models/AuthenticationTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

namespace PerformanceMonitorLite.Models;

/// <summary>
/// Constants for server authentication types.
/// </summary>
public static class AuthenticationTypes
{
/// <summary>
/// Windows integrated authentication.
/// </summary>
public const string Windows = "Windows";

/// <summary>
/// SQL Server username/password authentication.
/// </summary>
public const string SqlServer = "SqlServer";

/// <summary>
/// Microsoft Entra MFA (Azure AD) interactive authentication.
/// </summary>
public const string EntraMFA = "EntraMFA";
}
54 changes: 48 additions & 6 deletions Lite/Models/ServerConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,33 @@ public class ServerConnection
public string Id { get; set; } = Guid.NewGuid().ToString();
public string ServerName { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool UseWindowsAuth { get; set; } = true;

/// <summary>
/// Backward compatibility property for old servers.json files.
/// Returns true if authentication type is Windows.
/// Setter updates AuthenticationType for migration from old configs.
/// </summary>
public bool UseWindowsAuth
{
get => AuthenticationType == AuthenticationTypes.Windows;
set
{
// During JSON deserialization of old configs, update AuthenticationType based on UseWindowsAuth
// Only apply this if AuthenticationType is still at default (indicating old JSON without that field)
if (AuthenticationType == AuthenticationTypes.Windows && !value)
{
// Old config with UseWindowsAuth=false -> SQL Server auth
AuthenticationType = AuthenticationTypes.SqlServer;
}
// If value is true, keep Windows (already the default)
}
}

/// <summary>
/// Authentication type: Windows, SqlServer, or EntraMFA
/// </summary>
public string AuthenticationType { get; set; } = AuthenticationTypes.Windows;

public string? Description { get; set; }
public DateTime CreatedDate { get; set; } = DateTime.Now;
public DateTime LastConnected { get; set; } = DateTime.Now;
Expand Down Expand Up @@ -48,7 +74,12 @@ public class ServerConnection
/// Display-only property for showing authentication type in UI.
/// </summary>
[JsonIgnore]
public string AuthenticationDisplay => UseWindowsAuth ? "Windows" : "SQL Server";
public string AuthenticationDisplay => AuthenticationType switch
{
AuthenticationTypes.EntraMFA => "Microsoft Entra MFA",
AuthenticationTypes.SqlServer => "SQL Server",
_ => "Windows"
};

/// <summary>
/// Display-only property for showing status in UI.
Expand All @@ -65,7 +96,7 @@ public string GetConnectionString(CredentialService credentialService)
string? username = null;
string? password = null;

if (!UseWindowsAuth)
if (AuthenticationType == AuthenticationTypes.SqlServer)
{
var cred = credentialService.GetCredential(Id);
if (cred.HasValue)
Expand Down Expand Up @@ -102,16 +133,27 @@ private string BuildConnectionString(string? username, string? password)
_ => SqlConnectionEncryptOption.Optional
};

if (UseWindowsAuth)
if (AuthenticationType == AuthenticationTypes.Windows)
{
builder.IntegratedSecurity = true;
}
else
else if (AuthenticationType == AuthenticationTypes.SqlServer)
{
builder.IntegratedSecurity = false;
builder.UserID = username ?? string.Empty;
builder.Password = password ?? string.Empty;
}
else if (AuthenticationType == AuthenticationTypes.EntraMFA)
{
// Microsoft Entra MFA (Azure AD Interactive)
builder.IntegratedSecurity = false;
builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
// Optionally set UserID (email/UPN)
if (!string.IsNullOrWhiteSpace(username))
{
builder.UserID = username;
}
}

return builder.ConnectionString;
}
Expand All @@ -121,7 +163,7 @@ private string BuildConnectionString(string? username, string? password)
/// </summary>
public bool HasStoredCredentials(CredentialService credentialService)
{
if (UseWindowsAuth)
if (AuthenticationType == AuthenticationTypes.Windows || AuthenticationType == AuthenticationTypes.EntraMFA)
{
return true;
}
Expand Down
6 changes: 6 additions & 0 deletions Lite/Models/ServerConnectionStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ public class ServerConnectionStatus
/// </summary>
public int? UtcOffsetMinutes { get; set; }

/// <summary>
/// Indicates whether the user has cancelled MFA authentication for this server.
/// When true, MFA popups will not be shown until the user explicitly tries to connect again.
/// </summary>
public bool UserCancelledMfa { get; set; }

/// <summary>
/// Gets the status display text for the UI.
/// </summary>
Expand Down
88 changes: 80 additions & 8 deletions Lite/Services/RemoteCollectorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using PerformanceMonitorLite.Database;
using PerformanceMonitorLite.Helpers;
using PerformanceMonitorLite.Models;


Expand Down Expand Up @@ -64,6 +65,12 @@ public partial class RemoteCollectorService
/// </summary>
private static readonly SemaphoreSlim s_connectionThrottle = new(7, 7);

/// <summary>
/// Serializes MFA authentication attempts to prevent multiple popups.
/// Only one MFA authentication can happen at a time.
/// </summary>
private static readonly SemaphoreSlim s_mfaAuthLock = new(1, 1);

/// <summary>
/// Command timeout for DMV queries in seconds.
/// </summary>
Expand Down Expand Up @@ -254,6 +261,16 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam
return;
}

// Skip MFA servers if user has cancelled authentication
// This prevents repeated popup dialogs during background data collection
if (server.AuthenticationType == AuthenticationTypes.EntraMFA && serverStatus.UserCancelledMfa)
{
AppLogger.Info("Collector", $" [{server.DisplayName}] {collectorName} SKIPPED - MFA authentication cancelled by user");
_logger?.LogDebug("Skipping collector '{Collector}' for server '{Server}' - user cancelled MFA",
collectorName, server.DisplayName);
return;
}

_logger?.LogDebug("Running collector '{Collector}' for server '{Server}'",
collectorName, server.DisplayName);

Expand Down Expand Up @@ -314,6 +331,13 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam
collectorName, ex.Number, server.DisplayName);
}
}
catch (InvalidOperationException ex) when (ex.Message.Contains("MFA authentication cancelled"))
{
// User cancelled MFA - don't log as error, this is expected
status = "SKIPPED";
errorMessage = "MFA authentication cancelled by user";
AppLogger.Info("Collector", $" [{server.DisplayName}] {collectorName} SKIPPED - {errorMessage}");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
status = "CANCELLED";
Expand Down Expand Up @@ -395,13 +419,36 @@ INSERT INTO collection_log (log_id, server_id, collector_name, collection_time,

/// <summary>
/// Creates a SQL connection to a remote server.
/// Throws InvalidOperationException if MFA authentication was cancelled by user.
/// </summary>
protected async Task<SqlConnection> CreateConnectionAsync(ServerConnection server, CancellationToken cancellationToken)
{
await s_connectionThrottle.WaitAsync(cancellationToken);
// For MFA servers, serialize authentication attempts to prevent multiple popups
bool isMfaServer = server.AuthenticationType == AuthenticationTypes.EntraMFA;
bool mfaLockAcquired = false;

try
{
var connectionString = server.GetConnectionString(_serverManager.CredentialService);
// Acquire MFA lock first (if applicable) to serialize authentication
if (isMfaServer)
{
await s_mfaAuthLock.WaitAsync(cancellationToken);
mfaLockAcquired = true;

// Check if user already cancelled MFA for this server
var serverStatus = _serverManager.GetConnectionStatus(server.Id);
if (serverStatus.UserCancelledMfa)
{
AppLogger.Info("Collector", $" [{server.DisplayName}] MFA authentication already cancelled - aborting");
throw new InvalidOperationException("MFA authentication cancelled by user. Please connect to the server explicitly to retry.");
}
}

// Now acquire connection throttle
await s_connectionThrottle.WaitAsync(cancellationToken);
try
{
var connectionString = server.GetConnectionString(_serverManager.CredentialService);

var builder = new SqlConnectionStringBuilder(connectionString)
{
Expand All @@ -410,16 +457,41 @@ protected async Task<SqlConnection> CreateConnectionAsync(ServerConnection serve

var connStr = builder.ConnectionString;

return await RetryHelper.ExecuteWithRetryAsync(async () =>
return await RetryHelper.ExecuteWithRetryAsync(async () =>
{
var connection = new SqlConnection(connStr);

try
{
await connection.OpenAsync(cancellationToken);
return connection;
}
catch (Exception ex) when (isMfaServer)
{
// Detect MFA cancellation and mark immediately so other waiting connections abort
if (MfaAuthenticationHelper.IsMfaCancelledException(ex))
{
var serverStatus = _serverManager.GetConnectionStatus(server.Id);
serverStatus.UserCancelledMfa = true;
AppLogger.Info("Collector", $" [{server.DisplayName}] MFA authentication cancelled by user");
_logger?.LogInformation("MFA authentication cancelled by user for server '{DisplayName}' - flagging to abort other pending connections", server.DisplayName);
}
throw;
}
}, _logger, $"Connect to {server.DisplayName}", cancellationToken: cancellationToken);
}
finally
{
var connection = new SqlConnection(connStr);
await connection.OpenAsync(cancellationToken);
return connection;
}, _logger, $"Connect to {server.DisplayName}", cancellationToken: cancellationToken);
s_connectionThrottle.Release();
}
}
finally
{
s_connectionThrottle.Release();
// Release MFA lock if we acquired it
if (mfaLockAcquired)
{
s_mfaAuthLock.Release();
}
}
}

Expand Down
Loading