diff --git a/Directory.Packages.props b/Directory.Packages.props index 4baf8cbfec..2d150a29a4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,7 @@ - + @@ -97,6 +97,7 @@ + diff --git a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj new file mode 100644 index 0000000000..0ea12bd7d2 --- /dev/null +++ b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj @@ -0,0 +1,34 @@ + + + + + net481;net10.0-windows + net10.0-windows + + true + WinExe + Microsoft.Data.SqlClient.Samples.AzureSqlConnector + AzureSqlConnector + true + latest + disable + AnyCPU + true + false + + + + + + + + diff --git a/doc/apps/AzureSqlConnector/IdentityQuery.cs b/doc/apps/AzureSqlConnector/IdentityQuery.cs new file mode 100644 index 0000000000..6c6cff84a1 --- /dev/null +++ b/doc/apps/AzureSqlConnector/IdentityQuery.cs @@ -0,0 +1,25 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Shared SQL text used by both (UI-thread variant) and + /// (worker-thread variant). Keeping the literal in one place + /// avoids drift when one variant gains a new column. + /// + internal static class IdentityQuery + { + public const string CommandText = + "SELECT " + + " SUSER_SNAME() AS LoggedInUser, " + + " ORIGINAL_LOGIN() AS OriginalLogin, " + + " USER_NAME() AS DatabaseUser, " + + " SUSER_ID() AS LoginSid, " + + " DB_NAME() AS DatabaseName, " + + " @@SERVERNAME AS ServerName, " + + " HOST_NAME() AS ClientHost, " + + " APP_NAME() AS AppName, " + + " SESSION_USER AS SessionUser, " + + " CURRENT_USER AS CurrentUser, " + + " @@SPID AS SessionId, " + + " @@VERSION AS ServerVersion;"; + } +} diff --git a/doc/apps/AzureSqlConnector/MainForm.Designer.cs b/doc/apps/AzureSqlConnector/MainForm.Designer.cs new file mode 100644 index 0000000000..4dd6c5a047 --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainForm.Designer.cs @@ -0,0 +1,394 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + partial class MainForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.lblServer = new System.Windows.Forms.Label(); + this.txtServer = new System.Windows.Forms.TextBox(); + this.lblDatabase = new System.Windows.Forms.Label(); + this.txtDatabase = new System.Windows.Forms.TextBox(); + this.lblAuthentication = new System.Windows.Forms.Label(); + this.cmbAuthentication = new System.Windows.Forms.ComboBox(); + this.lblUserId = new System.Windows.Forms.Label(); + this.txtUserId = new System.Windows.Forms.TextBox(); + this.lblPassword = new System.Windows.Forms.Label(); + this.txtPassword = new System.Windows.Forms.TextBox(); + this.lblEncrypt = new System.Windows.Forms.Label(); + this.cmbEncrypt = new System.Windows.Forms.ComboBox(); + this.chkTrustServerCertificate = new System.Windows.Forms.CheckBox(); + this.lblTimeout = new System.Windows.Forms.Label(); + this.numTimeout = new System.Windows.Forms.NumericUpDown(); + this.lblOpenMode = new System.Windows.Forms.Label(); + this.cmbOpenMode = new System.Windows.Forms.ComboBox(); + this.lblConnectionString = new System.Windows.Forms.Label(); + this.txtConnectionString = new System.Windows.Forms.TextBox(); + this.btnBuild = new System.Windows.Forms.Button(); + this.btnTest = new System.Windows.Forms.Button(); + this.btnCopy = new System.Windows.Forms.Button(); + this.btnClear = new System.Windows.Forms.Button(); + this.btnWhoAmI = new System.Windows.Forms.Button(); + this.lblStatus = new System.Windows.Forms.Label(); + this.txtStatus = new System.Windows.Forms.TextBox(); + this.statusStrip = new System.Windows.Forms.StatusStrip(); + this.statusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).BeginInit(); + this.statusStrip.SuspendLayout(); + this.SuspendLayout(); + // + // lblServer + // + this.lblServer.AutoSize = true; + this.lblServer.Location = new System.Drawing.Point(16, 18); + this.lblServer.Name = "lblServer"; + this.lblServer.Size = new System.Drawing.Size(75, 13); + this.lblServer.TabIndex = 0; + this.lblServer.Text = "&Server name:"; + // + // txtServer + // + this.txtServer.Location = new System.Drawing.Point(150, 15); + this.txtServer.Name = "txtServer"; + this.txtServer.Size = new System.Drawing.Size(400, 20); + this.txtServer.TabIndex = 1; + // + // lblDatabase + // + this.lblDatabase.AutoSize = true; + this.lblDatabase.Location = new System.Drawing.Point(16, 48); + this.lblDatabase.Name = "lblDatabase"; + this.lblDatabase.Size = new System.Drawing.Size(86, 13); + this.lblDatabase.TabIndex = 2; + this.lblDatabase.Text = "&Database name:"; + // + // txtDatabase + // + this.txtDatabase.Location = new System.Drawing.Point(150, 45); + this.txtDatabase.Name = "txtDatabase"; + this.txtDatabase.Size = new System.Drawing.Size(400, 20); + this.txtDatabase.TabIndex = 3; + // + // lblAuthentication + // + this.lblAuthentication.AutoSize = true; + this.lblAuthentication.Location = new System.Drawing.Point(16, 78); + this.lblAuthentication.Name = "lblAuthentication"; + this.lblAuthentication.Size = new System.Drawing.Size(80, 13); + this.lblAuthentication.TabIndex = 4; + this.lblAuthentication.Text = "&Authentication:"; + // + // cmbAuthentication + // + this.cmbAuthentication.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbAuthentication.FormattingEnabled = true; + this.cmbAuthentication.Location = new System.Drawing.Point(150, 75); + this.cmbAuthentication.Name = "cmbAuthentication"; + this.cmbAuthentication.Size = new System.Drawing.Size(400, 21); + this.cmbAuthentication.TabIndex = 5; + this.cmbAuthentication.SelectedIndexChanged += new System.EventHandler(this.cmbAuthentication_SelectedIndexChanged); + // + // lblUserId + // + this.lblUserId.AutoSize = true; + this.lblUserId.Location = new System.Drawing.Point(16, 108); + this.lblUserId.Name = "lblUserId"; + this.lblUserId.Size = new System.Drawing.Size(45, 13); + this.lblUserId.TabIndex = 6; + this.lblUserId.Text = "&User ID:"; + // + // txtUserId + // + this.txtUserId.Location = new System.Drawing.Point(150, 105); + this.txtUserId.Name = "txtUserId"; + this.txtUserId.Size = new System.Drawing.Size(400, 20); + this.txtUserId.TabIndex = 7; + // + // lblPassword + // + this.lblPassword.AutoSize = true; + this.lblPassword.Location = new System.Drawing.Point(16, 138); + this.lblPassword.Name = "lblPassword"; + this.lblPassword.Size = new System.Drawing.Size(56, 13); + this.lblPassword.TabIndex = 8; + this.lblPassword.Text = "&Password:"; + // + // txtPassword + // + this.txtPassword.Location = new System.Drawing.Point(150, 135); + this.txtPassword.Name = "txtPassword"; + this.txtPassword.Size = new System.Drawing.Size(400, 20); + this.txtPassword.TabIndex = 9; + this.txtPassword.UseSystemPasswordChar = true; + // + // lblEncrypt + // + this.lblEncrypt.AutoSize = true; + this.lblEncrypt.Location = new System.Drawing.Point(16, 168); + this.lblEncrypt.Name = "lblEncrypt"; + this.lblEncrypt.Size = new System.Drawing.Size(46, 13); + this.lblEncrypt.TabIndex = 10; + this.lblEncrypt.Text = "&Encrypt:"; + // + // cmbEncrypt + // + this.cmbEncrypt.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbEncrypt.FormattingEnabled = true; + this.cmbEncrypt.Location = new System.Drawing.Point(150, 165); + this.cmbEncrypt.Name = "cmbEncrypt"; + this.cmbEncrypt.Size = new System.Drawing.Size(200, 21); + this.cmbEncrypt.TabIndex = 11; + // + // chkTrustServerCertificate + // + this.chkTrustServerCertificate.AutoSize = true; + this.chkTrustServerCertificate.Location = new System.Drawing.Point(370, 167); + this.chkTrustServerCertificate.Name = "chkTrustServerCertificate"; + this.chkTrustServerCertificate.Size = new System.Drawing.Size(149, 17); + this.chkTrustServerCertificate.TabIndex = 12; + this.chkTrustServerCertificate.Text = "&Trust server certificate"; + this.chkTrustServerCertificate.UseVisualStyleBackColor = true; + // + // lblTimeout + // + this.lblTimeout.AutoSize = true; + this.lblTimeout.Location = new System.Drawing.Point(16, 198); + this.lblTimeout.Name = "lblTimeout"; + this.lblTimeout.Size = new System.Drawing.Size(101, 13); + this.lblTimeout.TabIndex = 13; + this.lblTimeout.Text = "Connect timeout (s):"; + // + // numTimeout + // + this.numTimeout.Location = new System.Drawing.Point(150, 196); + this.numTimeout.Maximum = new decimal(new int[] { 600, 0, 0, 0 }); + this.numTimeout.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + this.numTimeout.Name = "numTimeout"; + this.numTimeout.Size = new System.Drawing.Size(80, 20); + this.numTimeout.TabIndex = 14; + this.numTimeout.Value = new decimal(new int[] { 30, 0, 0, 0 }); + // + // lblOpenMode + // + this.lblOpenMode.AutoSize = true; + this.lblOpenMode.Location = new System.Drawing.Point(260, 198); + this.lblOpenMode.Name = "lblOpenMode"; + this.lblOpenMode.Size = new System.Drawing.Size(67, 13); + this.lblOpenMode.TabIndex = 25; + this.lblOpenMode.Text = "&Open mode:"; + // + // cmbOpenMode + // + this.cmbOpenMode.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbOpenMode.FormattingEnabled = true; + this.cmbOpenMode.Location = new System.Drawing.Point(350, 195); + this.cmbOpenMode.Name = "cmbOpenMode"; + this.cmbOpenMode.Size = new System.Drawing.Size(200, 21); + this.cmbOpenMode.TabIndex = 26; + // + // lblConnectionString + // + this.lblConnectionString.AutoSize = true; + this.lblConnectionString.Location = new System.Drawing.Point(16, 230); + this.lblConnectionString.Name = "lblConnectionString"; + this.lblConnectionString.Size = new System.Drawing.Size(94, 13); + this.lblConnectionString.TabIndex = 15; + this.lblConnectionString.Text = "Connection string:"; + // + // txtConnectionString + // + this.txtConnectionString.Location = new System.Drawing.Point(16, 246); + this.txtConnectionString.Multiline = true; + this.txtConnectionString.Name = "txtConnectionString"; + this.txtConnectionString.ReadOnly = true; + this.txtConnectionString.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.txtConnectionString.Size = new System.Drawing.Size(534, 60); + this.txtConnectionString.TabIndex = 16; + this.txtConnectionString.BackColor = System.Drawing.SystemColors.Info; + // + // btnBuild + // + this.btnBuild.Location = new System.Drawing.Point(16, 316); + this.btnBuild.Name = "btnBuild"; + this.btnBuild.Size = new System.Drawing.Size(140, 26); + this.btnBuild.TabIndex = 17; + this.btnBuild.Text = "&Build Connection String"; + this.btnBuild.UseVisualStyleBackColor = true; + this.btnBuild.Click += new System.EventHandler(this.btnBuild_Click); + // + // btnTest + // + this.btnTest.Location = new System.Drawing.Point(166, 316); + this.btnTest.Name = "btnTest"; + this.btnTest.Size = new System.Drawing.Size(120, 26); + this.btnTest.TabIndex = 18; + this.btnTest.Text = "Te&st Connection"; + this.btnTest.UseVisualStyleBackColor = true; + this.btnTest.Click += new System.EventHandler(this.btnTest_Click); + // + // btnCopy + // + this.btnCopy.Location = new System.Drawing.Point(296, 316); + this.btnCopy.Name = "btnCopy"; + this.btnCopy.Size = new System.Drawing.Size(120, 26); + this.btnCopy.TabIndex = 19; + this.btnCopy.Text = "Cop&y to Clipboard"; + this.btnCopy.UseVisualStyleBackColor = true; + this.btnCopy.Click += new System.EventHandler(this.btnCopy_Click); + // + // btnClear + // + this.btnClear.Location = new System.Drawing.Point(426, 316); + this.btnClear.Name = "btnClear"; + this.btnClear.Size = new System.Drawing.Size(124, 26); + this.btnClear.TabIndex = 20; + this.btnClear.Text = "Cl&ear All"; + this.btnClear.UseVisualStyleBackColor = true; + this.btnClear.Click += new System.EventHandler(this.btnClear_Click); + // + // btnWhoAmI + // + this.btnWhoAmI.Location = new System.Drawing.Point(16, 348); + this.btnWhoAmI.Name = "btnWhoAmI"; + this.btnWhoAmI.Size = new System.Drawing.Size(534, 26); + this.btnWhoAmI.TabIndex = 21; + this.btnWhoAmI.Text = "&Who Am I? (run identity query on the database)"; + this.btnWhoAmI.UseVisualStyleBackColor = true; + this.btnWhoAmI.Click += new System.EventHandler(this.btnWhoAmI_Click); + // + // lblStatus + // + this.lblStatus.AutoSize = true; + this.lblStatus.Location = new System.Drawing.Point(16, 386); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Size = new System.Drawing.Size(40, 13); + this.lblStatus.TabIndex = 22; + this.lblStatus.Text = "Result:"; + // + // txtStatus + // + this.txtStatus.Location = new System.Drawing.Point(16, 402); + this.txtStatus.Multiline = true; + this.txtStatus.Name = "txtStatus"; + this.txtStatus.ReadOnly = true; + this.txtStatus.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.txtStatus.Size = new System.Drawing.Size(534, 160); + this.txtStatus.TabIndex = 23; + this.txtStatus.WordWrap = false; + this.txtStatus.Font = new System.Drawing.Font("Consolas", 9F); + // + // statusStrip + // + this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.statusLabel}); + this.statusStrip.Location = new System.Drawing.Point(0, 578); + this.statusStrip.Name = "statusStrip"; + this.statusStrip.Size = new System.Drawing.Size(566, 22); + this.statusStrip.TabIndex = 24; + // + // statusLabel + // + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(39, 17); + this.statusLabel.Text = "Ready"; + // + // MainForm + // + this.AcceptButton = this.btnTest; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(566, 600); + this.Controls.Add(this.statusStrip); + this.Controls.Add(this.txtStatus); + this.Controls.Add(this.lblStatus); + this.Controls.Add(this.btnWhoAmI); + this.Controls.Add(this.btnClear); + this.Controls.Add(this.btnCopy); + this.Controls.Add(this.btnTest); + this.Controls.Add(this.btnBuild); + this.Controls.Add(this.txtConnectionString); + this.Controls.Add(this.lblConnectionString); + this.Controls.Add(this.cmbOpenMode); + this.Controls.Add(this.lblOpenMode); + this.Controls.Add(this.numTimeout); + this.Controls.Add(this.lblTimeout); + this.Controls.Add(this.chkTrustServerCertificate); + this.Controls.Add(this.cmbEncrypt); + this.Controls.Add(this.lblEncrypt); + this.Controls.Add(this.txtPassword); + this.Controls.Add(this.lblPassword); + this.Controls.Add(this.txtUserId); + this.Controls.Add(this.lblUserId); + this.Controls.Add(this.cmbAuthentication); + this.Controls.Add(this.lblAuthentication); + this.Controls.Add(this.txtDatabase); + this.Controls.Add(this.lblDatabase); + this.Controls.Add(this.txtServer); + this.Controls.Add(this.lblServer); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.MaximizeBox = false; + this.Name = "MainForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Azure SQL Connector"; + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).EndInit(); + this.statusStrip.ResumeLayout(false); + this.statusStrip.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label lblServer; + private System.Windows.Forms.TextBox txtServer; + private System.Windows.Forms.Label lblDatabase; + private System.Windows.Forms.TextBox txtDatabase; + private System.Windows.Forms.Label lblAuthentication; + private System.Windows.Forms.ComboBox cmbAuthentication; + private System.Windows.Forms.Label lblUserId; + private System.Windows.Forms.TextBox txtUserId; + private System.Windows.Forms.Label lblPassword; + private System.Windows.Forms.TextBox txtPassword; + private System.Windows.Forms.Label lblEncrypt; + private System.Windows.Forms.ComboBox cmbEncrypt; + private System.Windows.Forms.CheckBox chkTrustServerCertificate; + private System.Windows.Forms.Label lblTimeout; + private System.Windows.Forms.NumericUpDown numTimeout; + private System.Windows.Forms.Label lblOpenMode; + private System.Windows.Forms.ComboBox cmbOpenMode; + private System.Windows.Forms.Label lblConnectionString; + private System.Windows.Forms.TextBox txtConnectionString; + private System.Windows.Forms.Button btnBuild; + private System.Windows.Forms.Button btnTest; + private System.Windows.Forms.Button btnCopy; + private System.Windows.Forms.Button btnClear; + private System.Windows.Forms.Button btnWhoAmI; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.TextBox txtStatus; + private System.Windows.Forms.StatusStrip statusStrip; + private System.Windows.Forms.ToolStripStatusLabel statusLabel; + } +} diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs new file mode 100644 index 0000000000..9cc6d0648c --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -0,0 +1,590 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.Data.SqlClient; +using Microsoft.Identity.Client; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// "UI-thread" variant of the connector form. Opens the SQL connection via + /// on the UI thread; the WinForms + /// keeps the message pump alive while + /// the async I/O completes, so the form remains responsive and MSAL.NET's embedded sign-in + /// browser (for ActiveDirectoryInteractive) parents itself correctly. + /// + public partial class MainForm : Form + { + // ────────────────────────────────────────────────────────────────── + #region Construction + + public MainForm() + { + InitializeComponent(); + this.Text = "Azure SQL Connector — UI thread"; + PopulateAuthenticationMethods(); + PopulateEncryptOptions(); + PopulateOpenModes(); + UpdateCredentialFieldsAvailability(); + + // Force the underlying Win32 window to be created NOW (on the UI thread) so we can + // safely hand its HWND to MSAL later. Even in async mode, MSAL.NET may invoke the + // parent-window callback from a worker thread (e.g. when the driver blocks on a + // synchronous Open()), and touching Form.Handle from a non-UI thread throws + // InvalidOperationException ("Cross-thread operation not valid"). + _ownerHwnd = this.Handle; + + RegisterActiveDirectoryProvider(); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Initialization + + private void PopulateAuthenticationMethods() + { + foreach (SqlAuthenticationMethod method in Enum.GetValues(typeof(SqlAuthenticationMethod))) + { + cmbAuthentication.Items.Add(method); + } + + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + } + + private void PopulateEncryptOptions() + { + cmbEncrypt.Items.Add(EncryptDisplay.Mandatory); + cmbEncrypt.Items.Add(EncryptDisplay.Optional); + cmbEncrypt.Items.Add(EncryptDisplay.Strict); + cmbEncrypt.SelectedIndex = 0; + } + + private void PopulateOpenModes() + { + cmbOpenMode.Items.Add(OpenModeDisplay.Async); + cmbOpenMode.Items.Add(OpenModeDisplay.Sync); + cmbOpenMode.SelectedIndex = 0; + } + + /// + /// Registers a single for every + /// Entra ID authentication method and gives it the form's captured HWND as the parent + /// window owner. Both callbacks intentionally use the HWND captured in the constructor + /// () rather than this.Handle, because MSAL.NET can invoke + /// them from a worker thread (e.g. when the driver blocks on a synchronous Open() + /// or when its internal continuations resume off-UI). + /// + private void RegisterActiveDirectoryProvider() + { + ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider(); + IntPtr ownerHwnd = _ownerHwnd; + +#if NETFRAMEWORK + // .NET Framework: parent the embedded WebView via the legacy IWin32Window API. + provider.SetIWin32WindowFunc(() => new Win32WindowHandle(ownerHwnd)); +#endif + + // Modern API: works on both .NET Framework and .NET 8+, and is the one MSAL's WAM + // broker consults on Windows. + provider.SetParentActivityOrWindowFunc(() => ownerHwnd); + + // Without this, MSAL's default device-code callback writes the prompt to + // Console.WriteLine, which is invisible in a WinForms host — the connection + // appears to hang while MSAL polls for a code the user never sees. + provider.SetDeviceCodeFlowCallback(DeviceCodeFlowCallback); + + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, provider); + #pragma warning disable CS0618 // Type or member is obsolete + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, provider); + #pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Device Code Flow callback. MSAL invokes this on a worker thread before it begins + /// polling the token endpoint. We surface the user code three ways so the user always + /// sees it: (1) appended to the log textbox via BeginInvoke (works whenever the UI + /// thread is pumping — async OpenAsync), (2) the verification URL launched in + /// the default browser, and (3) a modal owned by the MSAL worker thread (works even + /// when the UI thread is blocked by a synchronous Open()). MSAL polling waits + /// for the returned Task to complete, so dismissing the dialog also resumes polling. + /// + private Task DeviceCodeFlowCallback(DeviceCodeResult result) + { + string message = result.Message; + string url = result.VerificationUrl; + string code = result.UserCode; + + if (IsHandleCreated) + { + try + { + BeginInvoke((Action)(() => + { + AppendStatus(string.Empty); + AppendStatus("=== Device Code Flow ==="); + AppendStatus(message); + })); + } + catch (InvalidOperationException) + { + // Form is closing or handle was destroyed; fall through to the modal. + } + } + + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch + { + // Best-effort; the modal below still shows the URL and code. + } + + MessageBox.Show( + "Sign in to complete Device Code Flow:" + Environment.NewLine + Environment.NewLine + + " URL : " + url + Environment.NewLine + + " Code: " + code + Environment.NewLine + Environment.NewLine + + "A browser window has been opened. Enter the code above, complete sign-in," + + Environment.NewLine + "then click OK to resume the connection.", + "Device Code Flow", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + + return Task.CompletedTask; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Event Handlers + + private void cmbAuthentication_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateCredentialFieldsAvailability(); + } + + private void btnBuild_Click(object sender, EventArgs e) + { + try + { + SqlConnectionStringBuilder builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + SetStatus("Connection string built successfully.", isError: false); + AppendStatus("Connection string built:\r\n" + MaskPassword(builder)); + } + catch (Exception ex) + { + txtConnectionString.Text = string.Empty; + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private async void btnTest_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + bool useAsync = IsAsyncOpenSelected(); + SetBusy(true, useAsync ? "Testing connection (OpenAsync)..." : "Testing connection (Open)..."); + AppendStatus(string.Empty); + AppendStatus("Testing connectivity to " + builder.DataSource + " (" + + (useAsync ? "OpenAsync" : "sync Open") + ") ..."); + + try + { + string serverVersion; + using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) + { + await OpenConnectionAsync(connection, useAsync).ConfigureAwait(true); + serverVersion = connection.ServerVersion; + } + + SetStatus("Connected successfully.", isError: false); + AppendStatus("Connected successfully! Server version: " + serverVersion); + } + catch (SqlException ex) + { + SetStatus("Connection failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message + "\r\n" + ex.StackTrace); + } + catch (Exception ex) + { + SetStatus("Connection failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message + "\r\n" + ex.StackTrace); + } + finally + { + SetBusy(false, null); + } + } + + private async void btnWhoAmI_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + bool useAsync = IsAsyncOpenSelected(); + SetBusy(true, useAsync + ? "Querying logged-in identity (OpenAsync)..." + : "Querying logged-in identity (Open)..."); + AppendStatus(string.Empty); + AppendStatus("Running identity query against " + builder.DataSource + " (" + + (useAsync ? "OpenAsync" : "sync Open") + ") ..."); + + try + { + // Same UI-thread reasoning as btnTest_Click — keep the message pump alive for any + // ActiveDirectoryInteractive sign-in that may be required. + using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) + { + await OpenConnectionAsync(connection, useAsync).ConfigureAwait(true); + + using (SqlCommand command = connection.CreateCommand()) + { + command.CommandText = IdentityQuery.CommandText; + + using (SqlDataReader reader = await command.ExecuteReaderAsync().ConfigureAwait(true)) + { + if (await reader.ReadAsync().ConfigureAwait(true)) + { + AppendStatus("Identity:"); + for (int i = 0; i < reader.FieldCount; i++) + { + string name = reader.GetName(i); + object value = reader.IsDBNull(i) ? "(null)" : reader.GetValue(i); + AppendStatus(" " + name.PadRight(16) + ": " + value); + } + SetStatus("Identity query succeeded.", isError: false); + } + else + { + SetStatus("Identity query returned no rows.", isError: true); + AppendStatus("(no rows returned)"); + } + } + } + } + } + catch (SqlException ex) + { + SetStatus("Identity query failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Identity query failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private void btnCopy_Click(object sender, EventArgs e) + { + if (string.IsNullOrEmpty(txtConnectionString.Text)) + { + SetStatus("Nothing to copy. Build the connection string first.", isError: true); + return; + } + + try + { + Clipboard.SetText(BuildConnectionString().ConnectionString); + SetStatus("Connection string copied to clipboard.", isError: false); + } + catch (Exception ex) + { + SetStatus("Failed to copy to clipboard.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private void btnClear_Click(object sender, EventArgs e) + { + txtServer.Clear(); + txtDatabase.Clear(); + txtUserId.Clear(); + txtPassword.Clear(); + txtConnectionString.Clear(); + txtStatus.Clear(); + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + cmbEncrypt.SelectedIndex = 0; + cmbOpenMode.SelectedIndex = 0; + chkTrustServerCertificate.Checked = false; + numTimeout.Value = 30; + SetStatus("Ready", isError: false); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Connection String Construction + + private SqlConnectionStringBuilder BuildConnectionString() + { + string server = (txtServer.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(server)) + { + throw new InvalidOperationException("Server name is required."); + } + + SqlAuthenticationMethod authMethod = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + { + DataSource = server, + ConnectTimeout = (int)numTimeout.Value, + }; + + string database = (txtDatabase.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(database)) + { + builder.InitialCatalog = database; + } + + if (authMethod != SqlAuthenticationMethod.NotSpecified) + { + builder.Authentication = authMethod; + } + + if (RequiresUserAndPassword(authMethod)) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException( + "User ID is required for " + authMethod + " authentication."); + } + + builder.UserID = userId; + builder.Password = txtPassword.Text ?? string.Empty; + } + else if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + || authMethod == SqlAuthenticationMethod.ActiveDirectoryManagedIdentity + || authMethod == SqlAuthenticationMethod.ActiveDirectoryMSI + || authMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDefault + || authMethod == SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(userId)) + { + builder.UserID = userId; + } + + if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + && !string.IsNullOrEmpty(txtPassword.Text)) + { + builder.Password = txtPassword.Text; + } + } + + string encryptValue = cmbEncrypt.SelectedItem as string ?? EncryptDisplay.Mandatory; + switch (encryptValue) + { + case EncryptDisplay.Mandatory: + builder.Encrypt = SqlConnectionEncryptOption.Mandatory; + break; + case EncryptDisplay.Optional: + builder.Encrypt = SqlConnectionEncryptOption.Optional; + break; + case EncryptDisplay.Strict: + builder.Encrypt = SqlConnectionEncryptOption.Strict; + break; + } + + builder.TrustServerCertificate = chkTrustServerCertificate.Checked; + + return builder; + } + + private static bool RequiresUserAndPassword(SqlAuthenticationMethod method) + { + switch (method) + { + case SqlAuthenticationMethod.SqlPassword: +#pragma warning disable CS0618 // Type or member is obsolete + case SqlAuthenticationMethod.ActiveDirectoryPassword: +#pragma warning restore CS0618 + return true; + default: + return false; + } + } + + private static string MaskPassword(SqlConnectionStringBuilder builder) + { + if (string.IsNullOrEmpty(builder.Password)) + { + return builder.ConnectionString; + } + + SqlConnectionStringBuilder copy = new SqlConnectionStringBuilder(builder.ConnectionString) + { + Password = "********", + }; + return copy.ConnectionString; + } + + /// + /// Returns when the user picked Async (OpenAsync) in the + /// open-mode selector. Defaults to async if the selector has not been initialized yet. + /// + private bool IsAsyncOpenSelected() + { + return cmbOpenMode.SelectedItem as string != OpenModeDisplay.Sync; + } + + /// + /// Opens on the calling thread using either + /// or the synchronous + /// based on . The method itself is always async-returning so + /// callers can await uniformly; for the sync case it runs Open() inline on + /// the UI thread (which is supported with WAM broker because the broker dialog is hosted + /// by a separate process and does not need this thread's message pump). + /// + private static Task OpenConnectionAsync(SqlConnection connection, bool useAsync) + { + if (useAsync) + { + return connection.OpenAsync(); + } + + connection.Open(); + return Task.CompletedTask; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Helpers + + private void UpdateCredentialFieldsAvailability() + { + if (cmbAuthentication.SelectedItem == null) + { + return; + } + + SqlAuthenticationMethod method = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + bool userEnabled = method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; + bool passwordEnabled = RequiresUserAndPassword(method) + || method == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; + + txtUserId.Enabled = userEnabled; + txtPassword.Enabled = passwordEnabled; + + if (!passwordEnabled) + { + txtPassword.Clear(); + } + } + + private void SetStatus(string text, bool isError) + { + statusLabel.Text = text; + statusLabel.ForeColor = isError ? System.Drawing.Color.Firebrick : System.Drawing.Color.Black; + } + + private void AppendStatus(string line) + { + if (txtStatus.TextLength > 0) + { + txtStatus.AppendText(Environment.NewLine); + } + txtStatus.AppendText(line ?? string.Empty); + } + + private void SetBusy(bool busy, string statusText) + { + btnBuild.Enabled = !busy; + btnTest.Enabled = !busy; + btnCopy.Enabled = !busy; + btnClear.Enabled = !busy; + btnWhoAmI.Enabled = !busy; + cmbOpenMode.Enabled = !busy; + Cursor = busy ? Cursors.WaitCursor : Cursors.Default; + + if (statusText != null) + { + SetStatus(statusText, isError: false); + } + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Nested Types + + private static class EncryptDisplay + { + public const string Mandatory = "Mandatory"; + public const string Optional = "Optional"; + public const string Strict = "Strict"; + } + + private static class OpenModeDisplay + { + public const string Async = "Async (OpenAsync)"; + public const string Sync = "Sync (Open)"; + } + +#if NETFRAMEWORK + // Tiny IWin32Window wrapper around a raw HWND captured on the UI thread so MSAL.NET's + // legacy IWin32WindowFunc callback can safely return a window owner from a worker thread + // without ever touching Control.Handle off-UI. + private sealed class Win32WindowHandle : IWin32Window + { + private readonly IntPtr _hwnd; + public Win32WindowHandle(IntPtr hwnd) => _hwnd = hwnd; + public IntPtr Handle => _hwnd; + } +#endif + + #endregion + + // ─────────────────────────────────────────────────────────────── + #region Private Fields + + // The form's Win32 window handle, captured on the UI thread in the constructor. + // Read from worker threads by the Entra ID provider callbacks to parent MSAL's sign-in + // / WAM broker UI without illegally touching Control.Handle. + private readonly IntPtr _ownerHwnd; + + #endregion + } +} diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs b/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs new file mode 100644 index 0000000000..c872ee38ab --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs @@ -0,0 +1,383 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + partial class MainFormWorker + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.lblServer = new System.Windows.Forms.Label(); + this.txtServer = new System.Windows.Forms.TextBox(); + this.lblDatabase = new System.Windows.Forms.Label(); + this.txtDatabase = new System.Windows.Forms.TextBox(); + this.lblAuthentication = new System.Windows.Forms.Label(); + this.cmbAuthentication = new System.Windows.Forms.ComboBox(); + this.lblUserId = new System.Windows.Forms.Label(); + this.txtUserId = new System.Windows.Forms.TextBox(); + this.lblPassword = new System.Windows.Forms.Label(); + this.txtPassword = new System.Windows.Forms.TextBox(); + this.lblEncrypt = new System.Windows.Forms.Label(); + this.cmbEncrypt = new System.Windows.Forms.ComboBox(); + this.chkTrustServerCertificate = new System.Windows.Forms.CheckBox(); + this.lblTimeout = new System.Windows.Forms.Label(); + this.numTimeout = new System.Windows.Forms.NumericUpDown(); + this.chkClearTokenCache = new System.Windows.Forms.CheckBox(); + this.lblConnectionString = new System.Windows.Forms.Label(); + this.txtConnectionString = new System.Windows.Forms.TextBox(); + this.btnBuild = new System.Windows.Forms.Button(); + this.btnTest = new System.Windows.Forms.Button(); + this.btnCopy = new System.Windows.Forms.Button(); + this.btnClear = new System.Windows.Forms.Button(); + this.btnWhoAmI = new System.Windows.Forms.Button(); + this.lblStatus = new System.Windows.Forms.Label(); + this.txtStatus = new System.Windows.Forms.TextBox(); + this.statusStrip = new System.Windows.Forms.StatusStrip(); + this.statusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).BeginInit(); + this.statusStrip.SuspendLayout(); + this.SuspendLayout(); + // + // lblServer + // + this.lblServer.AutoSize = true; + this.lblServer.Location = new System.Drawing.Point(16, 18); + this.lblServer.Name = "lblServer"; + this.lblServer.Size = new System.Drawing.Size(75, 13); + this.lblServer.TabIndex = 0; + this.lblServer.Text = "&Server name:"; + // + // txtServer + // + this.txtServer.Location = new System.Drawing.Point(150, 15); + this.txtServer.Name = "txtServer"; + this.txtServer.Size = new System.Drawing.Size(400, 20); + this.txtServer.TabIndex = 1; + // + // lblDatabase + // + this.lblDatabase.AutoSize = true; + this.lblDatabase.Location = new System.Drawing.Point(16, 48); + this.lblDatabase.Name = "lblDatabase"; + this.lblDatabase.Size = new System.Drawing.Size(86, 13); + this.lblDatabase.TabIndex = 2; + this.lblDatabase.Text = "&Database name:"; + // + // txtDatabase + // + this.txtDatabase.Location = new System.Drawing.Point(150, 45); + this.txtDatabase.Name = "txtDatabase"; + this.txtDatabase.Size = new System.Drawing.Size(400, 20); + this.txtDatabase.TabIndex = 3; + // + // lblAuthentication + // + this.lblAuthentication.AutoSize = true; + this.lblAuthentication.Location = new System.Drawing.Point(16, 78); + this.lblAuthentication.Name = "lblAuthentication"; + this.lblAuthentication.Size = new System.Drawing.Size(80, 13); + this.lblAuthentication.TabIndex = 4; + this.lblAuthentication.Text = "&Authentication:"; + // + // cmbAuthentication + // + this.cmbAuthentication.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbAuthentication.FormattingEnabled = true; + this.cmbAuthentication.Location = new System.Drawing.Point(150, 75); + this.cmbAuthentication.Name = "cmbAuthentication"; + this.cmbAuthentication.Size = new System.Drawing.Size(400, 21); + this.cmbAuthentication.TabIndex = 5; + this.cmbAuthentication.SelectedIndexChanged += new System.EventHandler(this.cmbAuthentication_SelectedIndexChanged); + // + // lblUserId + // + this.lblUserId.AutoSize = true; + this.lblUserId.Location = new System.Drawing.Point(16, 108); + this.lblUserId.Name = "lblUserId"; + this.lblUserId.Size = new System.Drawing.Size(45, 13); + this.lblUserId.TabIndex = 6; + this.lblUserId.Text = "&User ID:"; + // + // txtUserId + // + this.txtUserId.Location = new System.Drawing.Point(150, 105); + this.txtUserId.Name = "txtUserId"; + this.txtUserId.Size = new System.Drawing.Size(400, 20); + this.txtUserId.TabIndex = 7; + // + // lblPassword + // + this.lblPassword.AutoSize = true; + this.lblPassword.Location = new System.Drawing.Point(16, 138); + this.lblPassword.Name = "lblPassword"; + this.lblPassword.Size = new System.Drawing.Size(56, 13); + this.lblPassword.TabIndex = 8; + this.lblPassword.Text = "&Password:"; + // + // txtPassword + // + this.txtPassword.Location = new System.Drawing.Point(150, 135); + this.txtPassword.Name = "txtPassword"; + this.txtPassword.Size = new System.Drawing.Size(400, 20); + this.txtPassword.TabIndex = 9; + this.txtPassword.UseSystemPasswordChar = true; + // + // lblEncrypt + // + this.lblEncrypt.AutoSize = true; + this.lblEncrypt.Location = new System.Drawing.Point(16, 168); + this.lblEncrypt.Name = "lblEncrypt"; + this.lblEncrypt.Size = new System.Drawing.Size(46, 13); + this.lblEncrypt.TabIndex = 10; + this.lblEncrypt.Text = "&Encrypt:"; + // + // cmbEncrypt + // + this.cmbEncrypt.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbEncrypt.FormattingEnabled = true; + this.cmbEncrypt.Location = new System.Drawing.Point(150, 165); + this.cmbEncrypt.Name = "cmbEncrypt"; + this.cmbEncrypt.Size = new System.Drawing.Size(200, 21); + this.cmbEncrypt.TabIndex = 11; + // + // chkTrustServerCertificate + // + this.chkTrustServerCertificate.AutoSize = true; + this.chkTrustServerCertificate.Location = new System.Drawing.Point(370, 167); + this.chkTrustServerCertificate.Name = "chkTrustServerCertificate"; + this.chkTrustServerCertificate.Size = new System.Drawing.Size(149, 17); + this.chkTrustServerCertificate.TabIndex = 12; + this.chkTrustServerCertificate.Text = "&Trust server certificate"; + this.chkTrustServerCertificate.UseVisualStyleBackColor = true; + // + // lblTimeout + // + this.lblTimeout.AutoSize = true; + this.lblTimeout.Location = new System.Drawing.Point(16, 198); + this.lblTimeout.Name = "lblTimeout"; + this.lblTimeout.Size = new System.Drawing.Size(101, 13); + this.lblTimeout.TabIndex = 13; + this.lblTimeout.Text = "Connect timeout (s):"; + // + // numTimeout + // + this.numTimeout.Location = new System.Drawing.Point(150, 196); + this.numTimeout.Maximum = new decimal(new int[] { 600, 0, 0, 0 }); + this.numTimeout.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + this.numTimeout.Name = "numTimeout"; + this.numTimeout.Size = new System.Drawing.Size(80, 20); + this.numTimeout.TabIndex = 14; + this.numTimeout.Value = new decimal(new int[] { 30, 0, 0, 0 }); + // + // chkClearTokenCache + // + this.chkClearTokenCache.AutoSize = true; + this.chkClearTokenCache.Location = new System.Drawing.Point(260, 198); + this.chkClearTokenCache.Name = "chkClearTokenCache"; + this.chkClearTokenCache.Size = new System.Drawing.Size(290, 17); + this.chkClearTokenCache.TabIndex = 15; + this.chkClearTokenCache.Text = "Clear MSAL token &cache before connect (force prompt)"; + this.chkClearTokenCache.UseVisualStyleBackColor = true; + // + // lblConnectionString + // + this.lblConnectionString.AutoSize = true; + this.lblConnectionString.Location = new System.Drawing.Point(16, 230); + this.lblConnectionString.Name = "lblConnectionString"; + this.lblConnectionString.Size = new System.Drawing.Size(94, 13); + this.lblConnectionString.TabIndex = 15; + this.lblConnectionString.Text = "Connection string:"; + // + // txtConnectionString + // + this.txtConnectionString.Location = new System.Drawing.Point(16, 246); + this.txtConnectionString.Multiline = true; + this.txtConnectionString.Name = "txtConnectionString"; + this.txtConnectionString.ReadOnly = true; + this.txtConnectionString.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.txtConnectionString.Size = new System.Drawing.Size(534, 60); + this.txtConnectionString.TabIndex = 16; + this.txtConnectionString.BackColor = System.Drawing.SystemColors.Info; + // + // btnBuild + // + this.btnBuild.Location = new System.Drawing.Point(16, 316); + this.btnBuild.Name = "btnBuild"; + this.btnBuild.Size = new System.Drawing.Size(140, 26); + this.btnBuild.TabIndex = 17; + this.btnBuild.Text = "&Build Connection String"; + this.btnBuild.UseVisualStyleBackColor = true; + this.btnBuild.Click += new System.EventHandler(this.btnBuild_Click); + // + // btnTest + // + this.btnTest.Location = new System.Drawing.Point(166, 316); + this.btnTest.Name = "btnTest"; + this.btnTest.Size = new System.Drawing.Size(120, 26); + this.btnTest.TabIndex = 18; + this.btnTest.Text = "Te&st Connection"; + this.btnTest.UseVisualStyleBackColor = true; + this.btnTest.Click += new System.EventHandler(this.btnTest_Click); + // + // btnCopy + // + this.btnCopy.Location = new System.Drawing.Point(296, 316); + this.btnCopy.Name = "btnCopy"; + this.btnCopy.Size = new System.Drawing.Size(120, 26); + this.btnCopy.TabIndex = 19; + this.btnCopy.Text = "Cop&y to Clipboard"; + this.btnCopy.UseVisualStyleBackColor = true; + this.btnCopy.Click += new System.EventHandler(this.btnCopy_Click); + // + // btnClear + // + this.btnClear.Location = new System.Drawing.Point(426, 316); + this.btnClear.Name = "btnClear"; + this.btnClear.Size = new System.Drawing.Size(124, 26); + this.btnClear.TabIndex = 20; + this.btnClear.Text = "Cl&ear All"; + this.btnClear.UseVisualStyleBackColor = true; + this.btnClear.Click += new System.EventHandler(this.btnClear_Click); + // + // btnWhoAmI + // + this.btnWhoAmI.Location = new System.Drawing.Point(16, 348); + this.btnWhoAmI.Name = "btnWhoAmI"; + this.btnWhoAmI.Size = new System.Drawing.Size(534, 26); + this.btnWhoAmI.TabIndex = 21; + this.btnWhoAmI.Text = "&Who Am I? (run identity query on the database)"; + this.btnWhoAmI.UseVisualStyleBackColor = true; + this.btnWhoAmI.Click += new System.EventHandler(this.btnWhoAmI_Click); + // + // lblStatus + // + this.lblStatus.AutoSize = true; + this.lblStatus.Location = new System.Drawing.Point(16, 386); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Size = new System.Drawing.Size(40, 13); + this.lblStatus.TabIndex = 22; + this.lblStatus.Text = "Result:"; + // + // txtStatus + // + this.txtStatus.Location = new System.Drawing.Point(16, 402); + this.txtStatus.Multiline = true; + this.txtStatus.Name = "txtStatus"; + this.txtStatus.ReadOnly = true; + this.txtStatus.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.txtStatus.Size = new System.Drawing.Size(534, 160); + this.txtStatus.TabIndex = 23; + this.txtStatus.WordWrap = false; + this.txtStatus.Font = new System.Drawing.Font("Consolas", 9F); + // + // statusStrip + // + this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.statusLabel}); + this.statusStrip.Location = new System.Drawing.Point(0, 578); + this.statusStrip.Name = "statusStrip"; + this.statusStrip.Size = new System.Drawing.Size(566, 22); + this.statusStrip.TabIndex = 24; + // + // statusLabel + // + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(39, 17); + this.statusLabel.Text = "Ready"; + // + // MainForm + // + this.AcceptButton = this.btnTest; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(566, 600); + this.Controls.Add(this.statusStrip); + this.Controls.Add(this.txtStatus); + this.Controls.Add(this.lblStatus); + this.Controls.Add(this.btnWhoAmI); + this.Controls.Add(this.btnClear); + this.Controls.Add(this.btnCopy); + this.Controls.Add(this.btnTest); + this.Controls.Add(this.btnBuild); + this.Controls.Add(this.txtConnectionString); + this.Controls.Add(this.lblConnectionString); + this.Controls.Add(this.numTimeout); + this.Controls.Add(this.lblTimeout); + this.Controls.Add(this.chkClearTokenCache); + this.Controls.Add(this.chkTrustServerCertificate); + this.Controls.Add(this.cmbEncrypt); + this.Controls.Add(this.lblEncrypt); + this.Controls.Add(this.txtPassword); + this.Controls.Add(this.lblPassword); + this.Controls.Add(this.txtUserId); + this.Controls.Add(this.lblUserId); + this.Controls.Add(this.cmbAuthentication); + this.Controls.Add(this.lblAuthentication); + this.Controls.Add(this.txtDatabase); + this.Controls.Add(this.lblDatabase); + this.Controls.Add(this.txtServer); + this.Controls.Add(this.lblServer); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.MaximizeBox = false; + this.Name = "MainFormWorker"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Azure SQL Connector — Worker thread (Task.Run + Open)"; + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).EndInit(); + this.statusStrip.ResumeLayout(false); + this.statusStrip.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label lblServer; + private System.Windows.Forms.TextBox txtServer; + private System.Windows.Forms.Label lblDatabase; + private System.Windows.Forms.TextBox txtDatabase; + private System.Windows.Forms.Label lblAuthentication; + private System.Windows.Forms.ComboBox cmbAuthentication; + private System.Windows.Forms.Label lblUserId; + private System.Windows.Forms.TextBox txtUserId; + private System.Windows.Forms.Label lblPassword; + private System.Windows.Forms.TextBox txtPassword; + private System.Windows.Forms.Label lblEncrypt; + private System.Windows.Forms.ComboBox cmbEncrypt; + private System.Windows.Forms.CheckBox chkTrustServerCertificate; + private System.Windows.Forms.Label lblTimeout; + private System.Windows.Forms.NumericUpDown numTimeout; + private System.Windows.Forms.CheckBox chkClearTokenCache; + private System.Windows.Forms.Label lblConnectionString; + private System.Windows.Forms.TextBox txtConnectionString; + private System.Windows.Forms.Button btnBuild; + private System.Windows.Forms.Button btnTest; + private System.Windows.Forms.Button btnCopy; + private System.Windows.Forms.Button btnClear; + private System.Windows.Forms.Button btnWhoAmI; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.TextBox txtStatus; + private System.Windows.Forms.StatusStrip statusStrip; + private System.Windows.Forms.ToolStripStatusLabel statusLabel; + } +} diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.cs b/doc/apps/AzureSqlConnector/MainFormWorker.cs new file mode 100644 index 0000000000..8d344cf6c4 --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainFormWorker.cs @@ -0,0 +1,598 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.Data.SqlClient; +using Microsoft.Identity.Client; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// "Worker thread" variant of the connector form. Opens the SQL connection synchronously + /// inside a call so the UI thread never blocks. + /// + /// + /// + /// The form's HWND is captured on the UI thread in the constructor and stashed in + /// . Both Entra ID parent-window callbacks return that captured + /// handle, so they are safe to invoke from the worker thread (touching Form.Handle + /// from a non-UI thread is illegal). + /// + /// + /// Compare with , which keeps Open on the UI thread and relies on + /// for responsiveness. + /// + /// + public partial class MainFormWorker : Form + { + // ────────────────────────────────────────────────────────────────── + #region Construction + + public MainFormWorker() + { + InitializeComponent(); + PopulateAuthenticationMethods(); + PopulateEncryptOptions(); + UpdateCredentialFieldsAvailability(); + + // Force the underlying Win32 window to be created NOW (on the UI thread) so we can + // safely capture its HWND for MSAL to use later from a worker thread. Touching + // Form.Handle from a non-UI thread is illegal, so we read it here once and stash it. + _ownerHwnd = this.Handle; + + RegisterActiveDirectoryProvider(); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Initialization + + private void PopulateAuthenticationMethods() + { + foreach (SqlAuthenticationMethod method in Enum.GetValues(typeof(SqlAuthenticationMethod))) + { + cmbAuthentication.Items.Add(method); + } + + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + } + + private void PopulateEncryptOptions() + { + cmbEncrypt.Items.Add(EncryptDisplay.Mandatory); + cmbEncrypt.Items.Add(EncryptDisplay.Optional); + cmbEncrypt.Items.Add(EncryptDisplay.Strict); + cmbEncrypt.SelectedIndex = 0; + } + + /// + /// Registers a single for every + /// Entra ID authentication method and gives it the form's captured HWND as the parent + /// window owner. Both callbacks intentionally use the HWND captured in the constructor + /// () rather than this.Handle; they are invoked by MSAL on + /// the worker thread that called . + /// + private void RegisterActiveDirectoryProvider() + { + ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider(); + IntPtr ownerHwnd = _ownerHwnd; + +#if NETFRAMEWORK + // .NET Framework: parent the embedded WebView via the legacy IWin32Window API. + provider.SetIWin32WindowFunc(() => new Win32WindowHandle(ownerHwnd)); +#endif + + // Modern API: works on both .NET Framework and .NET 8+, and is the one MSAL's WAM + // broker consults on Windows. + provider.SetParentActivityOrWindowFunc(() => ownerHwnd); + + // Without this, MSAL's default device-code callback writes the prompt to + // Console.WriteLine, which is invisible in a WinForms host — the connection + // appears to hang while MSAL polls for a code the user never sees. + provider.SetDeviceCodeFlowCallback(DeviceCodeFlowCallback); + + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, provider); + #pragma warning disable CS0618 // Type or member is obsolete + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, provider); + #pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Device Code Flow callback. MSAL invokes this on a worker thread before it begins + /// polling the token endpoint. We surface the user code three ways so the user always + /// sees it: (1) appended to the log textbox via BeginInvoke (the UI thread is free in + /// this variant because Open() runs on a Task.Run worker), (2) the verification URL + /// launched in the default browser, and (3) a modal owned by the MSAL worker thread. + /// MSAL polling waits for the returned Task to complete, so dismissing the dialog + /// also resumes polling. + /// + private Task DeviceCodeFlowCallback(DeviceCodeResult result) + { + string message = result.Message; + string url = result.VerificationUrl; + string code = result.UserCode; + + if (IsHandleCreated) + { + try + { + BeginInvoke((Action)(() => + { + AppendStatus(string.Empty); + AppendStatus("=== Device Code Flow ==="); + AppendStatus(message); + })); + } + catch (InvalidOperationException) + { + // Form is closing or handle was destroyed; fall through to the modal. + } + } + + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch + { + // Best-effort; the modal below still shows the URL and code. + } + + MessageBox.Show( + "Sign in to complete Device Code Flow:" + Environment.NewLine + Environment.NewLine + + " URL : " + url + Environment.NewLine + + " Code: " + code + Environment.NewLine + Environment.NewLine + + "A browser window has been opened. Enter the code above, complete sign-in," + + Environment.NewLine + "then click OK to resume the connection.", + "Device Code Flow", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + + return Task.CompletedTask; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Event Handlers + + private void cmbAuthentication_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateCredentialFieldsAvailability(); + } + + private void btnBuild_Click(object sender, EventArgs e) + { + try + { + SqlConnectionStringBuilder builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + SetStatus("Connection string built successfully.", isError: false); + AppendStatus("Connection string built:\r\n" + MaskPassword(builder)); + } + catch (Exception ex) + { + txtConnectionString.Text = string.Empty; + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private async void btnTest_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + SetBusy(true, "Testing connection..."); + AppendStatus(string.Empty); + AppendStatus("Testing connectivity to " + builder.DataSource + " ..."); + + MaybeClearTokenCache(); + + try + { + // Run Open() on a thread-pool worker so the UI thread never blocks. The await + // continuation hops back onto the UI thread automatically (the awaiter captures + // the current SynchronizationContext), so it is safe to touch the form's controls + // after the await. + // + // The Entra ID interactive / WAM flows still find a parent window because we + // captured the form's HWND on the UI thread in the constructor and the callbacks + // registered in RegisterActiveDirectoryProvider return that captured handle (no + // UI-thread-only Form.Handle access from the worker thread). + string connectionString = builder.ConnectionString; + string serverVersion = await Task.Run(() => + { + using (SqlConnection connection = new SqlConnection(connectionString)) + { + connection.Open(); + return connection.ServerVersion; + } + }).ConfigureAwait(true); + + SetStatus("Connected successfully.", isError: false); + AppendStatus("Connected successfully! Server version: " + serverVersion); + } + catch (SqlException ex) + { + SetStatus("Connection failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Connection failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private async void btnWhoAmI_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + SetBusy(true, "Querying logged-in identity..."); + AppendStatus(string.Empty); + AppendStatus("Running identity query against " + builder.DataSource + " ..."); + + MaybeClearTokenCache(); + + try + { + // Run the whole open + query + read on a worker thread so the UI never blocks. + // We materialize the single result row into a List<(name, value)> on the worker + // and then format it on the UI thread once the await returns. + string connectionString = builder.ConnectionString; + List<(string Name, object Value)> row = await Task.Run(() => + { + using (SqlConnection connection = new SqlConnection(connectionString)) + { + connection.Open(); + + using (SqlCommand command = connection.CreateCommand()) + { + command.CommandText = IdentityQuery.CommandText; + + using (SqlDataReader reader = command.ExecuteReader()) + { + if (!reader.Read()) + { + return null; + } + + var fields = new List<(string, object)>(reader.FieldCount); + for (int i = 0; i < reader.FieldCount; i++) + { + object value = reader.IsDBNull(i) ? "(null)" : reader.GetValue(i); + fields.Add((reader.GetName(i), value)); + } + return fields; + } + } + } + }).ConfigureAwait(true); + + if (row is null) + { + SetStatus("Identity query returned no rows.", isError: true); + AppendStatus("(no rows returned)"); + } + else + { + AppendStatus("Identity:"); + foreach (var (name, value) in row) + { + AppendStatus(" " + name.PadRight(16) + ": " + value); + } + SetStatus("Identity query succeeded.", isError: false); + } + } + catch (SqlException ex) + { + SetStatus("Identity query failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Identity query failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private void btnCopy_Click(object sender, EventArgs e) + { + if (string.IsNullOrEmpty(txtConnectionString.Text)) + { + SetStatus("Nothing to copy. Build the connection string first.", isError: true); + return; + } + + try + { + Clipboard.SetText(BuildConnectionString().ConnectionString); + SetStatus("Connection string copied to clipboard.", isError: false); + } + catch (Exception ex) + { + SetStatus("Failed to copy to clipboard.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private void btnClear_Click(object sender, EventArgs e) + { + txtServer.Clear(); + txtDatabase.Clear(); + txtUserId.Clear(); + txtPassword.Clear(); + txtConnectionString.Clear(); + txtStatus.Clear(); + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + cmbEncrypt.SelectedIndex = 0; + chkTrustServerCertificate.Checked = false; + numTimeout.Value = 30; + SetStatus("Ready", isError: false); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Connection String Construction + + private SqlConnectionStringBuilder BuildConnectionString() + { + string server = (txtServer.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(server)) + { + throw new InvalidOperationException("Server name is required."); + } + + SqlAuthenticationMethod authMethod = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + { + DataSource = server, + ConnectTimeout = (int)numTimeout.Value, + }; + + string database = (txtDatabase.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(database)) + { + builder.InitialCatalog = database; + } + + if (authMethod != SqlAuthenticationMethod.NotSpecified) + { + builder.Authentication = authMethod; + } + + if (RequiresUserAndPassword(authMethod)) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException( + "User ID is required for " + authMethod + " authentication."); + } + + builder.UserID = userId; + builder.Password = txtPassword.Text ?? string.Empty; + } + else if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + || authMethod == SqlAuthenticationMethod.ActiveDirectoryManagedIdentity + || authMethod == SqlAuthenticationMethod.ActiveDirectoryMSI + || authMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDefault + || authMethod == SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(userId)) + { + builder.UserID = userId; + } + + if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + && !string.IsNullOrEmpty(txtPassword.Text)) + { + builder.Password = txtPassword.Text; + } + } + + string encryptValue = cmbEncrypt.SelectedItem as string ?? EncryptDisplay.Mandatory; + switch (encryptValue) + { + case EncryptDisplay.Mandatory: + builder.Encrypt = SqlConnectionEncryptOption.Mandatory; + break; + case EncryptDisplay.Optional: + builder.Encrypt = SqlConnectionEncryptOption.Optional; + break; + case EncryptDisplay.Strict: + builder.Encrypt = SqlConnectionEncryptOption.Strict; + break; + } + + builder.TrustServerCertificate = chkTrustServerCertificate.Checked; + + return builder; + } + + private static bool RequiresUserAndPassword(SqlAuthenticationMethod method) + { + switch (method) + { + case SqlAuthenticationMethod.SqlPassword: +#pragma warning disable CS0618 // Type or member is obsolete + case SqlAuthenticationMethod.ActiveDirectoryPassword: +#pragma warning restore CS0618 + return true; + default: + return false; + } + } + + private static string MaskPassword(SqlConnectionStringBuilder builder) + { + if (string.IsNullOrEmpty(builder.Password)) + { + return builder.ConnectionString; + } + + SqlConnectionStringBuilder copy = new SqlConnectionStringBuilder(builder.ConnectionString) + { + Password = "********", + }; + return copy.ConnectionString; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Helpers + + private void UpdateCredentialFieldsAvailability() + { + if (cmbAuthentication.SelectedItem == null) + { + return; + } + + SqlAuthenticationMethod method = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + bool userEnabled = method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; + bool passwordEnabled = RequiresUserAndPassword(method) + || method == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; + + txtUserId.Enabled = userEnabled; + txtPassword.Enabled = passwordEnabled; + + if (!passwordEnabled) + { + txtPassword.Clear(); + } + } + + private void SetStatus(string text, bool isError) + { + statusLabel.Text = text; + statusLabel.ForeColor = isError ? System.Drawing.Color.Firebrick : System.Drawing.Color.Black; + } + + private void AppendStatus(string line) + { + if (txtStatus.TextLength > 0) + { + txtStatus.AppendText(Environment.NewLine); + } + txtStatus.AppendText(line ?? string.Empty); + } + + private void SetBusy(bool busy, string statusText) + { + btnBuild.Enabled = !busy; + btnTest.Enabled = !busy; + btnCopy.Enabled = !busy; + btnClear.Enabled = !busy; + btnWhoAmI.Enabled = !busy; + Cursor = busy ? Cursors.WaitCursor : Cursors.Default; + + if (statusText != null) + { + SetStatus(statusText, isError: false); + } + } + + // Only drops the in-process PCA / TokenCredential maps; MSAL's persistent on-disk cache + // and WAM broker accounts are untouched. Sufficient to demo a worker-thread interactive + // prompt when the persistent cache has already been cleared (fresh run or no WAM account + // bound), and a useful reset between back-to-back connects within a single session. + private void MaybeClearTokenCache() + { + if (!chkClearTokenCache.Checked) + { + return; + } + + ActiveDirectoryAuthenticationProvider.ClearUserTokenCache(); + AppendStatus("Cleared in-process MSAL token cache."); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Nested Types + + private static class EncryptDisplay + { + public const string Mandatory = "Mandatory"; + public const string Optional = "Optional"; + public const string Strict = "Strict"; + } + + /// + /// Tiny wrapper around a raw HWND captured on the UI thread. + /// Used so that MSAL.NET's IWin32WindowFunc callback can safely return a window + /// owner from a worker thread without ever touching off-UI. + /// Only needed on .NET Framework where the legacy SetIWin32WindowFunc API is used. + /// +#if NETFRAMEWORK + private sealed class Win32WindowHandle : IWin32Window + { + private readonly IntPtr _hwnd; + public Win32WindowHandle(IntPtr hwnd) => _hwnd = hwnd; + public IntPtr Handle => _hwnd; + } +#endif + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Private Fields + + /// + /// The form's Win32 window handle, captured on the UI thread in the constructor. + /// Read from worker threads by the Entra ID provider callbacks to parent MSAL's + /// sign-in / WAM broker UI without illegally touching . + /// + private readonly IntPtr _ownerHwnd; + + #endregion + } +} diff --git a/doc/apps/AzureSqlConnector/ModeSelectorForm.cs b/doc/apps/AzureSqlConnector/ModeSelectorForm.cs new file mode 100644 index 0000000000..651412de32 --- /dev/null +++ b/doc/apps/AzureSqlConnector/ModeSelectorForm.cs @@ -0,0 +1,116 @@ +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Choice exposed by . + /// + internal enum ConnectionMode + { + /// + /// Use , which calls SqlConnection.OpenAsync() on the UI + /// thread. Relies on the WinForms SynchronizationContext to keep the message pump alive. + /// + UiThreadOpenAsync, + + /// + /// Use , which calls SqlConnection.Open() inside + /// Task.Run on a thread-pool worker. The captured form HWND is passed to MSAL. + /// + WorkerThreadOpen, + } + + /// + /// Tiny modal dialog shown at startup that lets the user pick which connector form + /// (UI-thread async or worker-thread sync) to launch. + /// + internal sealed class ModeSelectorForm : Form + { + private readonly RadioButton _rdoUiThread; + private readonly RadioButton _rdoWorker; + + internal ConnectionMode SelectedMode => + _rdoWorker.Checked ? ConnectionMode.WorkerThreadOpen : ConnectionMode.UiThreadOpenAsync; + + internal ModeSelectorForm() + { + Text = "Azure SQL Connector — Choose Mode"; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterScreen; + MaximizeBox = false; + MinimizeBox = false; + ClientSize = new Size(460, 200); + + Label lblHeader = new Label + { + AutoSize = false, + Text = "Select how SqlConnection.Open should be invoked:", + Location = new Point(16, 14), + Size = new Size(420, 20), + Font = new Font(Font, FontStyle.Bold), + }; + + _rdoUiThread = new RadioButton + { + Text = "&UI thread", + Location = new Point(20, 42), + Size = new Size(420, 20), + Checked = true, + }; + + Label lblUiHint = new Label + { + AutoSize = false, + Text = " Async/Sync open on the UI thread; SynchronizationContext keeps the form responsive.", + Location = new Point(20, 62), + Size = new Size(420, 18), + ForeColor = SystemColors.GrayText, + }; + + _rdoWorker = new RadioButton + { + Text = "&Worker thread", + Location = new Point(20, 90), + Size = new Size(420, 20), + }; + + Label lblWorkerHint = new Label + { + AutoSize = false, + Text = " Sync open on a thread-pool worker; HWND is captured up-front for MSAL.", + Location = new Point(20, 110), + Size = new Size(420, 18), + ForeColor = SystemColors.GrayText, + }; + + Button btnOk = new Button + { + Text = "&Launch", + DialogResult = DialogResult.OK, + Location = new Point(268, 152), + Size = new Size(82, 28), + }; + + Button btnCancel = new Button + { + Text = "Cancel", + DialogResult = DialogResult.Cancel, + Location = new Point(358, 152), + Size = new Size(82, 28), + }; + + AcceptButton = btnOk; + CancelButton = btnCancel; + + Controls.AddRange(new Control[] + { + lblHeader, + _rdoUiThread, lblUiHint, + _rdoWorker, lblWorkerHint, + btnOk, btnCancel, + }); + } + } +} diff --git a/doc/apps/AzureSqlConnector/Program.cs b/doc/apps/AzureSqlConnector/Program.cs new file mode 100644 index 0000000000..c4bfad836c --- /dev/null +++ b/doc/apps/AzureSqlConnector/Program.cs @@ -0,0 +1,39 @@ +using System; +using System.Windows.Forms; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Application entry point for the Azure SQL Connector WinForms test app. + /// + internal static class Program + { + /// + /// The main entry point for the application. Shows a small chooser dialog at startup so + /// the user can pick between the UI-thread and the worker-thread + /// variant of the connector. + /// + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + ConnectionMode mode; + using (ModeSelectorForm selector = new ModeSelectorForm()) + { + if (selector.ShowDialog() != DialogResult.OK) + { + return; + } + mode = selector.SelectedMode; + } + + Form main = mode == ConnectionMode.WorkerThreadOpen + ? (Form)new MainFormWorker() + : new MainForm(); + + Application.Run(main); + } + } +} diff --git a/doc/apps/AzureSqlConnector/README.md b/doc/apps/AzureSqlConnector/README.md new file mode 100644 index 0000000000..62f100e966 --- /dev/null +++ b/doc/apps/AzureSqlConnector/README.md @@ -0,0 +1,137 @@ +# Azure SQL Connector (WinForms) + +A small Windows Forms test application that lets a user fill in Azure SQL Database connection +parameters in a UI, builds the corresponding ADO.NET connection string via +`SqlConnectionStringBuilder`, and tests connectivity using `Microsoft.Data.SqlClient`. + +It is intended as a quick, repeatable scratch tool for manually validating connection-string +combinations (server / database / authentication mode / encryption / etc.) against an Azure SQL DB +or SQL Server instance, **and as a manual repro** for the WAM-broker behavior added in this +branch's `ActiveDirectoryAuthenticationProvider`. + +The sample multi-targets: + +| TFM | Purpose | +| ---------------- | ------------------------------------------------------------------------------------------------ | +| `net481` | Exercises the legacy `SetIWin32WindowFunc` API used by .NET Framework callers with WinForms. | +| `net10.0-windows` | Exercises the modern `SetParentActivityOrWindowFunc` API used on .NET 8+. | + +`net10.0-windows` restores and builds cleanly on Linux/macOS hosts even though the resulting +binary only runs on Windows, so the project no longer needs a separate no-op cross-platform +fallback. + +> **Note:** `SetParentActivityOrWindowFunc` is also available on `net481` and is the +> recommended API for new code on any framework. The sample wires `net481` up to +> `SetIWin32WindowFunc` only to keep coverage of that legacy code path; replacing the +> `SetIWin32WindowFunc(() => this)` call with `SetParentActivityOrWindowFunc(() => this.Handle)` +> on `net481` works the same way. + +## Mode selector + +When the app launches it shows a small `ModeSelectorForm` that picks between two top-level forms: + +| Mode | Form | What it exercises | +| ---------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **UI thread (`OpenAsync`)** | `MainForm` | Calls `SqlConnection.OpenAsync()` on the UI thread so the Windows Forms message pump stays alive during MSAL sign-in. | +| **Worker thread (`Open`, sync)** | `MainFormWorker` | Calls `SqlConnection.Open()` on a background worker thread; the parent window handle is captured up-front on the UI thread. | + +Both forms demonstrate the supported patterns for parenting the WAM broker (or the legacy +embedded WebView on .NET Framework). + +## Form inputs + +| Field | Maps to connection string keyword | +| -------------------------- | ----------------------------------------------- | +| Server name | `Data Source` | +| Database name | `Initial Catalog` *(only added when non-empty)* | +| Authentication | `Authentication` *(SqlAuthenticationMethod)* | +| User ID | `User ID` | +| Password | `Password` | +| Encrypt | `Encrypt` *(Mandatory / Optional / Strict)* | +| Trust server certificate | `TrustServerCertificate` | +| Connect timeout (s) | `Connect Timeout` | + +The **Authentication** dropdown is populated from every member of +`Microsoft.Data.SqlClient.SqlAuthenticationMethod`. The User ID and Password fields are enabled / +disabled automatically based on the selected method: + +- **SqlPassword** / **ActiveDirectoryPassword** — both User ID and Password are required. +- **ActiveDirectoryServicePrincipal** — User ID = App (Client) ID, Password = client secret. +- **ActiveDirectoryManagedIdentity / MSI / Default / Interactive / DeviceCodeFlow / WorkloadIdentity** + — User ID is optional (e.g. user-assigned MI client id), Password is disabled. +- **ActiveDirectoryIntegrated** — credentials come from the OS, both fields disabled. + +## Buttons + +| Button | Action | +| ----------------------- | ---------------------------------------------------------------------- | +| Build Connection String | Builds the connection string from the form values and displays it. | +| Test Connection | Builds the connection string and opens the connection. | +| Copy to Clipboard | Copies the currently-built connection string to the clipboard. | +| Clear All | Resets every input field to its default state. | +| Who Am I? | Connects and runs an identity query (`SUSER_SNAME()`, `ORIGINAL_LOGIN()`, `USER_NAME()`, `DB_NAME()`, `@@SPID`, etc.) and prints the results. | + +The result pane shows the built connection string with the password masked, the test connection +outcome (including SQL error number when applicable), and the server version on success. + +## Prerequisites + +- Visual Studio 2026 (or any IDE / SDK with .NET Framework **4.8.1** Developer Pack installed) for + the `net481` target. The `net10.0-windows` target only needs the .NET 10 SDK. +- Network connectivity to your Azure SQL Database (server firewall must allow your client IP). +- For Entra ID authentication modes, valid credentials available through Azure CLI / environment + variables / managed identity / the WAM broker, depending on the chosen method. + +## Build & run + +From the project folder: + +```pwsh +dotnet build .\AzureSqlConnector.csproj +dotnet run --project .\AzureSqlConnector.csproj -f net10.0-windows # modern WAM API +dotnet run --project .\AzureSqlConnector.csproj -f net481 # legacy IWin32Window API +``` + +Or load `src\Microsoft.Data.SqlClient.slnx` in Visual Studio, set **AzureSqlConnector** as the +startup project, and press **F5**. + +## Example + +1. **Server name:** `myserver.database.windows.net` +2. **Database name:** `MyDb` +3. **Authentication:** `SqlPassword` +4. **User ID:** `sqladmin` +5. **Password:** *your password* +6. **Encrypt:** `Mandatory` +7. **Trust server certificate:** unchecked +8. Click **Test Connection** — the result pane should display + `Connected successfully! Server version: 12.00.xxxx`. + +## Entra ID parent-window plumbing + +For any `ActiveDirectory*` authentication method (especially **ActiveDirectoryInteractive**) the +app installs an `ActiveDirectoryAuthenticationProvider` and tells it which window should host the +sign-in UI: + +- On **`net481`** the form calls `provider.SetIWin32WindowFunc(() => this)`. This is the legacy + API used by .NET Framework callers with the embedded WebView. +- On **`net10.0-windows`** the form calls + `provider.SetParentActivityOrWindowFunc(() => this.Handle)`. This is the modern API that also + integrates with the WAM broker on Windows. + +The provider is registered for every `SqlAuthenticationMethod.ActiveDirectory*` value at startup. + +### Threading patterns + +| Form | Open mode | Parent window callback | +| ----------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `MainForm` | `OpenAsync` on UI thread | Callback runs on the UI thread when MSAL invokes it, so `this`/`this.Handle` is naturally safe to access. | +| `MainFormWorker` | `Open` (sync) on worker | The form captures `this.Handle` into a field on the UI thread before kicking off the worker; the callback closes over that captured value so it never needs to marshal back. | + +Without one of these patterns the WAM broker (or the embedded WebView on .NET Framework) can fail +to render or stay unresponsive while it waits for the user. + +## Notes + +- This is a sample / diagnostic tool, **not** a product. It does not persist credentials. +- From the repo root: `dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj` diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index 36f10a6aab..4dd9f61d5a 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -1,4 +1,9 @@ - + + @@ -74,7 +79,12 @@ Clears cached user tokens from the token provider. - This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + + The driver's per-pool federated-authentication token cache is also cleared, so subsequent calls will reacquire fed-auth tokens instead of reusing cached entries. + diff --git a/global.json b/global.json index bf6c9e3e32..3c51141e58 100644 --- a/global.json +++ b/global.json @@ -7,7 +7,7 @@ // .NET 10 SDK versions in the 10.0.2xx series require MSBuild 18.x, so we stick with the // 10.0.1xx feature band which is compatible with MSBuild 17.x. // - "version": "10.0.100", + "version": "10.0.109", // Allow roll-forward within the 10.0.1xx feature band so servicing patches // are picked up automatically without requiring a PR for each bump. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs index e0adf34e49..5007384465 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs @@ -59,7 +59,7 @@ static Internal() // Look for the manager class. const string className = "Microsoft.Data.SqlClient.SqlAuthenticationProviderManager"; - var manager = assembly.GetType(className); + Type? manager = assembly.GetType(className); if (manager is null) { diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 84b6343497..2b58a8676f 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -1,4 +1,4 @@ - +