diff --git a/Lite/Helpers/MfaAuthenticationHelper.cs b/Lite/Helpers/MfaAuthenticationHelper.cs
new file mode 100644
index 00000000..dfedf948
--- /dev/null
+++ b/Lite/Helpers/MfaAuthenticationHelper.cs
@@ -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;
+
+///
+/// Helper utilities for Microsoft Entra MFA authentication.
+///
+public static class MfaAuthenticationHelper
+{
+ ///
+ /// Checks if an exception indicates that the user cancelled MFA authentication.
+ ///
+ /// The exception to check.
+ /// True if the exception represents user cancellation, false otherwise.
+ 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");
+ }
+}
diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs
index cfae29a2..03aa2b23 100644
--- a/Lite/MainWindow.xaml.cs
+++ b/Lite/MainWindow.xaml.cs
@@ -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;
diff --git a/Lite/Models/AuthenticationTypes.cs b/Lite/Models/AuthenticationTypes.cs
new file mode 100644
index 00000000..f2842e1a
--- /dev/null
+++ b/Lite/Models/AuthenticationTypes.cs
@@ -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;
+
+///
+/// Constants for server authentication types.
+///
+public static class AuthenticationTypes
+{
+ ///
+ /// Windows integrated authentication.
+ ///
+ public const string Windows = "Windows";
+
+ ///
+ /// SQL Server username/password authentication.
+ ///
+ public const string SqlServer = "SqlServer";
+
+ ///
+ /// Microsoft Entra MFA (Azure AD) interactive authentication.
+ ///
+ public const string EntraMFA = "EntraMFA";
+}
diff --git a/Lite/Models/ServerConnection.cs b/Lite/Models/ServerConnection.cs
index c3a8a3fa..34bd442e 100644
--- a/Lite/Models/ServerConnection.cs
+++ b/Lite/Models/ServerConnection.cs
@@ -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;
+
+ ///
+ /// Backward compatibility property for old servers.json files.
+ /// Returns true if authentication type is Windows.
+ /// Setter updates AuthenticationType for migration from old configs.
+ ///
+ 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)
+ }
+ }
+
+ ///
+ /// Authentication type: Windows, SqlServer, or EntraMFA
+ ///
+ 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;
@@ -48,7 +74,12 @@ public class ServerConnection
/// Display-only property for showing authentication type in UI.
///
[JsonIgnore]
- public string AuthenticationDisplay => UseWindowsAuth ? "Windows" : "SQL Server";
+ public string AuthenticationDisplay => AuthenticationType switch
+ {
+ AuthenticationTypes.EntraMFA => "Microsoft Entra MFA",
+ AuthenticationTypes.SqlServer => "SQL Server",
+ _ => "Windows"
+ };
///
/// Display-only property for showing status in UI.
@@ -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)
@@ -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;
}
@@ -121,7 +163,7 @@ private string BuildConnectionString(string? username, string? password)
///
public bool HasStoredCredentials(CredentialService credentialService)
{
- if (UseWindowsAuth)
+ if (AuthenticationType == AuthenticationTypes.Windows || AuthenticationType == AuthenticationTypes.EntraMFA)
{
return true;
}
diff --git a/Lite/Models/ServerConnectionStatus.cs b/Lite/Models/ServerConnectionStatus.cs
index 6aa365c3..16977173 100644
--- a/Lite/Models/ServerConnectionStatus.cs
+++ b/Lite/Models/ServerConnectionStatus.cs
@@ -88,6 +88,12 @@ public class ServerConnectionStatus
///
public int? UtcOffsetMinutes { get; set; }
+ ///
+ /// 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.
+ ///
+ public bool UserCancelledMfa { get; set; }
+
///
/// Gets the status display text for the UI.
///
diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs
index 6dfb5353..c9f5edd3 100644
--- a/Lite/Services/RemoteCollectorService.cs
+++ b/Lite/Services/RemoteCollectorService.cs
@@ -16,6 +16,7 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Helpers;
using PerformanceMonitorLite.Models;
@@ -64,6 +65,12 @@ public partial class RemoteCollectorService
///
private static readonly SemaphoreSlim s_connectionThrottle = new(7, 7);
+ ///
+ /// Serializes MFA authentication attempts to prevent multiple popups.
+ /// Only one MFA authentication can happen at a time.
+ ///
+ private static readonly SemaphoreSlim s_mfaAuthLock = new(1, 1);
+
///
/// Command timeout for DMV queries in seconds.
///
@@ -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);
@@ -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";
@@ -395,13 +419,36 @@ INSERT INTO collection_log (log_id, server_id, collector_name, collection_time,
///
/// Creates a SQL connection to a remote server.
+ /// Throws InvalidOperationException if MFA authentication was cancelled by user.
///
protected async Task 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)
{
@@ -410,16 +457,41 @@ protected async Task 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();
+ }
}
}
diff --git a/Lite/Services/ServerManager.cs b/Lite/Services/ServerManager.cs
index d14a313a..2363ae62 100644
--- a/Lite/Services/ServerManager.cs
+++ b/Lite/Services/ServerManager.cs
@@ -15,6 +15,7 @@
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
+using PerformanceMonitorLite.Helpers;
using PerformanceMonitorLite.Models;
namespace PerformanceMonitorLite.Services;
@@ -104,13 +105,23 @@ public void AddServer(ServerConnection server, string? username = null, string?
SaveServers();
}
- if (!server.UseWindowsAuth && !string.IsNullOrEmpty(username) && password != null)
+ // Save credentials based on authentication type
+ if (server.AuthenticationType == AuthenticationTypes.SqlServer && !string.IsNullOrEmpty(username) && password != null)
{
+ // For SQL Server auth, save both username and password
if (!_credentialService.SaveCredential(server.Id, username, password))
{
throw new InvalidOperationException("Failed to save credentials to Windows Credential Manager");
}
}
+ else if (server.AuthenticationType == AuthenticationTypes.EntraMFA && !string.IsNullOrEmpty(username))
+ {
+ // For MFA auth, save username (password can be empty)
+ if (!_credentialService.SaveCredential(server.Id, username, string.Empty))
+ {
+ throw new InvalidOperationException("Failed to save username to Windows Credential Manager");
+ }
+ }
// Initialize status as unknown for new server
_connectionStatuses[server.Id] = new ServerConnectionStatus { ServerId = server.Id };
@@ -136,15 +147,26 @@ public void UpdateServer(ServerConnection server, string? username = null, strin
SaveServers();
}
- if (!server.UseWindowsAuth && !string.IsNullOrEmpty(username) && password != null)
+ // Update credentials based on authentication type
+ if (server.AuthenticationType == AuthenticationTypes.SqlServer && !string.IsNullOrEmpty(username) && password != null)
{
+ // For SQL Server auth, update both username and password
if (!_credentialService.UpdateCredential(server.Id, username, password))
{
throw new InvalidOperationException("Failed to update credentials in Windows Credential Manager");
}
}
- else if (server.UseWindowsAuth)
+ else if (server.AuthenticationType == AuthenticationTypes.EntraMFA && !string.IsNullOrEmpty(username))
{
+ // For MFA auth, update username (password can be empty)
+ if (!_credentialService.UpdateCredential(server.Id, username, string.Empty))
+ {
+ throw new InvalidOperationException("Failed to update username in Windows Credential Manager");
+ }
+ }
+ else if (server.AuthenticationType == AuthenticationTypes.Windows)
+ {
+ // For Windows auth, remove any stored credentials
_credentialService.DeleteCredential(server.Id);
}
@@ -226,7 +248,9 @@ public ServerConnectionStatus GetConnectionStatus(string serverId)
///
/// Checks the connection status of a server.
///
- public async Task CheckConnectionAsync(string serverId)
+ /// The server ID to check.
+ /// Whether to allow interactive authentication (e.g., MFA). Set to false for background checks.
+ public async Task CheckConnectionAsync(string serverId, bool allowInteractiveAuth = false)
{
var server = GetServerById(serverId);
if (server == null)
@@ -244,6 +268,48 @@ public async Task CheckConnectionAsync(string serverId)
// Get previous status to detect status changes
var previousStatus = GetConnectionStatus(serverId);
+ // Skip interactive authentication methods during background checks
+ if (!allowInteractiveAuth && server.AuthenticationType == AuthenticationTypes.EntraMFA)
+ {
+ // Determine appropriate message based on whether user cancelled
+ var errorMsg = previousStatus.UserCancelledMfa
+ ? "Authentication cancelled by user"
+ : "Skipped - requires interactive authentication";
+
+ return new ServerConnectionStatus
+ {
+ ServerId = serverId,
+ IsOnline = previousStatus.UserCancelledMfa ? false : previousStatus.IsOnline,
+ LastChecked = DateTime.Now,
+ StatusChangedAt = previousStatus.StatusChangedAt,
+ ErrorMessage = errorMsg,
+ PreviousIsOnline = previousStatus.IsOnline,
+ UserCancelledMfa = previousStatus.UserCancelledMfa
+ };
+ }
+
+ // Clear cancellation flag when user explicitly tries to connect (allowInteractiveAuth = true)
+ // This gives them a fresh attempt at authentication
+ if (allowInteractiveAuth && previousStatus.UserCancelledMfa)
+ {
+ _logger?.LogDebug("Clearing MFA cancellation flag for server '{DisplayName}' - user is retrying", server.DisplayName);
+ }
+
+ // CRITICAL: Prevent connection checks while Add/Edit dialog is open
+ // This prevents MFA popups when user is just configuring the server
+ if (Windows.AddServerDialog.IsDialogOpen && server.AuthenticationType == AuthenticationTypes.EntraMFA)
+ {
+ return new ServerConnectionStatus
+ {
+ ServerId = serverId,
+ IsOnline = previousStatus.IsOnline,
+ LastChecked = DateTime.Now,
+ StatusChangedAt = previousStatus.StatusChangedAt,
+ ErrorMessage = "Skipped - dialog open",
+ PreviousIsOnline = previousStatus.IsOnline
+ };
+ }
+
var status = new ServerConnectionStatus
{
ServerId = serverId,
@@ -281,6 +347,7 @@ CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds
{
status.IsOnline = true;
status.ErrorMessage = null;
+ status.UserCancelledMfa = false; // Clear cancellation flag on successful connection
if (!reader.IsDBNull(0))
{
@@ -314,13 +381,35 @@ CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds
{
status.IsOnline = false;
status.ErrorMessage = ex.Message;
- _logger?.LogWarning("Connectivity check failed for server '{DisplayName}': {Message}", server.DisplayName, ex.Message);
+
+ // Detect MFA cancellation (error code 0 with specific message patterns)
+ if (server.AuthenticationType == AuthenticationTypes.EntraMFA && MfaAuthenticationHelper.IsMfaCancelledException(ex))
+ {
+ status.UserCancelledMfa = true;
+ status.ErrorMessage = "Authentication cancelled by user";
+ _logger?.LogInformation("MFA authentication cancelled by user for server '{DisplayName}'", server.DisplayName);
+ }
+ else
+ {
+ _logger?.LogWarning("Connectivity check failed for server '{DisplayName}': {Message}", server.DisplayName, ex.Message);
+ }
}
catch (Exception ex)
{
status.IsOnline = false;
status.ErrorMessage = ex.Message;
- _logger?.LogWarning(ex, "Connectivity check error for server '{DisplayName}'", server.DisplayName);
+
+ // Detect MFA cancellation from generic exceptions
+ if (server.AuthenticationType == AuthenticationTypes.EntraMFA && MfaAuthenticationHelper.IsMfaCancelledException(ex))
+ {
+ status.UserCancelledMfa = true;
+ status.ErrorMessage = "Authentication cancelled by user";
+ _logger?.LogInformation("MFA authentication cancelled by user for server '{DisplayName}'", server.DisplayName);
+ }
+ else
+ {
+ _logger?.LogWarning(ex, "Connectivity check error for server '{DisplayName}'", server.DisplayName);
+ }
}
// Track when status changed (online to offline or vice versa)
@@ -343,11 +432,13 @@ CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds
///
/// Checks the connection status of all servers.
+ /// Background operation - will skip servers requiring interactive authentication (e.g., MFA).
///
public async Task CheckAllConnectionsAsync()
{
var servers = GetAllServers();
- var tasks = servers.Select(s => CheckConnectionAsync(s.Id));
+ // Explicitly pass allowInteractiveAuth: false to prevent MFA popups during background checks
+ var tasks = servers.Select(s => CheckConnectionAsync(s.Id, allowInteractiveAuth: false));
await Task.WhenAll(tasks);
}
@@ -373,6 +464,10 @@ private void LoadServers()
try { File.Copy(_configFilePath, _configFilePath + ".bak", overwrite: true); }
catch { /* best effort */ }
+ // MIGRATION: Backward compatibility for existing servers.json files
+ // Migration from old UseWindowsAuth property happens automatically during deserialization
+ MigrateServerAuthentication(_servers);
+
// Initialize status tracking for all loaded servers
foreach (var server in _servers)
{
@@ -394,6 +489,10 @@ private void LoadServers()
string bakJson = File.ReadAllText(bakPath);
var bakConfig = JsonSerializer.Deserialize(bakJson);
_servers = bakConfig?.Servers ?? new List();
+
+ // MIGRATION: Backward compatibility
+ MigrateServerAuthentication(_servers);
+
foreach (var server in _servers)
{
_connectionStatuses[server.Id] = new ServerConnectionStatus { ServerId = server.Id };
@@ -430,6 +529,22 @@ private void SaveServers()
}
}
+ ///
+ /// Migrates server authentication configuration for backward compatibility.
+ /// Note: Migration from old UseWindowsAuth property happens automatically via
+ /// the UseWindowsAuth setter during JSON deserialization.
+ ///
+ private void MigrateServerAuthentication(List servers)
+ {
+ // Migration is now automatic via UseWindowsAuth property setter
+ // Just log the loaded servers for debugging
+ foreach (var server in servers)
+ {
+ _logger?.LogDebug("Server '{DisplayName}' loaded with AuthenticationType={AuthType}",
+ server.DisplayName, server.AuthenticationType);
+ }
+ }
+
///
/// JSON wrapper for servers list.
///
diff --git a/Lite/Windows/AddServerDialog.xaml b/Lite/Windows/AddServerDialog.xaml
index 730a8fe3..376d6f4a 100644
--- a/Lite/Windows/AddServerDialog.xaml
+++ b/Lite/Windows/AddServerDialog.xaml
@@ -43,7 +43,10 @@
+ Checked="AuthMode_Changed" Margin="0,0,0,4"/>
+
@@ -56,6 +59,13 @@
Padding="6,4"/>
+
+
+
+
+
+
diff --git a/Lite/Windows/AddServerDialog.xaml.cs b/Lite/Windows/AddServerDialog.xaml.cs
index 861452d8..085c8960 100644
--- a/Lite/Windows/AddServerDialog.xaml.cs
+++ b/Lite/Windows/AddServerDialog.xaml.cs
@@ -9,6 +9,7 @@
using System;
using System.Windows;
using Microsoft.Data.SqlClient;
+using PerformanceMonitorLite.Helpers;
using PerformanceMonitorLite.Models;
using PerformanceMonitorLite.Services;
@@ -17,6 +18,12 @@ namespace PerformanceMonitorLite.Windows;
public partial class AddServerDialog : Window
{
private readonly ServerManager _serverManager;
+ private static bool _isDialogOpen = false;
+
+ ///
+ /// Indicates if any AddServerDialog is currently open. Used to prevent background connection checks.
+ ///
+ public static bool IsDialogOpen => _isDialogOpen;
///
/// The server that was added, or null if the dialog was cancelled.
@@ -27,6 +34,8 @@ public AddServerDialog(ServerManager serverManager)
{
InitializeComponent();
_serverManager = serverManager;
+ _isDialogOpen = true;
+ Closed += (s, e) => _isDialogOpen = false;
}
///
@@ -51,13 +60,35 @@ public AddServerDialog(ServerManager serverManager, ServerConnection existing) :
DescriptionTextBox.Text = existing.Description ?? "";
DatabaseNameBox.Text = existing.DatabaseName ?? "";
- if (existing.UseWindowsAuth)
+ // Set authentication mode
+ if (existing.AuthenticationType == AuthenticationTypes.EntraMFA)
{
- WindowsAuthRadio.IsChecked = true;
+ EntraMfaAuthRadio.IsChecked = true;
+
+ // Load username if stored
+ var credentialService = new CredentialService();
+ var cred = credentialService.GetCredential(existing.Id);
+ if (cred.HasValue)
+ {
+ EntraMfaUsernameBox.Text = cred.Value.Username;
+ }
}
- else
+ else if (existing.AuthenticationType == AuthenticationTypes.SqlServer)
{
SqlAuthRadio.IsChecked = true;
+
+ // Load credentials if stored
+ var credentialService = new CredentialService();
+ var cred = credentialService.GetCredential(existing.Id);
+ if (cred.HasValue)
+ {
+ UsernameBox.Text = cred.Value.Username;
+ PasswordBox.Password = cred.Value.Password;
+ }
+ }
+ else
+ {
+ WindowsAuthRadio.IsChecked = true;
}
AddedServer = existing;
@@ -65,11 +96,17 @@ public AddServerDialog(ServerManager serverManager, ServerConnection existing) :
private void AuthMode_Changed(object sender, RoutedEventArgs e)
{
- if (SqlCredentialsPanel != null)
+ if (SqlCredentialsPanel != null && EntraMfaPanel != null)
{
+ // Show credentials panel for SQL Server authentication
SqlCredentialsPanel.Visibility = SqlAuthRadio.IsChecked == true
? Visibility.Visible
: Visibility.Collapsed;
+
+ // Show MFA panel for Microsoft Entra MFA
+ EntraMfaPanel.Visibility = EntraMfaAuthRadio.IsChecked == true
+ ? Visibility.Visible
+ : Visibility.Collapsed;
}
}
@@ -122,12 +159,27 @@ private async void TestButton_Click(object sender, RoutedEventArgs e)
{
builder.IntegratedSecurity = true;
}
- else
+ else if (SqlAuthRadio.IsChecked == true)
{
builder.IntegratedSecurity = false;
builder.UserID = UsernameBox.Text.Trim();
builder.Password = PasswordBox.Password;
}
+ else if (EntraMfaAuthRadio.IsChecked == true)
+ {
+ // Microsoft Entra MFA (Azure AD Interactive)
+ builder.IntegratedSecurity = false;
+ builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
+
+ // Optional: Use username if provided
+ var username = EntraMfaUsernameBox.Text.Trim();
+ if (!string.IsNullOrEmpty(username))
+ {
+ builder.UserID = username;
+ }
+
+ StatusText.Text = "Please complete authentication in the popup window...";
+ }
using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();
@@ -137,10 +189,25 @@ private async void TestButton_Click(object sender, RoutedEventArgs e)
var shortVersion = version?.Split('\n')[0] ?? "Connected";
StatusText.Text = $"Success: {shortVersion}";
+
+ // Clear any previous MFA cancellation flag on successful connection
+ if (AddedServer != null && EntraMfaAuthRadio.IsChecked == true)
+ {
+ var status = _serverManager.GetConnectionStatus(AddedServer.Id);
+ status.UserCancelledMfa = false;
+ }
}
catch (Exception ex)
{
StatusText.Text = $"Failed: {ex.Message}";
+
+ // Mark MFA as cancelled if user cancelled the authentication popup
+ if (AddedServer != null && EntraMfaAuthRadio.IsChecked == true && MfaAuthenticationHelper.IsMfaCancelledException(ex))
+ {
+ var status = _serverManager.GetConnectionStatus(AddedServer.Id);
+ status.UserCancelledMfa = true;
+ StatusText.Text = "Authentication cancelled by user. Click Test to try again.";
+ }
}
finally
{
@@ -163,12 +230,24 @@ private void SaveButton_Click(object sender, RoutedEventArgs e)
displayName = serverName;
}
- var useWindowsAuth = WindowsAuthRadio.IsChecked == true;
+ // Determine authentication type
+ string authenticationType;
string? username = null;
string? password = null;
- if (!useWindowsAuth)
+ if (WindowsAuthRadio.IsChecked == true)
+ {
+ authenticationType = AuthenticationTypes.Windows;
+ }
+ else if (EntraMfaAuthRadio.IsChecked == true)
+ {
+ authenticationType = AuthenticationTypes.EntraMFA;
+ // Optionally store username for MFA
+ username = EntraMfaUsernameBox.Text.Trim();
+ }
+ else // SQL Server Authentication
{
+ authenticationType = AuthenticationTypes.SqlServer;
username = UsernameBox.Text.Trim();
password = PasswordBox.Password;
@@ -186,7 +265,7 @@ private void SaveButton_Click(object sender, RoutedEventArgs e)
/* Editing existing server */
AddedServer.ServerName = serverName;
AddedServer.DisplayName = displayName;
- AddedServer.UseWindowsAuth = useWindowsAuth;
+ AddedServer.AuthenticationType = authenticationType;
AddedServer.IsEnabled = EnabledCheckBox.IsChecked == true;
AddedServer.TrustServerCertificate = TrustCertCheckBox.IsChecked == true;
AddedServer.EncryptMode = GetSelectedEncryptMode();
@@ -203,7 +282,7 @@ private void SaveButton_Click(object sender, RoutedEventArgs e)
{
ServerName = serverName,
DisplayName = displayName,
- UseWindowsAuth = useWindowsAuth,
+ AuthenticationType = authenticationType,
IsEnabled = EnabledCheckBox.IsChecked == true,
TrustServerCertificate = TrustCertCheckBox.IsChecked == true,
EncryptMode = GetSelectedEncryptMode(),