diff --git a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj new file mode 100644 index 0000000000..18ff203968 --- /dev/null +++ b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj @@ -0,0 +1,36 @@ + + + + + net481 + net9.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..1a686484e1 --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -0,0 +1,583 @@ +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..92d4194345 --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainFormWorker.cs @@ -0,0 +1,591 @@ +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..be930949b8 --- /dev/null +++ b/doc/apps/AzureSqlConnector/README.md @@ -0,0 +1,127 @@ +# 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 targets **`net481`** on Windows and **`net9.0-windows`** on non-Windows hosts, exercising the modern +`SetParentActivityOrWindowFunc` API (used on .NET 8+) and the WAM broker integration. + +`net9.0-windows` restores and builds cleanly on Linux/macOS hosts (via +`EnableWindowsTargeting`) even though the resulting binary only runs on Windows. + +> **Note:** The upstream PR on `main` multi-targets `net481` and `net10.0-windows`. +> On the `release/6.1` branch the .NET Framework and .NET Core implementations live in +> separate csproj files, which makes cross-TFM `ProjectReference` selection unreliable +> during NuGet restore. This sample therefore ships a single modern TFM. + +## 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 + +- .NET 9 SDK (or Visual Studio 2022 17.12+). +- 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 +``` + +Or from the repository root: + +```pwsh +dotnet build doc\apps\AzureSqlConnector\AzureSqlConnector.csproj +``` + +## 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 by calling `provider.SetParentActivityOrWindowFunc(() => this.Handle)`. This is the +modern API that 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.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 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/apps/Directory.Packages.props b/doc/apps/Directory.Packages.props new file mode 100644 index 0000000000..dde81bd01a --- /dev/null +++ b/doc/apps/Directory.Packages.props @@ -0,0 +1,17 @@ + + + true + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index a940011eb3..15c946e9f9 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -58,6 +58,17 @@ Initializes the class with the provided device code flow callback method and application client id. + + + A instance whose properties initialize the provider. Must not be . + + + Initializes the class from a bag. This is the recommended constructor for new code because new options can be added to ActiveDirectoryAuthenticationProviderOptions without forcing breaking changes on callers. + + + Thrown when is . + + The Active Directory authentication parameters passed to authentication providers. diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProviderOptions.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProviderOptions.xml new file mode 100644 index 0000000000..737faa0e09 --- /dev/null +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProviderOptions.xml @@ -0,0 +1,34 @@ + + + + + Property bag used by the constructor. + + + Prefer this options-bag constructor over the positional-argument overloads in new code. Additional options can be added here in future versions without introducing new constructor overloads. + + is a Windows-only setting: it has no effect on non-Windows platforms, where interactive Entra ID flows always use the system browser. + + + + + + Optional device-code-flow callback invoked for SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow. + When , the provider's default callback (which writes the device-code instructions to the console) + is used. + + + + + Optional Entra ID application (client) id. When , the SqlClient first-party application id is used and WAM broker mode is forced on (regardless of ) when running on Windows. + + + + + Windows-only setting to control whether the provider uses the WAM broker for interactive authentication. + Defaults to when you provide a custom . + Otherwise, WAM broker mode is used automatically on Windows. + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 81f8d36578..11a832898f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -11,7 +11,8 @@ - + + @@ -31,7 +32,8 @@ - + + @@ -50,7 +52,8 @@ - + + @@ -68,7 +71,8 @@ - + + diff --git a/src/Microsoft.Data.SqlClient.sln b/src/Microsoft.Data.SqlClient.sln index e07853a025..f797897653 100644 --- a/src/Microsoft.Data.SqlClient.sln +++ b/src/Microsoft.Data.SqlClient.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31912.275 +# Visual Studio Version 18 +VisualStudioVersion = 18.7.11911.148 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Data.SqlClient", "Microsoft.Data.SqlClient\netfx\src\Microsoft.Data.SqlClient.csproj", "{407890AC-9876-4FEF-A6F1-F36A876BAADE}" EndProject @@ -297,16 +297,20 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "steps", "steps", "{AD738BD4 ..\eng\pipelines\steps\compound-extract-akv-apiscan-files-step.yml = ..\eng\pipelines\steps\compound-extract-akv-apiscan-files-step.yml ..\eng\pipelines\steps\compound-nuget-pack-step.yml = ..\eng\pipelines\steps\compound-nuget-pack-step.yml ..\eng\pipelines\steps\compound-publish-symbols-step.yml = ..\eng\pipelines\steps\compound-publish-symbols-step.yml + ..\eng\pipelines\steps\install-dotnet-arm64.ps1 = ..\eng\pipelines\steps\install-dotnet-arm64.ps1 + ..\eng\pipelines\steps\install-dotnet.yml = ..\eng\pipelines\steps\install-dotnet.yml ..\eng\pipelines\steps\roslyn-analyzers-akv-step.yml = ..\eng\pipelines\steps\roslyn-analyzers-akv-step.yml ..\eng\pipelines\steps\script-output-environment-variables-step.yml = ..\eng\pipelines\steps\script-output-environment-variables-step.yml - ..\eng\pipelines\steps\install-dotnet.yml = ..\eng\pipelines\steps\install-dotnet.yml - ..\eng\pipelines\steps\install-dotnet-arm64.ps1 = ..\eng\pipelines\steps\install-dotnet-arm64.ps1 EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Data.SqlClient.UnitTests", "Microsoft.Data.SqlClient\tests\UnitTests\Microsoft.Data.SqlClient.UnitTests.csproj", "{4461063D-2F2B-274C-7E6F-F235119D258E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Microsoft.Data.SqlClient\tests\Common\Common.csproj", "{67128EC0-30F5-6A98-448B-55F88A1DE707}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "apps", "apps", "{B7FEAC28-743E-4837-BC7E-A30127CE5E6D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureSqlConnector", "..\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj", "{8B299DCD-C728-231A-747E-254252866A0F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -575,6 +579,18 @@ Global {67128EC0-30F5-6A98-448B-55F88A1DE707}.Release|x64.Build.0 = Release|x64 {67128EC0-30F5-6A98-448B-55F88A1DE707}.Release|x86.ActiveCfg = Release|x86 {67128EC0-30F5-6A98-448B-55F88A1DE707}.Release|x86.Build.0 = Release|x86 + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|x64.ActiveCfg = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|x64.Build.0 = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|x86.ActiveCfg = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Debug|x86.Build.0 = Debug|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|Any CPU.Build.0 = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|x64.ActiveCfg = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|x64.Build.0 = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|x86.ActiveCfg = Release|Any CPU + {8B299DCD-C728-231A-747E-254252866A0F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -625,6 +641,8 @@ Global {AD738BD4-6A02-4B88-8F93-FBBBA49A74C8} = {4CAE9195-4F1A-4D48-854C-1C9FBC512C66} {4461063D-2F2B-274C-7E6F-F235119D258E} = {0CC4817A-12F3-4357-912C-09315FAAD008} {67128EC0-30F5-6A98-448B-55F88A1DE707} = {0CC4817A-12F3-4357-912C-09315FAAD008} + {B7FEAC28-743E-4837-BC7E-A30127CE5E6D} = {ED952CF7-84DF-437A-B066-F516E9BE1C2C} + {8B299DCD-C728-231A-747E-254252866A0F} = {B7FEAC28-743E-4837-BC7E-A30127CE5E6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {01D48116-37A2-4D33-B9EC-94793C702431} diff --git a/src/Microsoft.Data.SqlClient/netcore/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/netcore/ref/Microsoft.Data.SqlClient.cs index a07133eb07..b238541152 100644 --- a/src/Microsoft.Data.SqlClient/netcore/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/netcore/ref/Microsoft.Data.SqlClient.cs @@ -153,12 +153,16 @@ public ActiveDirectoryAuthenticationProvider(string applicationClientId) { } public static void ClearUserTokenCache() { } /// public ActiveDirectoryAuthenticationProvider(System.Func deviceCodeFlowCallbackMethod, string applicationClientId = null) { } + /// + public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) { } /// public override System.Threading.Tasks.Task AcquireTokenAsync(SqlAuthenticationParameters parameters) { throw null; } /// public void SetDeviceCodeFlowCallback(System.Func deviceCodeFlowCallbackMethod) { } /// public void SetAcquireAuthorizationCodeAsyncCallback(System.Func> acquireAuthorizationCodeAsyncCallback) { } + /// + public void SetParentActivityOrWindowFunc(System.Func parentActivityOrWindowFunc) { } /// public override bool IsSupported(SqlAuthenticationMethod authentication) { throw null; } /// @@ -166,6 +170,16 @@ public override void BeforeLoad(SqlAuthenticationMethod authentication) { } /// public override void BeforeUnload(SqlAuthenticationMethod authentication) { } } + /// + public sealed class ActiveDirectoryAuthenticationProviderOptions + { + /// + public System.Func DeviceCodeFlowCallback { get { throw null; } set { } } + /// + public string ApplicationClientId { get { throw null; } set { } } + /// + public bool UseWamBroker { get { throw null; } set { } } + } /// public enum ApplicationIntent { diff --git a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj index c032bad6a9..1fcfa2a74a 100644 --- a/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netcore/src/Microsoft.Data.SqlClient.csproj @@ -32,6 +32,7 @@ + @@ -174,6 +175,9 @@ Microsoft\Data\SqlClient\ActiveDirectoryAuthenticationProvider.cs + + Microsoft\Data\SqlClient\ActiveDirectoryAuthenticationProviderOptions.cs + Microsoft\Data\SqlClient\ActiveDirectoryAuthenticationTimeoutRetryHelper.cs @@ -811,7 +815,16 @@ + + $(DefineConstants);TARGETS_WINDOWS + + + Microsoft\Data\SqlClient\ActiveDirectoryAuthenticationProvider.Windows.cs + + + Interop\Windows\User32\User32.cs + Interop\Windows\Handles\SafeLibraryHandle.netcore.cs @@ -1033,6 +1046,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/netfx/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/netfx/ref/Microsoft.Data.SqlClient.cs index 636c39c932..ffe5ca7dee 100644 --- a/src/Microsoft.Data.SqlClient/netfx/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/netfx/ref/Microsoft.Data.SqlClient.cs @@ -68,6 +68,8 @@ public ActiveDirectoryAuthenticationProvider(string applicationClientId) { } public static void ClearUserTokenCache() { } /// public ActiveDirectoryAuthenticationProvider(System.Func deviceCodeFlowCallbackMethod, string applicationClientId = null) { } + /// + public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) { } /// public override System.Threading.Tasks.Task AcquireTokenAsync(SqlAuthenticationParameters parameters) { throw null; } /// @@ -76,6 +78,8 @@ public void SetDeviceCodeFlowCallback(System.Func> acquireAuthorizationCodeAsyncCallback) { } /// public void SetIWin32WindowFunc(System.Func iWin32WindowFunc) { } + /// + public void SetParentActivityOrWindowFunc(System.Func parentActivityOrWindowFunc) { } /// public override bool IsSupported(SqlAuthenticationMethod authentication) { throw null; } /// @@ -83,6 +87,16 @@ public override void BeforeLoad(SqlAuthenticationMethod authentication) { } /// public override void BeforeUnload(SqlAuthenticationMethod authentication) { } } + /// + public sealed class ActiveDirectoryAuthenticationProviderOptions + { + /// + public System.Func DeviceCodeFlowCallback { get { throw null; } set { } } + /// + public string ApplicationClientId { get { throw null; } set { } } + /// + public bool UseWamBroker { get { throw null; } set { } } + } /// public enum ApplicationIntent { diff --git a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj index 68830b0b7c..1e75a23376 100644 --- a/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj @@ -388,6 +388,15 @@ Microsoft\Data\SqlClient\ActiveDirectoryAuthenticationProvider.cs + + Microsoft\Data\SqlClient\ActiveDirectoryAuthenticationProvider.Windows.cs + + + Microsoft\Data\SqlClient\ActiveDirectoryAuthenticationProviderOptions.cs + + + Interop\Windows\User32\User32.cs + Microsoft\Data\SqlClient\AlwaysEncryptedEnclaveProviderUtils.cs @@ -960,6 +969,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs index 530838ba38..493ac201fa 100644 --- a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs +++ b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs @@ -90,5 +90,13 @@ internal static extern int GetFullPathName( [DllImport(DllName, SetLastError = true, ExactSpelling = true)] internal static extern bool SetThreadErrorMode(uint dwNewMode, out uint lpOldMode); + + /// + /// Raw kernel32!GetConsoleWindow P/Invoke. Documented by Windows to return + /// when the calling process is not attached to a console + /// (and to never throw). + /// + [DllImport(DllName)] + internal static extern IntPtr GetConsoleWindow(); } } diff --git a/src/Microsoft.Data.SqlClient/src/Interop/Windows/User32/User32.cs b/src/Microsoft.Data.SqlClient/src/Interop/Windows/User32/User32.cs new file mode 100644 index 0000000000..1ee3bc6e0e --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Interop/Windows/User32/User32.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace Interop.Windows.User32 +{ + /// + /// Win32 P/Invoke wrappers from user32.dll used by + /// for + /// console/window owner discovery when parenting MSAL UI on Windows. + /// + internal static class User32 + { + private const string DllName = "user32.dll"; + + /// + /// GA_ROOTOWNER flag value for GetAncestor — "Retrieves the owned root + /// window by walking the chain of parent and owner windows returned by GetParent." + /// + private const uint GA_ROOTOWNER = 3; + + /// + /// Raw user32!GetAncestor P/Invoke. Documented by Windows to return + /// rather than throw when the input handle is invalid. + /// + [DllImport(DllName)] + private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); + + /// + /// Walks the parent/owner chain of and returns the root owner + /// window, or when none can be found. + /// + internal static IntPtr GetRootOwner(IntPtr hwnd) + { + return GetAncestor(hwnd, GA_ROOTOWNER); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.Windows.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.Windows.cs new file mode 100644 index 0000000000..cdf5b52b4b --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.Windows.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using Interop.Windows.Kernel32; +using Interop.Windows.User32; + +namespace Microsoft.Data.SqlClient +{ + /// + /// Windows-only portion of . + /// + public sealed partial class ActiveDirectoryAuthenticationProvider + { + /// + /// Resolves the parent window handle used to parent MSAL UI (WAM broker dialog on Windows, + /// or the embedded WebView on .NET Framework). + /// + private IntPtr GetParentWindow() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return IntPtr.Zero; + } + + // If the user has provided a custom parent activity/window function, use it. + // Exceptions from the user-supplied callback escape to MSAL by design. + if (_parentActivityOrWindowFunc != null) + { + object parentWindow = _parentActivityOrWindowFunc(); + if (parentWindow is IntPtr hwnd) + { + return hwnd; + } +#if NETFRAMEWORK + if (parentWindow is System.Windows.Forms.IWin32Window win32Window) + { + return win32Window.Handle; + } +#endif + if (parentWindow != null) + { + throw new InvalidOperationException( + $"{nameof(SetParentActivityOrWindowFunc)} expects the callback to return an " + + "IntPtr window handle" + +#if NETFRAMEWORK + " (or an IWin32Window on .NET Framework)" + +#endif + $"; got {parentWindow.GetType().FullName}."); + } + } + + // Fall back to finding the console window, then getting its root owner. The Win32 + // calls are documented to return NULL on failure rather than throwing, so we treat any + // IntPtr.Zero return as "no console window available". + IntPtr consoleHandle = Kernel32.GetConsoleWindow(); + if (consoleHandle != IntPtr.Zero) + { + IntPtr rootOwner = User32.GetRootOwner(consoleHandle); + if (rootOwner != IntPtr.Zero) + { + return rootOwner; + } + return consoleHandle; + } + + return IntPtr.Zero; + } + } +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.cs index f5b25823c4..ccff961387 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -13,12 +14,13 @@ using Microsoft.Data.Common.ConnectionString; using Microsoft.Extensions.Caching.Memory; using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Broker; using Microsoft.Identity.Client.Extensibility; namespace Microsoft.Data.SqlClient { /// - public sealed class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider + public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider { /// /// This is a static cache instance meant to hold instances of "PublicClientApplication" mapping to information available in PublicClientAppKey. @@ -31,7 +33,21 @@ public sealed class ActiveDirectoryAuthenticationProvider : SqlAuthenticationPro private static SemaphoreSlim s_tokenCredentialMapModifierSemaphore = new(1, 1); private static readonly MemoryCache s_accountPwCache = new MemoryCache(new MemoryCacheOptions()); private static readonly int s_accountPwCacheTtlInHours = 2; + + // MSAL redirect URI used when WAM brokered authentication is in effect on Windows. MSAL + // expects the suffix to match the client id of the registered application. + private const string s_wamBrokerRedirectUriPrefix = "ms-appx-web://microsoft.aad.brokerplugin/"; + +#if NETFRAMEWORK + // Non-broker redirect URI used on .NET Framework when WAM is not in use (legacy embedded + // WebView path). private static readonly string s_nativeClientRedirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; +#endif + + // Loopback redirect URI used by MSAL's system browser flow on non-Windows platforms and on + // .NET (non-framework) Windows without WAM. + private const string s_systemBrowserRedirectUri = "http://localhost"; + private static readonly string s_defaultScopeSuffix = "/.default"; private readonly string _type = typeof(ActiveDirectoryAuthenticationProvider).Name; private readonly SqlClientLogger _logger = new(); @@ -39,28 +55,63 @@ public sealed class ActiveDirectoryAuthenticationProvider : SqlAuthenticationPro private ICustomWebUi _customWebUI = null; private readonly string _applicationClientId = ActiveDirectoryAuthentication.AdoClientId; + // True when this provider should enable the Windows Account Manager (WAM) broker for + // interactive Entra ID flows on Windows. Always true for the SqlClient first-party app id; + // for caller-supplied app ids, opt-in via the Options-pattern constructor. + private readonly bool _useWamBroker = false; + /// public ActiveDirectoryAuthenticationProvider() - : this(DefaultDeviceFlowCallback) + : this(new ActiveDirectoryAuthenticationProviderOptions()) { } /// public ActiveDirectoryAuthenticationProvider(string applicationClientId) - : this(DefaultDeviceFlowCallback, applicationClientId) + : this(new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = applicationClientId }) { } /// public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string applicationClientId = null) + : this(new ActiveDirectoryAuthenticationProviderOptions + { + DeviceCodeFlowCallback = deviceCodeFlowCallbackMethod, + ApplicationClientId = applicationClientId, + }) + { + } + + /// + public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) { - if (applicationClientId != null) + if (options == null) { - _applicationClientId = applicationClientId; + throw new ArgumentNullException(nameof(options)); + } + + _deviceCodeFlowCallback = options.DeviceCodeFlowCallback ?? DefaultDeviceFlowCallback; + if (options.ApplicationClientId != null) + { + _applicationClientId = options.ApplicationClientId; } - SetDeviceCodeFlowCallback(deviceCodeFlowCallbackMethod); + // WAM broker mode is always enabled for the SqlClient first-party application id (its + // app registration is configured for the WAM broker redirect URI). For a caller-supplied + // application id, WAM is opt-in via the Options ctor. + _useWamBroker = _applicationClientId == ActiveDirectoryAuthentication.AdoClientId || options.UseWamBroker; } + /// + /// Indicates whether this provider instance has the Windows Account Manager (WAM) broker + /// enabled for interactive Entra ID flows on Windows. Exposed as internal for tests. + /// + internal bool UseWamBroker => _useWamBroker; + + /// + /// The Entra ID application client id used by this provider instance. Exposed as internal for tests. + /// + internal string ApplicationClientId => _applicationClientId; + /// public static void ClearUserTokenCache() { @@ -114,6 +165,16 @@ public override void BeforeUnload(SqlAuthenticationMethod authentication) public void SetIWin32WindowFunc(Func iWin32WindowFunc) => this._iWin32WindowFunc = iWin32WindowFunc; #endif + private Func _parentActivityOrWindowFunc = null; + + /// + public void SetParentActivityOrWindowFunc(Func parentActivityOrWindowFunc) + { + // Passing null clears a previously-installed callback (and reverts the provider to its + // automatic console-window fallback on Windows). + _parentActivityOrWindowFunc = parentActivityOrWindowFunc; + } + /// public override async Task AcquireTokenAsync(SqlAuthenticationParameters parameters) @@ -192,20 +253,33 @@ public override async Task AcquireTokenAsync(SqlAuthenti } /* - * Today, MSAL.NET uses another redirect URI by default in desktop applications that run on Windows - * (urn:ietf:wg:oauth:2.0:oob). In the future, we'll want to change this default, so we recommend - * that you use https://login.microsoftonline.com/common/oauth2/nativeclient. + * For the remaining Active Directory authentication methods, we use MSAL.NET to acquire tokens. + * With WAM broker support in MSAL enabled, on Windows we use a fixed redirect URI in the format + * "ms-appx-web://microsoft.aad.brokerplugin/{clientId}" where {clientId} is the client ID configured for this provider. + * This is required for MSAL to correctly route the authentication request to the WAM broker and for WAM to route the response back to MSAL. * - * https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-app-registration#redirect-uris + * This means the Entra ID app registration for that client ID must include the above redirect URI to use WAM brokered authentication on Windows. */ - string redirectUri = s_nativeClientRedirectUri; - -#if NET - if (parameters.AuthenticationMethod != SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow) + string redirectUri; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - redirectUri = "http://localhost"; - } + if (_useWamBroker) + { + redirectUri = s_wamBrokerRedirectUriPrefix + _applicationClientId; + } + else + { +#if NETFRAMEWORK + redirectUri = s_nativeClientRedirectUri; +#else + redirectUri = s_systemBrowserRedirectUri; #endif + } + } + else + { + redirectUri = s_systemBrowserRedirectUri; + } PublicClientAppKey pcaKey = new(parameters.Authority, redirectUri, _applicationClientId #if NETFRAMEWORK , _iWin32WindowFunc @@ -411,10 +485,19 @@ private static async Task AcquireTokenInteractiveDeviceFlo } else { + // Device Code Flow needs minutes for the user to navigate to the verification + // URL and enter the user code. The outer per-request CTS inherits the + // SqlConnection's Connect Timeout (default 15s) and would cancel MSAL long + // before the user can finish, surfacing as "The operation was canceled." + // Use a dedicated CTS with the typical AAD user-code lifetime as the cap, + // mirroring the pattern used for ActiveDirectoryInteractive above. + using CancellationTokenSource ctsDeviceFlow = new(); + ctsDeviceFlow.CancelAfter(TimeSpan.FromMinutes(3)); // 3 minutes + AuthenticationResult result = await app.AcquireTokenWithDeviceCode(scopes, deviceCodeResult => deviceCodeFlowCallback(deviceCodeResult)) .WithCorrelationId(connectionId) - .ExecuteAsync(cancellationToken: cts.Token) + .ExecuteAsync(cancellationToken: ctsDeviceFlow.Token) .ConfigureAwait(false); return result; } @@ -560,13 +643,45 @@ private IPublicClientApplication CreateClientAppInstance(PublicClientAppKey publ RedirectUri = publicClientAppKey._redirectUri, }) .WithAuthority(publicClientAppKey._authority); - - #if NETFRAMEWORK - if (_iWin32WindowFunc is not null) + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (_useWamBroker) + { + // Enable WAM broker on Windows for all supported authentication modes. + // The broker provides enhanced security by enabling device-based Conditional Access + // policies through the Windows Account Manager (WAM). + builder.WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows)); + } + + // Set the parent window handle for broker UI. + // On .NET Framework, prefer the IWin32WindowFunc if provided by the caller. +#if NETFRAMEWORK + if (publicClientAppKey._iWin32WindowFunc is not null) + { + builder.WithParentActivityOrWindow(publicClientAppKey._iWin32WindowFunc); + } + else + { + builder.WithParentActivityOrWindow(() => (object)GetParentWindow()); + } +#else +#if TARGETS_WINDOWS + builder.WithParentActivityOrWindow(() => (object)GetParentWindow()); +#endif +#endif + } + else { - builder.WithParentActivityOrWindow(_iWin32WindowFunc); + // Non-Windows (Android / iOS / MAUI on .NET). Forward the caller-supplied callback + // to MSAL so interactive flows can parent their UI to the host Activity / + // UIViewController. WAM broker remains Windows-only and is intentionally skipped. + Func parentActivityOrWindowFunc = _parentActivityOrWindowFunc; + if (parentActivityOrWindowFunc is not null) + { + builder.WithParentActivityOrWindow(parentActivityOrWindowFunc); + } } - #endif return builder.Build(); } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProviderOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProviderOptions.cs new file mode 100644 index 0000000000..dfa6b4cd39 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProviderOptions.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Microsoft.Identity.Client; + +namespace Microsoft.Data.SqlClient +{ + /// + public sealed class ActiveDirectoryAuthenticationProviderOptions + { + /// + public Func DeviceCodeFlowCallback { get; set; } + + /// + public string ApplicationClientId { get; set; } + + /// + public bool UseWamBroker { get; set; } + } +} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs index 1fe587720c..eb08fea771 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs @@ -56,7 +56,23 @@ private static void SetDefaultAuthProviders(SqlAuthenticationProviderManager ins { if (instance != null) { - var activeDirectoryAuthProvider = new ActiveDirectoryAuthenticationProvider(instance._applicationClientId); + ActiveDirectoryAuthenticationProvider activeDirectoryAuthProvider; + if (instance._useWamBroker.HasValue) + { + // useWamBroker was explicitly set via config; route through the Options ctor so + // the value is applied for caller-supplied application ids. Note: when the SqlClient + // first-party application id is used, WAM broker is always enabled by design. + activeDirectoryAuthProvider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = instance._applicationClientId, + UseWamBroker = instance._useWamBroker.Value, + }); + } + else + { + activeDirectoryAuthProvider = new ActiveDirectoryAuthenticationProvider(instance._applicationClientId); + } instance.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, activeDirectoryAuthProvider); instance.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, activeDirectoryAuthProvider); instance.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, activeDirectoryAuthProvider); @@ -77,6 +93,13 @@ private static void SetDefaultAuthProviders(SqlAuthenticationProviderManager ins private readonly SqlClientLogger _sqlAuthLogger = new SqlClientLogger(); private readonly string _applicationClientId = ActiveDirectoryAuthentication.AdoClientId; + // Optional override for ActiveDirectoryAuthenticationProviderOptions.UseWamBroker + // read from the app.config attribute. + // null means the app did not configure the value, in which case we leave the + // provider's default behavior (WAM is implied by the SqlClient first-party app id and + // off otherwise) untouched. + private readonly bool? _useWamBroker = null; + /// /// Constructor. /// @@ -103,6 +126,23 @@ public SqlAuthenticationProviderManager(SqlAuthenticationProviderConfigurationSe _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "No user-defined Application Client Id found."); } + if (!string.IsNullOrEmpty(configSection.UseWamBroker)) + { + if (bool.TryParse(configSection.UseWamBroker, out bool useWamBroker)) + { + _useWamBroker = useWamBroker; + _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, $"Received user-defined UseWamBroker={useWamBroker}."); + } + else + { + _sqlAuthLogger.LogError(nameof(SqlAuthenticationProviderManager), methodName, $"Ignoring user-defined UseWamBroker='{configSection.UseWamBroker}': not a valid boolean."); + } + } + else + { + _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "No user-defined UseWamBroker found."); + } + // Create user-defined auth initializer, if any. if (!string.IsNullOrEmpty(configSection.InitializerType)) { @@ -292,6 +332,15 @@ internal class SqlAuthenticationProviderConfigurationSection : ConfigurationSect /// [ConfigurationProperty("applicationClientId", IsRequired = false)] public string ApplicationClientId => this["applicationClientId"] as string; + + /// + /// Forwarded to ActiveDirectoryAuthenticationProviderOptions.UseWamBroker + /// when the default Active Directory provider is auto-installed. Stored as a string so + /// that an unset attribute can be distinguished from useWamBroker="false"; the + /// runtime parses it with . + /// + [ConfigurationProperty("useWamBroker", IsRequired = false)] + public string UseWamBroker => this["useWamBroker"] as string; } /// diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs index f354e6f806..fb8d12684e 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs @@ -10,6 +10,7 @@ namespace Microsoft.Data.SqlClient.Tests { + [Collection("SqlAuthenticationProvider")] public class AADAuthenticationTests { private SqlConnectionStringBuilder _builder; diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj index cf92a3105a..fb6cb4b883 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj @@ -42,6 +42,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlAuthenticationProviderCollection.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlAuthenticationProviderCollection.cs new file mode 100644 index 0000000000..8d6f21be49 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlAuthenticationProviderCollection.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Xunit; + +namespace Microsoft.Data.SqlClient.Tests +{ + /// + /// Defines a test collection that serializes execution of test classes + /// which mutate the global registry. + /// + [CollectionDefinition("SqlAuthenticationProvider")] + public class SqlAuthenticationProviderCollection + { + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs index 078699cd03..6a9ef08ccf 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs @@ -524,6 +524,11 @@ public static bool IsAADAuthorityURLSetup() return !string.IsNullOrEmpty(AADAuthorityURL); } + public static bool IsUserManagedIdentitySetup() + { + return !string.IsNullOrEmpty(UserManagedIdentityClientId); + } + public static bool IsAzureServer() { return AreConnStringsSetup() && Utils.IsAzureSqlServer(new SqlConnectionStringBuilder(TCPConnectionString).DataSource); @@ -619,7 +624,24 @@ public static bool IsUTF8Supported() public static bool IsTCPConnectionStringPasswordIncluded() { - return RetrieveValueFromConnStr(TCPConnectionString, new string[] { "Password", "PWD" }) != string.Empty; + return RetrieveValueFromConnStr(TCPConnectionString, ["Password", "PWD"]) != string.Empty; + } + + /// + /// Returns the Azure connection string without credentials (user id, password) and authentication information. + /// + /// + public static string GetAzureConnectionStringWithoutAuthInfo() + { + string[] credKeys = ["Authentication", "User ID", "Password", "UID", "PWD"]; + return RemoveKeysInConnStr(AADPasswordConnectionString, credKeys); + } + + public static string GetManagedIdentityAuthConnectionString() + { + string connStr = GetAzureConnectionStringWithoutAuthInfo() + + $"Authentication=ActiveDirectoryManagedIdentity;User ID={UserManagedIdentityClientId};"; + return connStr; } public static bool DoesHostAddressContainBothIPv4AndIPv6() diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AADFedAuthTokenRefreshTest/AADFedAuthTokenRefreshTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AADFedAuthTokenRefreshTest/AADFedAuthTokenRefreshTest.cs index 027acfde23..4f1bbf00c5 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AADFedAuthTokenRefreshTest/AADFedAuthTokenRefreshTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AADFedAuthTokenRefreshTest/AADFedAuthTokenRefreshTest.cs @@ -19,12 +19,12 @@ public AADFedAuthTokenRefreshTest(ITestOutputHelper testOutputHelper) _testOutputHelper = testOutputHelper; } - [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAADPasswordConnStrSetup))] + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAADPasswordConnStrSetup), nameof(DataTestUtility.IsUserManagedIdentitySetup))] public void FedAuthTokenRefreshTest() { - string connectionString = DataTestUtility.AADPasswordConnectionString; + string connectionString = DataTestUtility.GetManagedIdentityAuthConnectionString(); - using (SqlConnection connection = new SqlConnection(connectionString)) + using (SqlConnection connection = new(connectionString)) { connection.Open(); @@ -46,7 +46,7 @@ public void FedAuthTokenRefreshTest() Assert.True(result != string.Empty, "The connection's command must return a value"); // The new connection will use the same FedAuthToken but will refresh it first as it will expire in 1 minute. - using (SqlConnection connection2 = new SqlConnection(connectionString)) + using (SqlConnection connection2 = new(connectionString)) { connection2.Open(); diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectionPoolTest/ConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectionPoolTest/ConnectionPoolTest.cs index 10c3939774..e6e156d5de 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectionPoolTest/ConnectionPoolTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectionPoolTest/ConnectionPoolTest.cs @@ -78,10 +78,9 @@ public static void BasicConnectionPoolingTest(string connectionString) public static void AccessTokenConnectionPoolingTest() { SqlConnection.ClearAllPools(); - - // Remove cred info and add invalid token - string[] credKeys = { "User ID", "Password", "UID", "PWD", "Authentication" }; - string connectionString = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys); + + // Get connection string to connect to Azure SQL Database without credentials + string connectionString = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo(); using SqlConnection connection = new SqlConnection(connectionString); connection.AccessToken = DataTestUtility.GetAccessToken(); diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectivityTests/AADConnectionTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectivityTests/AADConnectionTest.cs index 34cdbb5155..f8258b0642 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectivityTests/AADConnectionTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectivityTests/AADConnectionTest.cs @@ -37,18 +37,10 @@ public override async Task AcquireTokenAsync(SqlAuthenti var cts = new CancellationTokenSource(); cts.CancelAfter(parameters.ConnectionTimeout * 1000); - string[] scopes = new string[] { scope }; - SecureString password = new SecureString(); - - #pragma warning disable CS0618 // Type or member is obsolete - AuthenticationResult result = await PublicClientApplicationBuilder.Create(_appClientId) - .WithAuthority(parameters.Authority) - .Build().AcquireTokenByUsernamePassword(scopes, parameters.UserId, parameters.Password) - .WithCorrelationId(parameters.ConnectionId) - .ExecuteAsync(cancellationToken: cts.Token); - #pragma warning restore CS0618 // Type or member is obsolete - - return new SqlAuthenticationToken(result.AccessToken, result.ExpiresOn); + string[] scopes = [scope]; + AccessToken result = await DataTestUtility.GetTokenCredential().GetTokenAsync(new TokenRequestContext(scopes), cts.Token).ConfigureAwait(false); + + return new SqlAuthenticationToken(result.Token, result.ExpiresOn); } public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) @@ -92,8 +84,7 @@ public static void KustoDatabaseTest() public static void AccessTokenTest() { // Remove cred info and add invalid token - string[] credKeys = { "User ID", "Password", "UID", "PWD", "Authentication" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys); + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo(); using (SqlConnection connection = new SqlConnection(connStr)) { @@ -108,8 +99,7 @@ public static void AccessTokenTest() public static void InvalidAccessTokenTest() { // Remove cred info and add invalid token - string[] credKeys = { "User ID", "Password", "UID", "PWD", "Authentication" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys); + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo(); using (SqlConnection connection = new SqlConnection(connStr)) { @@ -125,8 +115,7 @@ public static void InvalidAccessTokenTest() public static void AccessTokenWithAuthType() { // Remove cred info and add invalid token - string[] credKeys = { "User ID", "Password", "UID", "PWD" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys); + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Password;"; using (SqlConnection connection = new SqlConnection(connStr)) { @@ -159,8 +148,7 @@ public static void AccessTokenWithCred() public static void AccessTokenTestWithEmptyToken() { // Remove cred info and add invalid token - string[] credKeys = { "User ID", "Password", "UID", "PWD", "Authentication" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys); + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo(); using (SqlConnection connection = new SqlConnection(connStr)) { @@ -176,8 +164,7 @@ public static void AccessTokenTestWithEmptyToken() public static void AccessTokenTestWithIntegratedSecurityTrue() { // Remove cred info and add invalid token - string[] credKeys = { "User ID", "Password", "UID", "PWD", "Authentication" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + "Integrated Security=True;"; + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Integrated Security=True;"; using (SqlConnection connection = new SqlConnection(connStr)) { @@ -192,8 +179,7 @@ public static void AccessTokenTestWithIntegratedSecurityTrue() public static void InvalidAuthTypeTest() { // Remove cred info and add invalid token - string[] credKeys = { "Authentication" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + "Authentication=Active Directory Pass;"; + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Pass;"; ArgumentException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); @@ -224,63 +210,6 @@ public static void AADPasswordWithWrongPassword() // and error message may change accordingly. } - [ConditionalFact(nameof(IsAADConnStringsSetup))] - public static void GetAccessTokenByPasswordTest() - { - // Clear token cache for code coverage. - ActiveDirectoryAuthenticationProvider.ClearUserTokenCache(); - using (SqlConnection connection = new SqlConnection(DataTestUtility.AADPasswordConnectionString)) - { - connection.Open(); - Assert.True(connection.State == System.Data.ConnectionState.Open); - } - } - - [ConditionalFact(nameof(IsAADConnStringsSetup))] - public static void TestADPasswordAuthentication() - { - // Connect to Azure DB with password and retrieve user name. - using (SqlConnection conn = new SqlConnection(DataTestUtility.AADPasswordConnectionString)) - { - conn.Open(); - using (SqlCommand sqlCommand = new SqlCommand - ( - cmdText: $"SELECT SUSER_SNAME();", - connection: conn, - transaction: null - )) - { - string customerId = (string)sqlCommand.ExecuteScalar(); - string expected = DataTestUtility.RetrieveValueFromConnStr(DataTestUtility.AADPasswordConnectionString, new string[] { "User ID", "UID" }); - Assert.Equal(expected, customerId); - } - } - } - - [ConditionalFact(nameof(IsAADConnStringsSetup))] - public static void TestCustomProviderAuthentication() - { - SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, new CustomSqlAuthenticationProvider(DataTestUtility.ApplicationClientId)); - // Connect to Azure DB with password and retrieve user name using custom authentication provider - using (SqlConnection conn = new SqlConnection(DataTestUtility.AADPasswordConnectionString)) - { - conn.Open(); - using (SqlCommand sqlCommand = new SqlCommand - ( - cmdText: $"SELECT SUSER_SNAME();", - connection: conn, - transaction: null - )) - { - string customerId = (string)sqlCommand.ExecuteScalar(); - string expected = DataTestUtility.RetrieveValueFromConnStr(DataTestUtility.AADPasswordConnectionString, new string[] { "User ID", "UID" }); - Assert.Equal(expected, customerId); - } - } - // Reset to driver internal provider. - SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, new ActiveDirectoryAuthenticationProvider(DataTestUtility.ApplicationClientId)); - } - [ConditionalFact(nameof(IsAADConnStringsSetup))] public static void ActiveDirectoryPasswordWithNoAuthType() { @@ -370,9 +299,6 @@ public static void AADPasswordWithInvalidUser() [ConditionalFact(nameof(IsAADConnStringsSetup))] public static void NoCredentialsActiveDirectoryPassword() { - // test Passes with correct connection string. - ConnectAndDisconnect(DataTestUtility.AADPasswordConnectionString); - // connection fails with expected error message. string[] credKeys = { "User ID", "Password", "UID", "PWD" }; string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys); @@ -385,15 +311,11 @@ public static void NoCredentialsActiveDirectoryPassword() [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAADServicePrincipalSetup))] public static void NoCredentialsActiveDirectoryServicePrincipal() { - // test Passes with correct connection string. - string[] removeKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, removeKeys) + + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + $"Authentication=Active Directory Service Principal; User ID={DataTestUtility.AADServicePrincipalId}; PWD={DataTestUtility.AADServicePrincipalSecret};"; - ConnectAndDisconnect(connStr); // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Service Principal;"; InvalidOperationException e = Assert.Throws(() => ConnectAndDisconnect(connStrWithNoCred)); @@ -405,8 +327,7 @@ public static void NoCredentialsActiveDirectoryServicePrincipal() public static void ActiveDirectoryDeviceCodeFlowWithUserIdMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithUID = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithUID = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Device Code Flow; UID=someuser;"; ArgumentException e = Assert.Throws(() => ConnectAndDisconnect(connStrWithUID)); @@ -418,8 +339,7 @@ public static void ActiveDirectoryDeviceCodeFlowWithUserIdMustFail() public static void ActiveDirectoryDeviceCodeFlowWithCredentialsMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Device Code Flow;"; SecureString str = new SecureString(); @@ -439,8 +359,7 @@ public static void ActiveDirectoryDeviceCodeFlowWithCredentialsMustFail() public static void ActiveDirectoryManagedIdentityWithCredentialsMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Managed Identity;"; SecureString str = new SecureString(); @@ -460,8 +379,7 @@ public static void ActiveDirectoryManagedIdentityWithCredentialsMustFail() public static void ActiveDirectoryWorkloadIdentityWithCredentialsMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Workload Identity;"; SecureString str = new SecureString(); @@ -481,8 +399,7 @@ public static void ActiveDirectoryWorkloadIdentityWithCredentialsMustFail() public static void ActiveDirectoryManagedIdentityWithPasswordMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Managed Identity; Password=anything"; ArgumentException e = Assert.Throws(() => ConnectAndDisconnect(connStrWithNoCred)); @@ -497,8 +414,7 @@ public static void ActiveDirectoryManagedIdentityWithPasswordMustFail() public static void ActiveDirectoryManagedIdentityWithInvalidUserIdMustFail(string userId) { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + $"Authentication=Active Directory Managed Identity; User Id={userId}"; SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStrWithNoCred)); @@ -511,8 +427,7 @@ public static void ActiveDirectoryManagedIdentityWithInvalidUserIdMustFail(strin public static void ActiveDirectoryMSIWithCredentialsMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory MSI;"; SecureString str = new SecureString(); @@ -532,8 +447,7 @@ public static void ActiveDirectoryMSIWithCredentialsMustFail() public static void ActiveDirectoryMSIWithPasswordMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=ActiveDirectoryMSI; Password=anything"; ArgumentException e = Assert.Throws(() => ConnectAndDisconnect(connStrWithNoCred)); @@ -546,8 +460,7 @@ public static void ActiveDirectoryMSIWithPasswordMustFail() public static void ActiveDirectoryDefaultWithCredentialsMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=Active Directory Default;"; SecureString str = new SecureString(); @@ -567,8 +480,7 @@ public static void ActiveDirectoryDefaultWithCredentialsMustFail() public static void ActiveDirectoryDefaultWithPasswordMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=ActiveDirectoryDefault; Password=anything"; ArgumentException e = Assert.Throws(() => ConnectAndDisconnect(connStrWithNoCred)); @@ -581,8 +493,7 @@ public static void ActiveDirectoryDefaultWithPasswordMustFail() public static void ActiveDirectoryDefaultWithAccessTokenCallbackMustFail() { // connection fails with expected error message. - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStrWithNoCred = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + "Authentication=ActiveDirectoryDefault"; InvalidOperationException e = Assert.Throws(() => { @@ -603,8 +514,7 @@ public static void ActiveDirectoryDefaultWithAccessTokenCallbackMustFail() [ConditionalFact(nameof(IsAADConnStringsSetup))] public static void AccessTokenCallbackMustOpenPassAndChangePropertyFail() { - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys); + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo(); var cred = DataTestUtility.GetTokenCredential(); const string defaultScopeSuffix = "/.default"; using (SqlConnection conn = new SqlConnection(connStr)) @@ -629,8 +539,7 @@ public static void AccessTokenCallbackReceivesUsernameAndPassword() { var userId = "someuser"; var pwd = "somepassword"; - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + $"User ID={userId}; Password={pwd}"; var cred = DataTestUtility.GetTokenCredential(); const string defaultScopeSuffix = "/.default"; @@ -651,8 +560,7 @@ public static void AccessTokenCallbackReceivesUsernameAndPassword() [ConditionalFact(nameof(IsAADConnStringsSetup), nameof(IsManagedIdentitySetup))] public static void ActiveDirectoryDefaultMustPass() { - string[] credKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, credKeys) + + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + $"Authentication=ActiveDirectoryDefault;User ID={DataTestUtility.UserManagedIdentityClientId};"; // Connection should be established using Managed Identity by default. @@ -675,10 +583,10 @@ public static void ADIntegratedUsingSSPI() [ConditionalFact(nameof(IsAADConnStringsSetup))] public static void ConnectionSpeed() { - var connString = DataTestUtility.AADPasswordConnectionString; + var connString = DataTestUtility.GetManagedIdentityAuthConnectionString(); //Ensure server endpoints are warm - using (var connectionDrill = new SqlConnection(connString)) + using (SqlConnection connectionDrill = new(connString)) { connectionDrill.Open(); } @@ -686,15 +594,15 @@ public static void ConnectionSpeed() SqlConnection.ClearAllPools(); ActiveDirectoryAuthenticationProvider.ClearUserTokenCache(); - Stopwatch firstConnectionTime = new Stopwatch(); - Stopwatch secondConnectionTime = new Stopwatch(); + Stopwatch firstConnectionTime = new(); + Stopwatch secondConnectionTime = new(); - using (var connectionDrill = new SqlConnection(connString)) + using (SqlConnection connectionDrill = new(connString)) { firstConnectionTime.Start(); connectionDrill.Open(); firstConnectionTime.Stop(); - using (var connectionDrill2 = new SqlConnection(connString)) + using (SqlConnection connectionDrill2 = new(connString)) { secondConnectionTime.Start(); connectionDrill2.Open(); @@ -712,8 +620,7 @@ public static void ConnectionSpeed() [ConditionalFact(nameof(IsAADConnStringsSetup), nameof(IsManagedIdentitySetup), nameof(SupportsSystemAssignedManagedIdentity))] public static void SystemAssigned_ManagedIdentityTest() { - string[] removeKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, removeKeys) + + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + $"Authentication=Active Directory Managed Identity;"; ConnectAndDisconnect(connStr); } @@ -721,8 +628,7 @@ public static void SystemAssigned_ManagedIdentityTest() [ConditionalFact(nameof(IsAADConnStringsSetup), nameof(IsManagedIdentitySetup))] public static void UserAssigned_ManagedIdentityTest() { - string[] removeKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connStr = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, removeKeys) + + string connStr = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo() + $"Authentication=Active Directory Managed Identity; User Id={DataTestUtility.UserManagedIdentityClientId};"; ConnectAndDisconnect(connStr); } @@ -730,8 +636,7 @@ public static void UserAssigned_ManagedIdentityTest() [ConditionalFact(nameof(IsAADConnStringsSetup), nameof(IsManagedIdentitySetup), nameof(SupportsSystemAssignedManagedIdentity))] public static void AccessToken_SystemManagedIdentityTest() { - string[] removeKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connectionString = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, removeKeys); + string connectionString = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo(); using (SqlConnection conn = new SqlConnection(connectionString)) { conn.AccessToken = DataTestUtility.GetSystemIdentityAccessToken(); @@ -744,8 +649,7 @@ public static void AccessToken_SystemManagedIdentityTest() [ConditionalFact(nameof(IsAADConnStringsSetup), nameof(IsManagedIdentitySetup))] public static void AccessToken_UserManagedIdentityTest() { - string[] removeKeys = { "Authentication", "User ID", "Password", "UID", "PWD" }; - string connectionString = DataTestUtility.RemoveKeysInConnStr(DataTestUtility.AADPasswordConnectionString, removeKeys); + string connectionString = DataTestUtility.GetAzureConnectionStringWithoutAuthInfo(); using (SqlConnection conn = new SqlConnection(connectionString)) { conn.AccessToken = DataTestUtility.GetUserIdentityAccessToken(); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderCollection.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderCollection.cs new file mode 100644 index 0000000000..fe51e2587e --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderCollection.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.Microsoft.Data.SqlClient +{ + /// + /// Defines a test collection that serializes execution of test classes + /// which mutate the global registry. + /// + [CollectionDefinition("SqlAuthenticationProvider")] + public class SqlAuthenticationProviderCollection + { + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerTests.cs new file mode 100644 index 0000000000..d9e1d5e3f8 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerTests.cs @@ -0,0 +1,280 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.Microsoft.Data.SqlClient +{ + [Collection("SqlAuthenticationProvider")] + public class WamBrokerTests + { + // The SqlClient first-party application client id that is hard-coded in the provider. + private const string SqlClientApplicationId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; + + // A fixed, deterministic stand-in for a caller-supplied application id. Hard-coded (instead + // of Guid.NewGuid()) so test outcomes don't depend on RNG and so a single point asserts + // that this value differs from the SqlClient first-party id. + private const string TestCustomAppId = "11111111-2222-3333-4444-555555555555"; + + // Reads the private _parentActivityOrWindowFunc field. Used to assert downstream effects + // of SetParentActivityOrWindowFunc without triggering a live MSAL flow. + private static Func GetParentActivityOrWindowFunc(ActiveDirectoryAuthenticationProvider provider) + { + FieldInfo field = typeof(ActiveDirectoryAuthenticationProvider).GetField( + "_parentActivityOrWindowFunc", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + return (Func)field.GetValue(provider); + } + + /// + /// A callback is treated as "clear any previously installed callback" + /// and must not throw. + /// + [Fact] + public void SetParentActivityOrWindowFunc_Null_ClearsCallback() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + Func first = () => IntPtr.Zero; + provider.SetParentActivityOrWindowFunc(first); + Assert.Same(first, GetParentActivityOrWindowFunc(provider)); + + provider.SetParentActivityOrWindowFunc(null); + Assert.Null(GetParentActivityOrWindowFunc(provider)); + } + + /// + /// The single-string constructor with the SqlClient first-party application id always + /// enables WAM broker mode. + /// + [Fact] + public void Ctor_ApplicationClientId_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider(SqlClientApplicationId); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "Constructor with SqlClient first-party application id must enable WAM broker."); + } + + /// + /// The parameterless constructor uses the SqlClient first-party application id, which always + /// enables WAM broker mode regardless of any opt-in flag. + /// + [Fact] + public void Ctor_Default_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "Default ctor must enable WAM broker (uses SqlClient first-party application id)."); + } + + /// A caller-supplied application id without explicit opt-in must NOT enable WAM broker. + [Fact] + public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() + { + var provider = new ActiveDirectoryAuthenticationProvider(TestCustomAppId); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.False(provider.UseWamBroker, + "Custom application id without useWamBroker=true must keep WAM broker disabled."); + } + + /// + /// The Options constructor with only ApplicationClientId set defaults UseWamBroker to false. + /// + [Fact] + public void Ctor_Options_AppClientIdOnly_DefaultsUseWamBrokerToFalse() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.False(provider.UseWamBroker, + "Options ctor with ApplicationClientId set and UseWamBroker omitted must keep WAM broker disabled."); + } + + /// A caller-supplied application id with explicit opt-in must enable WAM broker. + [Fact] + public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + UseWamBroker = true, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "Custom application id with UseWamBroker=true must enable WAM broker."); + } + + /// A caller-supplied application id with explicit opt-out keeps WAM broker disabled. + [Fact] + public void Ctor_AppClientId_UseWamBrokerFalse_DisablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + UseWamBroker = false, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.False(provider.UseWamBroker); + } + + /// + /// Even when the SqlClient first-party application id is passed explicitly with + /// UseWamBroker=false, WAM broker mode must remain enabled because the first-party + /// app id is hard-wired to the WAM broker redirect URI. + /// + [Fact] + public void Ctor_SqlClientAppIdExplicit_UseWamBrokerFalse_StillEnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = SqlClientApplicationId, + UseWamBroker = false, + }); + + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "SqlClient first-party application id must always enable WAM broker, regardless of the UseWamBroker option."); + } + + [Fact] + public void Ctor_WithDeviceCodeCallback_UseWamBrokerTrue_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + DeviceCodeFlowCallback = _ => Task.CompletedTask, + ApplicationClientId = TestCustomAppId, + UseWamBroker = true, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker); + } + + /// + /// The two-arg device-code constructor (deviceCodeCallback, applicationClientId) must default + /// useWamBroker to false for caller-supplied application ids. + /// + [Fact] + public void Ctor_WithDeviceCodeCallback_AppClientIdOnly_DefaultsUseWamBrokerToFalse() + { + var provider = new ActiveDirectoryAuthenticationProvider( + deviceCodeFlowCallbackMethod: _ => Task.CompletedTask, + applicationClientId: TestCustomAppId); + + Assert.False(provider.UseWamBroker); + Assert.NotEqual(SqlClientApplicationId, provider.ApplicationClientId); + } + + /// + /// When the device-code callback constructor is invoked without an application id, the + /// provider falls back to the SqlClient first-party id and must enable WAM broker. + /// + [Fact] + public void Ctor_WithDeviceCodeCallback_NoAppClientId_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + deviceCodeFlowCallbackMethod: _ => Task.CompletedTask); + + Assert.True(provider.UseWamBroker); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + } + + [Fact] + public void Ctor_Options_CustomAppId_UseWamBrokerTrue_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + UseWamBroker = true, + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker); + } + + /// + /// Options with ApplicationClientId = null falls back to the SqlClient first-party + /// id, which always enables WAM broker, regardless of UseWamBroker. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Ctor_Options_NullAppId_AlwaysEnablesWamBroker(bool useWamBroker) + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = null, + UseWamBroker = useWamBroker, + }); + + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker); + } + + /// + /// The Options-based constructor must reject a null options instance with + /// ArgumentNullException so misuse fails fast at construction. + /// + [Fact] + public void Ctor_Options_Null_ThrowsArgumentNullException() + { + Assert.Throws( + () => new ActiveDirectoryAuthenticationProvider((ActiveDirectoryAuthenticationProviderOptions)null)); + } + + /// + /// Registering an instance via SqlAuthenticationProvider.SetProvider must not + /// wrap or replace the instance, so its WAM broker setting survives registration. + /// + [Fact] + public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProviderOptions + { + ApplicationClientId = TestCustomAppId, + UseWamBroker = true, + }); + + SqlAuthenticationProvider original = + SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive); + try + { + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + + var retrieved = SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive) + as ActiveDirectoryAuthenticationProvider; + Assert.NotNull(retrieved); + Assert.Same(provider, retrieved); + Assert.Equal(TestCustomAppId, retrieved.ApplicationClientId); + Assert.True(retrieved.UseWamBroker); + } + finally + { + if (original != null) + { + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, original); + } + } + } + } +} diff --git a/tools/specs/Microsoft.Data.SqlClient.nuspec b/tools/specs/Microsoft.Data.SqlClient.nuspec index cc16fac94f..4abd669e03 100644 --- a/tools/specs/Microsoft.Data.SqlClient.nuspec +++ b/tools/specs/Microsoft.Data.SqlClient.nuspec @@ -33,7 +33,8 @@ - + + @@ -50,7 +51,8 @@ - + + @@ -64,7 +66,8 @@ - + + @@ -78,7 +81,8 @@ - + +