diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 68154682b..34f7a5b6e 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -31,13 +31,6 @@ parameters: - name: windows_matrix type: object default: - - id: windows_x86 - jobName: 'Windows (x86)' - runtime: win-x86 - pool: GitClientPME-1ESHostedPool-intel-pc - poolArch: amd64 - image: win-x86_64-ado1es - os: windows - id: windows_x64 jobName: 'Windows (x64)' runtime: win-x64 @@ -609,7 +602,7 @@ extends: inputs: targetType: inline script: | - echo "##vso[task.setvariable variable=value;isOutput=true;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" + echo "##vso[task.setvariable variable=value;isOutput=true;isReadOnly=true]$(cat ./VERSION)" - job: github displayName: 'Publish GitHub release' @@ -626,9 +619,6 @@ extends: isProduction: true inputs: # Installers and packages - - input: pipelineArtifact - artifactName: 'win-x86' - targetPath: $(Pipeline.Workspace)/assets/win-x86 - input: pipelineArtifact artifactName: 'win-x64' targetPath: $(Pipeline.Workspace)/assets/win-x64 @@ -664,8 +654,6 @@ extends: isDraft: true addChangeLog: false assets: | - $(Pipeline.Workspace)/assets/win-x86/*.exe - $(Pipeline.Workspace)/assets/win-x86/*.zip $(Pipeline.Workspace)/assets/win-x64/*.exe $(Pipeline.Workspace)/assets/win-x64/*.zip $(Pipeline.Workspace)/assets/win-arm64/*.exe diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index e6a589997..b640e6f73 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - runtime: [ win-x86, win-x64, win-arm64 ] + runtime: [ win-x64, win-arm64 ] steps: - uses: actions/checkout@v7 @@ -27,15 +27,6 @@ jobs: with: dotnet-version: 10.0.x - # The x86 test host requires an x86 .NET runtime, which isn't pre-installed - # on the runner, nor can the actions/setup-dotnet action install it. - # Install it manually so tests can run. - - name: Setup .NET (x86) - if: matrix.runtime == 'win-x86' - run: | - Invoke-WebRequest 'https://dot.net/v1/dotnet-install.ps1' -OutFile dotnet-install.ps1 - ./dotnet-install.ps1 -Channel 10.0 -Architecture x86 -InstallDir 'C:\Program Files (x86)\dotnet' - - name: Install dependencies run: dotnet restore diff --git a/README.md b/README.md index 92ab1c7f9..6ad8b4454 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ You can still use Git with SSH - see the specific documentation for your host on how to set up SSH: [Azure DevOps][azure-devops-ssh], [GitHub][github-ssh], [Bitbucket][bitbucket-ssh] -Feature|Windows|macOS|Linux +Feature|Windows|macOS|Linux¹ -|:-:|:-:|:-: Installer/uninstaller|✓|✓|✓ Secure platform [credential storage][gcm-credstores]|✓|✓|✓ @@ -47,10 +47,20 @@ Windows Integrated Authentication (NTLM/Kerberos)|✓|_N/A_|_N/A_ Generic OAuth authentication|✓|✓|✓ Basic HTTP authentication|✓|✓|✓ Network proxies|✓|✓|✓ -`amd64` support|✓|✓|✓ -`x86` support|✓|_N/A_|✗ -`arm64` support|best effort|✓|✓ -`armhf` support|_N/A_|_N/A_|✓ +x64 _(x86_64 / AMD64)_ support|✓|✓²|✓ +x86 _(i686)_ support|✓³|_N/A_|✗ +ARM64 _(aarch64)_ support|✓|✓|✓ +ARM32 _(armhf)_ support|_N/A_|_N/A_|✓³ + +1. Linux support is limited to the + [distributions officially supported by .NET][dotnet-os-support]. + +2. Mac support on Intel-based (x64) devices is best effort. + Apple announced that macOS 28 will [drop support][apple-intel-support] for + Intel-based applications. + +3. No official builds produced, but local builds should be possible. + Support is best effort. ## Supported Environments @@ -183,3 +193,4 @@ When using GitHub logos, please be sure to follow the [roadmap]: https://github.com/git-ecosystem/git-credential-manager/milestones?direction=desc&sort=due_date&state=open [roadmap-announcement]: https://github.com/git-ecosystem/git-credential-manager/discussions/1203 [workflow-status]: https://github.com/git-ecosystem/git-credential-manager/actions/workflows/continuous-integration.yml +[apple-intel-support]: https://support.apple.com/en-us/102527 diff --git a/VERSION b/VERSION index 4a36342fc..cb2b00e4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0 +3.0.1 diff --git a/docs/environment.md b/docs/environment.md index 424867a68..c6d258f52 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -173,6 +173,46 @@ Defaults to enabled. --- +### GCM_MODAL_PARENTHWND + +Specify the parent window handle (`HWND`) for modal dialogs on Windows. + +When this variable is unset, GCM tries to parent authentication dialogs to the +console's visible root-owner window. Hidden or invalid windows are ignored. + +Explicitly setting a handle using this variable takes precedence and it is not +checked for visibility. + +> [!NOTE] +> This is intended for use by application developers who are calling Git and/or +> GCM from their application and wish to correctly parent authentication dialogs +> to an existing window. +> +> Do **not** set this manually. + +#### Example + +##### Windows + +```csharp +public static int Example(IntPtr myWindowHandle) +{ + using var process = new Process(); + process.StartInfo = new ProcessStartInfo("git.exe", "pull") + { + Environment = + { + ["GCM_MODAL_PARENTHWND"] = myWindowHandle.ToString("d") + } + }; + process.Start(); + process.WaitForExit(); + return process.ExitCode; +} +``` + +--- + ### GCM_PROVIDER Define the host provider to use when authenticating. diff --git a/docs/wsl.md b/docs/wsl.md index cbcd6b384..dd6c80d3e 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -21,20 +21,46 @@ Start by installing the [latest Git for Windows ⬇️][latest-git-for-windows] _Inside your WSL installation_, run the following command to set GCM as the Git credential helper: +> [!IMPORTANT] +> The exact location of git-credential-manager.exe may be different in your +> installation of Git for Windows depending on the version you have installed. +> Please check and confirm the actual installation path before proceeding. + +### Git for Windows (x64 / x86_64 / "64-bit") + +The latest versions of Git for Windows for x64 (v2.56.0 and later) use the +`ucrt64` toolchain and installation path: + +```shell +git config --global credential.helper "/mnt/c/Program\ Files/Git/ucrt64/bin/git-credential-manager.exe" +``` + +Older versions of Git for Windows for x64 (v2.55.x and earlier) use the +`mingw64` toolchain and installation path: + ```shell git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe" ``` -> **Note:** the location of git-credential-manager.exe may be different in your -installation of Git for Windows. +### Git for Windows (ARM64) -If you intend to use Azure DevOps you must _also_ set the following Git -configuration _inside of your WSL installation_. +All known versions of Git for Windows for ARM64 use the `clangarm64` toolchain +and installation path: ```shell -git config --global credential.https://dev.azure.com.useHttpPath true +git config --global credential.helper "/mnt/c/Program\ Files/Git/clangarm64/bin/git-credential-manager.exe" ``` +## Azure DevOps Additional Configuration + +> [!IMPORTANT] +> If you intend to use Azure DevOps you must _also_ set the following Git +> configuration _inside of your WSL installation_. +> +> ```shell +> git config --global credential.https://dev.azure.com.useHttpPath true +> ``` + ## Configuring WSL without Git for Windows If you wish to use GCM inside of WSL _without installing Git for Windows_ @@ -47,21 +73,27 @@ _Inside your WSL installation_, run the following command to set GCM as the Git credential helper: ```shell +# For x64 or ARM64 installations of Git Credential Manager +git config --global credential.helper "/mnt/c/Program\ Files/Git\ Credential\ Manager/git-credential-manager.exe" + +# For x86 installations of Git Credential Manager git config --global credential.helper "/mnt/c/Program\ Files\ \(x86\)/Git\ Credential\ Manager/git-credential-manager.exe" # For Azure DevOps support only git config --global credential.https://dev.azure.com.useHttpPath true ``` -In **_Windows_** you need to update the `WSLENV` environment variable to include -the value `GIT_EXEC_PATH/wp`. From an _Administrator_ Command Prompt run the -following: - -```batch -SETX WSLENV %WSLENV%:GIT_EXEC_PATH/wp -``` - -After updating the `WSLENV` environment variable, restart your WSL installation. +> [!IMPORTANT] +> In **_Windows_** you need to update the `WSLENV` environment variable to +> include the value `GIT_EXEC_PATH/wp`. From an _Administrator_ Command Prompt +> run the following: +> +> ```batch +> SETX WSLENV %WSLENV%:GIT_EXEC_PATH/wp +> ``` +> +> After updating the `WSLENV` environment variable, **restart your WSL +> installation**. ### Using the user-only GCM installer? diff --git a/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs b/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs new file mode 100644 index 000000000..b60a70558 --- /dev/null +++ b/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using GitCredentialManager.Authentication.Entra; +using Xunit; + +namespace GitCredentialManager.Tests.Authentication.Entra; + +public class MsalParentWindowAdapterTests +{ + [Theory] + [InlineData(42, false)] + [InlineData(42, true)] + public void GetWindow_ExplicitParent_ReturnsParent(int parentValue, bool createIfMissing) + { + object parentWindow = new IntPtr(parentValue); + using var adapter = new MsalParentWindowAdapter(parentWindow, createIfMissing, + _ => throw new InvalidOperationException("A progress window should not be created.")); + + object result = adapter.GetWindow(); + Assert.Same(parentWindow, result); + } + + [Fact] + public void GetWindow_NoParent_Required_CreatesProgressWindow() + { + var progressWindow = new IntPtr(42); + var windowToken = CancellationToken.None; + using (var adapter = new MsalParentWindowAdapter(IntPtr.Zero, true, + ct => + { + windowToken = ct; + return progressWindow; + })) + { + IntPtr result = Assert.IsType(adapter.GetWindow()); + + Assert.Equal(progressWindow, result); + Assert.True(windowToken.CanBeCanceled); + Assert.False(windowToken.IsCancellationRequested); + } + + Assert.True(windowToken.IsCancellationRequested); + } + + [Fact] + public void GetWindow_NoParent_NotRequired_ReturnsNull() + { + using var adapter = new MsalParentWindowAdapter(IntPtr.Zero, false, + _ => throw new InvalidOperationException("A progress window should not be created.")); + + object result = adapter.GetWindow(); + + Assert.Null(result); + } +} diff --git a/src/Core.Tests/Commands/CapabilityCommandTests.cs b/src/Core.Tests/Commands/CapabilityCommandTests.cs index 9a90eb58b..3b2ef862c 100644 --- a/src/Core.Tests/Commands/CapabilityCommandTests.cs +++ b/src/Core.Tests/Commands/CapabilityCommandTests.cs @@ -22,7 +22,7 @@ public void CapabilityCommand_Execute_WritesVersionAndAdvertisedCapabilities() Assert.StartsWith("version 0\n", actualOutput); // GCM advertises the state capability. - Assert.Equal("version 0\ncapability state\n", actualOutput); + Assert.Equal("version 0\ncapability state\ncapability authtype\n", actualOutput); } [Fact] diff --git a/src/Core.Tests/Commands/GetCommandTests.cs b/src/Core.Tests/Commands/GetCommandTests.cs index 3878e5b40..5993487a4 100644 --- a/src/Core.Tests/Commands/GetCommandTests.cs +++ b/src/Core.Tests/Commands/GetCommandTests.cs @@ -122,7 +122,7 @@ public async Task GetCommand_ExecuteAsync_NegotiatedCapability_EchoesIntersectio string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); Assert.Contains("capability[]=state\n", actualOutput); - Assert.DoesNotContain("capability[]=authtype", actualOutput); + Assert.Contains("capability[]=authtype\n", actualOutput); } [Fact] @@ -328,6 +328,62 @@ public async Task GetCommand_ExecuteAsync_OutputOrdering_CapabilitiesFirstThenSc "state[] must follow continue=1"); } + [Fact] + public async Task GetCommand_ExecuteAsync_CapabilityAuthType_MissingRevertsToPlainPassword() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential, isEphemeral: true, authtype: "token"); + + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string[] actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n").Split('\n'); + + // Emits regular Credential without 'state[]=authtype' support. + Assert.Contains("username=alice", actualOutput); + Assert.Contains("password=hunter2", actualOutput); + Assert.DoesNotContain("authtype=token", actualOutput); + Assert.DoesNotContain("credential=hunter2", actualOutput); + Assert.DoesNotContain("ephemeral=1", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_CapabilityAuthType_UsesAuthTypeFeatures() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential, isEphemeral: true, authtype: "token"); + + var stdin = "protocol=https\nhost=example.com\ncapability[]=authtype\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string[] actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n").Split('\n'); + + // Ephemeral credential with 'authtype' and 'credential' value + Assert.DoesNotContain("username=alice", actualOutput); + Assert.DoesNotContain("password=hunter2", actualOutput); + Assert.Contains("authtype=token", actualOutput); + Assert.Contains("credential=hunter2", actualOutput); + Assert.Contains("ephemeral=1", actualOutput); + } + #region Helpers private static IDictionary ParseDictionary(StringBuilder sb) => ParseDictionary(sb.ToString()); diff --git a/src/Core/Authentication/AuthenticationBase.cs b/src/Core/Authentication/AuthenticationBase.cs index fb177d112..5bfc1a9d0 100644 --- a/src/Core/Authentication/AuthenticationBase.cs +++ b/src/Core/Authentication/AuthenticationBase.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using GitCredentialManager.Tty; using GitCredentialManager.UI.ViewModels; namespace GitCredentialManager.Authentication @@ -125,11 +124,19 @@ protected void ThrowIfWindowCancelled(WindowViewModel viewModel) protected IntPtr GetParentWindowHandle() { + // Check if the user has specified a parent window ID if (int.TryParse(Context.Settings.ParentWindowId, out int id)) { return new IntPtr(id); } + // Check if we can use the console window as a parent + IntPtr consoleParent = PlatformUtils.GetConsoleParentWindow(); + if (consoleParent != IntPtr.Zero) + { + return consoleParent; + } + return IntPtr.Zero; } diff --git a/src/Core/Authentication/Entra/MsalAdaptors.cs b/src/Core/Authentication/Entra/MsalAdaptors.cs index 33997fc5e..2f335649c 100644 --- a/src/Core/Authentication/Entra/MsalAdaptors.cs +++ b/src/Core/Authentication/Entra/MsalAdaptors.cs @@ -1,7 +1,6 @@ using System; using System.Net.Http; using System.Threading; -using GitCredentialManager.Interop.Windows.Native; using GitCredentialManager.UI.Controls; using Microsoft.Identity.Client; @@ -31,40 +30,35 @@ internal class MsalParentWindowAdapter : IDisposable private readonly bool _createIfMissing; private readonly CancellationTokenSource _cts = new(); + private readonly Func _createWindow; + public static MsalParentWindowAdapter Create(object parentWindow, bool createIfMissing = false) { - return new MsalParentWindowAdapter(parentWindow, createIfMissing); + return new MsalParentWindowAdapter(parentWindow, createIfMissing, ProgressWindow.ShowAndGetHandle); } - private MsalParentWindowAdapter(object parentWindow, bool createIfMissing = false) + internal MsalParentWindowAdapter(object parentWindow, bool createIfMissing, + Func createWindow) { + EnsureArgument.NotNull(createWindow, nameof(createWindow)); + _parentWindow = parentWindow; _createIfMissing = createIfMissing; + _createWindow = createWindow; } public object GetWindow() { + // Try to use the parent window we were given (if any) if (_parentWindow is IntPtr p && p != IntPtr.Zero) { return _parentWindow; } - // Create a stub window to use as a parent + // Create a stub window to use as a parent instead (if the caller requires it) if (_createIfMissing) { - return ProgressWindow.ShowAndGetHandle(_cts.Token); - } - - // On Windows we can try and get the console window parent handle if that exists - if (PlatformUtils.IsWindows()) - { - IntPtr consoleHandle = Kernel32.GetConsoleWindow(); - IntPtr parentHandle = User32.GetAncestor(consoleHandle, GetAncestorFlags.GetRootOwner); - - if (parentHandle != IntPtr.Zero) - { - return parentHandle; - } + return _createWindow(_cts.Token); } return null; diff --git a/src/Core/Commands/GetCommand.cs b/src/Core/Commands/GetCommand.cs index 551e1feac..6ac5a0756 100644 --- a/src/Core/Commands/GetCommand.cs +++ b/src/Core/Commands/GetCommand.cs @@ -59,6 +59,7 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv // Negotiate capabilities by intersecting what Git advertised with what GCM supports. // Capability-gated output fields may only be emitted for capabilities in this set. GitCapabilities negotiated = request.Capabilities & Constants.SupportedCapabilities; + bool authTypeCapNegotiated = (negotiated & GitCapabilities.AuthType) != 0; bool stateCapNegotiated = (negotiated & GitCapabilities.State) != 0; Context.Trace.WriteLine($"Git capability: {request.Capabilities}"); @@ -98,10 +99,26 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv // // Credential // - stdout.WriteLine($"username={credential.Account}"); - Context.Trace.WriteLine($"\tusername={credential.Account}"); - stdout.WriteLine($"password={credential.Password}"); - Context.Trace.WriteLineSecrets("\tpassword={0}", new object[] { credential.Password }); + var authtype = response.AuthType; + if (authTypeCapNegotiated && authtype is not null) + { + stdout.WriteLine($"{Constants.CredentialProtocol.AuthTypeKey}={authtype}"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.AuthTypeKey}={authtype}"); + stdout.WriteLine($"{Constants.CredentialProtocol.CredentialKey}={credential.Password}"); + Context.Trace.WriteLineSecrets("\t" + Constants.CredentialProtocol.CredentialKey + "={0}", [credential.Password]); + } + else + { + stdout.WriteLine($"{Constants.CredentialProtocol.UserNameKey}={credential.Account}"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.UserNameKey}={credential.Account}"); + stdout.WriteLine($"{Constants.CredentialProtocol.PasswordKey}={credential.Password}"); + Context.Trace.WriteLineSecrets("\t" + Constants.CredentialProtocol.PasswordKey + "={0}", [ credential.Password ]); + } + if (authTypeCapNegotiated && response.IsCredentialEphemeral) + { + stdout.WriteLine($"{Constants.CredentialProtocol.EphemeralKey}=1"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.EphemeralKey}=1"); + } // // Custom additional properties diff --git a/src/Core/Commands/GitCommandBase.cs b/src/Core/Commands/GitCommandBase.cs index 1dfc398a9..fcb0a0d79 100644 --- a/src/Core/Commands/GitCommandBase.cs +++ b/src/Core/Commands/GitCommandBase.cs @@ -51,7 +51,7 @@ internal async Task ExecuteAsync() // Determine the host provider Context.Trace.WriteLine("Detecting host provider for request:"); - Context.Trace.WriteDictionarySecrets(inputDict, new []{ "password" }, StringComparer.OrdinalIgnoreCase); + Context.Trace.WriteDictionarySecrets(inputDict, [ "password", "credential" ], StringComparer.OrdinalIgnoreCase); IHostProvider provider; using (Trace2.StartRegion("git_cmd", "resolve_provider")) { diff --git a/src/Core/Commands/StoreCommand.cs b/src/Core/Commands/StoreCommand.cs index 09043ff74..dcc100c98 100644 --- a/src/Core/Commands/StoreCommand.cs +++ b/src/Core/Commands/StoreCommand.cs @@ -24,6 +24,16 @@ protected override void EnsureMinimumRequest(GitRequest request) { base.EnsureMinimumRequest(request); + // When "authtype" is set, Git uses "credential" field for the secret + if (request.AuthType is not null) + { + if (request.Credential is null) + { + throw new InvalidOperationException("Missing 'credential' request argument"); + } + return; + } + // An empty string username/password are valid inputs, so only check for `null` (not provided) if (request.UserName is null) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cff7a1a1b..f2409b0ab 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -36,7 +36,7 @@ public static class Constants /// /// The set of Git credential protocol capabilities supported by Git Credential Manager. /// - public const GitCapabilities SupportedCapabilities = GitCapabilities.State; + public const GitCapabilities SupportedCapabilities = GitCapabilities.State | GitCapabilities.AuthType; public static class CredentialProtocol { @@ -57,6 +57,36 @@ public static class CredentialProtocol /// public const string ContinueKey = "continue"; + /// + /// The Git credential protocol attribute name carrying alternative + /// HTTP Authorization scheme type. + /// (string, gated by the authtype capability). + /// + public const string AuthTypeKey = "authtype"; + + /// + /// The Git credential protocol attribute name carrying raw credential data. + /// (string, gated by the authtype capability). + /// + public const string CredentialKey = "credential"; + + /// + /// The Git credential protocol attribute name carrying setting + /// for ephemeral credential type. + /// (string, gated by the authtype capability). + /// + public const string EphemeralKey = "ephemeral"; + + /// + /// The Git credential protocol attribute name for password. + /// + public const string PasswordKey = "password"; + + /// + /// The Git credential protocol attribute name for username. + /// + public const string UserNameKey = "username"; + /// /// Prefix that Git Credential Manager reserves on every state[] entry /// so its state can be distinguished from other helpers' state per diff --git a/src/Core/GitCapabilities.cs b/src/Core/GitCapabilities.cs index 2232abbbf..82de5a779 100644 --- a/src/Core/GitCapabilities.cs +++ b/src/Core/GitCapabilities.cs @@ -38,6 +38,14 @@ public enum GitCapabilities /// flag that signals a non-final authentication step is expected. /// State = 1 << 0, + + /// + /// The authtype, credential, and ephemeral attributes are understood. + /// + /// + /// Provides alternative formats for HTTP Authorization header. + /// + AuthType = 1 << 1, } /// @@ -66,7 +74,8 @@ public static GitCapabilities ParseName(string name) // handling is implemented. return name.ToLowerInvariant() switch { - "state" => GitCapabilities.State, + Constants.CredentialProtocol.StateKey => GitCapabilities.State, + Constants.CredentialProtocol.AuthTypeKey => GitCapabilities.AuthType, _ => GitCapabilities.None, }; } @@ -89,7 +98,8 @@ public static string ToProtocolName(GitCapabilities capability) // protocol name distinct from its .NET enum name). return capability switch { - GitCapabilities.State => "state", + GitCapabilities.State => Constants.CredentialProtocol.StateKey, + GitCapabilities.AuthType => Constants.CredentialProtocol.AuthTypeKey, GitCapabilities.None => throw new ArgumentException( "Cannot render the None capability to a protocol name.", nameof(capability)), diff --git a/src/Core/GitRequest.cs b/src/Core/GitRequest.cs index d742650da..fac9c37ec 100644 --- a/src/Core/GitRequest.cs +++ b/src/Core/GitRequest.cs @@ -47,8 +47,13 @@ public GitRequest(IDictionary> dict) public string Protocol => GetArgumentOrDefault("protocol"); public string Host => GetArgumentOrDefault("host"); public string Path => GetArgumentOrDefault("path"); - public string UserName => GetArgumentOrDefault("username"); - public string Password => GetArgumentOrDefault("password"); + + public string AuthType => GetArgumentOrDefault(Constants.CredentialProtocol.AuthTypeKey); + public string Credential => GetArgumentOrDefault(Constants.CredentialProtocol.CredentialKey); + public string UserName => GetArgumentOrDefault(Constants.CredentialProtocol.UserNameKey); + public string Password => GetArgumentOrDefault(Constants.CredentialProtocol.PasswordKey); + public bool IsEphemeral => StringExtensions.IsTruthy(GetArgumentOrDefault(Constants.CredentialProtocol.EphemeralKey)); + public IList WwwAuth => GetMultiArgumentOrDefault("wwwauth"); /// diff --git a/src/Core/GitResponse.cs b/src/Core/GitResponse.cs index b7baaf3cb..caaf1b54e 100644 --- a/src/Core/GitResponse.cs +++ b/src/Core/GitResponse.cs @@ -43,7 +43,7 @@ public class GitResponse private readonly Dictionary _state = new(StringComparer.Ordinal); private ReadOnlyDictionary _stateView; - private GitResponse(ICredential credential, bool isContinue, bool isCancelled, bool isYielded) + private GitResponse(ICredential credential, bool isEphemeral = false, string authType = null, bool isContinue = false, bool isCancelled = false, bool isYielded = false) { // At most one of Continue, Cancel, Yield may be set (Ok is "none of them"). if ((isContinue && isCancelled) || @@ -71,6 +71,9 @@ private GitResponse(ICredential credential, bool isContinue, bool isCancelled, b } Credential = credential; + IsCredentialEphemeral = isEphemeral; + AuthType = authType; + IsContinue = isContinue; IsCancelled = isCancelled; IsYielded = isYielded; @@ -81,15 +84,15 @@ private GitResponse(ICredential credential, bool isContinue, bool isCancelled, b /// /// Equivalent to . public GitResponse(ICredential credential) - : this(credential, isContinue: false, isCancelled: false, isYielded: false) + : this(credential, authType: null) { } /// /// Construct a successful response carrying the given credential. /// - public static GitResponse Ok(ICredential credential) => - new GitResponse(credential, isContinue: false, isCancelled: false, isYielded: false); + public static GitResponse Ok(ICredential credential, bool isEphemeral = false, string authtype = null) => + new(credential, isEphemeral: isEphemeral, authType: authtype); /// /// Construct a successful response carrying the given credential and @@ -101,8 +104,8 @@ public static GitResponse Ok(ICredential credential) => /// multistage HTTP authentication (NTLM/Kerberos) and any flow where the /// helper wants to be invoked again after the next server response. /// - public static GitResponse Continue(ICredential credential) => - new GitResponse(credential, isContinue: true, isCancelled: false, isYielded: false); + public static GitResponse Continue(ICredential credential, bool isEphemeral = false, string authType = null) => + new (credential, isEphemeral: isEphemeral, authType: authType, isContinue: true); /// /// Construct a cancellation response: the provider declined to produce a @@ -117,7 +120,7 @@ public static GitResponse Continue(ICredential credential) => /// cancelled response are ignored. /// public static GitResponse Cancel() => - new GitResponse(credential: null, isContinue: false, isCancelled: true, isYielded: false); + new GitResponse(credential: null, isCancelled: true); /// /// Construct a yielded response: the provider has nothing to contribute @@ -132,7 +135,7 @@ public static GitResponse Cancel() => /// set on a yielded response are ignored. /// public static GitResponse Yield() => - new GitResponse(credential: null, isContinue: false, isCancelled: false, isYielded: true); + new GitResponse(credential: null, isYielded: true); /// /// The credential resolved or generated for the request, or @@ -140,6 +143,16 @@ public static GitResponse Yield() => /// public ICredential Credential { get; } + /// + /// The credential is ephemeral (or generated) and not to be stored on success. + /// + public bool IsCredentialEphemeral { get; } + + /// + /// The special Autorization type for the supplied credential. + /// + public string AuthType { get; } + /// /// when the provider expects a further round of /// authentication. The command layer translates this into a continue=1 diff --git a/src/Core/Interop/Windows/Native/User32.cs b/src/Core/Interop/Windows/Native/User32.cs index 8cf05a8d8..7745a10db 100644 --- a/src/Core/Interop/Windows/Native/User32.cs +++ b/src/Core/Interop/Windows/Native/User32.cs @@ -36,6 +36,9 @@ public static IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr value) [DllImport(LibraryName, SetLastError = true)] public static extern bool GetClientRect(IntPtr hwnd, out RECT lpRect); + [DllImport(LibraryName)] + public static extern bool IsWindowVisible(IntPtr hwnd); + /// /// Retrieves the handle to the ancestor of the specified window. /// diff --git a/src/Core/PlatformUtils.cs b/src/Core/PlatformUtils.cs index cba2c4e3c..6f8dc38e9 100644 --- a/src/Core/PlatformUtils.cs +++ b/src/Core/PlatformUtils.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using GitCredentialManager.Interop.Posix.Native; +using GitCredentialManager.Interop.Windows.Native; namespace GitCredentialManager { @@ -211,6 +212,36 @@ public static bool IsElevatedUser() return false; } + public static IntPtr GetConsoleParentWindow() + { + // On Windows we can try and get the console window parent handle if that exists + if (!IsWindows()) + { + return IntPtr.Zero; + } + + IntPtr consoleHandle = Kernel32.GetConsoleWindow(); + + // When the parent is using ConPTY (pseudo-terminals) the console + // window may be a fake/stub (as is the case with Windows Terminal). + // This means we need to walk up the ancestor chain to get the actual + // root owner (typically the terminal emulator's window). + IntPtr parent = User32.GetAncestor(consoleHandle, GetAncestorFlags.GetRootOwner); + + // On Windows the parent window may be hidden (not visible) if the console is + // running in a background process (like a service). + // We do not consider a hidden window as a valid parent. We do however consider + // a minimised window as a valid parent (and the user is able to restore it and + // see our child window too). + if (User32.IsWindowVisible(parent)) + { + return parent; + } + + // No visible console window available! + return IntPtr.Zero; + } + #region Platform Entry Path Utils /// diff --git a/src/Core/Settings.cs b/src/Core/Settings.cs index ae787ef19..b023fa9d7 100644 --- a/src/Core/Settings.cs +++ b/src/Core/Settings.cs @@ -819,11 +819,6 @@ public bool? UseMsAuthDefaultAccount return str.ToBooleany(); } - if (PlatformUtils.IsDevBox()) - { - return true; - } - return null; } } diff --git a/src/Core/UI/HelperCommand.cs b/src/Core/UI/HelperCommand.cs index f75096ddb..49492bf1f 100644 --- a/src/Core/UI/HelperCommand.cs +++ b/src/Core/UI/HelperCommand.cs @@ -16,11 +16,19 @@ public HelperCommand(ICommandContext context, string name, string description) protected IntPtr GetParentHandle() { + // Check if the user has specified a parent window ID if (int.TryParse(Context.Settings.ParentWindowId, out int id)) { return new IntPtr(id); } + // Check if we can use the console window as a parent + IntPtr consoleParent = PlatformUtils.GetConsoleParentWindow(); + if (consoleParent != IntPtr.Zero) + { + return consoleParent; + } + return IntPtr.Zero; }