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(),