diff --git a/.github/ISSUE_TEMPLATE/auth-problem.md b/.github/ISSUE_TEMPLATE/auth-problem.md index 99346df26..8973dc52b 100644 --- a/.github/ISSUE_TEMPLATE/auth-problem.md +++ b/.github/ISSUE_TEMPLATE/auth-problem.md @@ -2,7 +2,7 @@ name: Authentication failure about: An authentication problem occurred when running a Git command. title: '' -labels: 'auth-failure' +labels: 'auth-issue' assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/experimental.md b/.github/ISSUE_TEMPLATE/experimental.md new file mode 100644 index 000000000..c07154154 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/experimental.md @@ -0,0 +1,65 @@ +--- +name: Experimental feature issues +about: A problem or issue occurred when using an experimental feature. +title: '' +labels: 'experimental' +assignees: '' +--- + +**Which version of GCM Core are you using?** + +From a terminal, run `git-credential-manager-core version` and paste the output. + + + +**Which Git host provider are you trying to connect to?** + +* [ ] Azure DevOps +* [ ] Azure DevOps Server (TFS/on-prem) +* [ ] GitHub +* [ ] GitHub Enterprise +* [ ] Bitbucket +* [ ] Other - please describe + +**Can you access the remote repository directly in the browser using the remote URL?** + +From a terminal, run `git remote -v` to see your remote URL. + + + +* [ ] Yes +* [ ] No, I get a permission error +* [ ] No, for a different reason - please describe + +--- + +**_[Azure DevOps only]_ What format is your remote URL?** + +* [ ] Not applicable +* [ ] https://dev.azure.com/`{org}`/... +* [ ] https://`{org}`@dev.azure.com/`{org}`/... +* [ ] https://`{org}`.visualstudio.com/... + +**_[Azure DevOps only]_ If the account picker shows more than one identity as you authenticate, check that you selected the same one that has access on the web.** + +* [ ] Not applicable +* [ ] I only see one identity +* [ ] I checked each identity and none worked + +--- + +**Expected behavior** + +I am authenticated and my Git operation completes successfully. + +**Actual behavior** + +A clear and concise description of what happens. For example: exception is thrown, UI freezes, etc. + +**Logs** + +Set the environment variables `GCM_TRACE=1` and `GIT_TRACE=1` and re-run your Git command. Review and redact any private information and attach the log. diff --git a/.github/workflows/release-winget.yaml b/.github/workflows/release-winget.yaml index 552f62970..2a2050c67 100644 --- a/.github/workflows/release-winget.yaml +++ b/.github/workflows/release-winget.yaml @@ -7,26 +7,29 @@ jobs: release: runs-on: ubuntu-latest steps: - - name: Update winget repository - uses: mjcheetham/update-winget@v1.0 + - id: update-winget + name: Update winget repository + uses: mjcheetham/update-winget@v1.2.1 with: - token: ${{ secrets.WINGET_TOKEN }} - repo: microsoft/winget-pkgs id: Microsoft.GitCredentialManagerCore + token: ${{ secrets.WINGET_TOKEN }} releaseAsset: gcmcore-win-x86-(.*)\.exe manifestText: | - Id: {{id}} - Version: {{version}} - Name: Git Credential Manager Core + PackageIdentifier: {{id}} + PackageVersion: {{version}} + PackageName: Git Credential Manager Core Publisher: Microsoft Corporation - AppMoniker: git-credential-manager-core - Homepage: https://aka.ms/gcmcore - Tags: "gcm, gcmcore, git, credential" + Moniker: git-credential-manager-core + PackageUrl: https://aka.ms/gcmcore + Tags: [ gcm, gcmcore, git, credential ] License: Copyright (C) Microsoft Corporation - Description: Secure, cross-platform Git credential storage with authentication to GitHub, Azure Repos, and other popular Git hosting services. + ShortDescription: Secure, cross-platform Git credential storage with authentication to GitHub, Azure Repos, and other popular Git hosting services. Installers: - - Arch: x86 - Url: {{url}} - InstallerType: Inno - Sha256: {{sha256}} + - Architecture: x86 + InstallerUrl: {{url}} + InstallerType: inno + InstallerSha256: {{sha256}} + PackageLocale: en-US + ManifestType: singleton + ManifestVersion: 1.0.0 alwaysUsePullRequest: true diff --git a/docs/azrepos-users-and-tokens.md b/docs/azrepos-users-and-tokens.md index 7caf63156..a735602cc 100644 --- a/docs/azrepos-users-and-tokens.md +++ b/docs/azrepos-users-and-tokens.md @@ -7,13 +7,18 @@ The Azure Repos host provider supports creating multiple types of credential: - Azure DevOps personal access tokens - Microsoft identity OAuth tokens (experimental) +To select which type of credential the Azure Repos host provider will create +and use, you can set the [`credential.azreposCredentialType`](https://github.com/microsoft/Git-Credential-Manager-Core/blob/master/docs/configuration.md#credentialazreposcredentialtype-experimental) +configuration entry (or [`GCM_AZREPOS_CREDENTIALTYPE`](https://github.com/microsoft/Git-Credential-Manager-Core/blob/master/docs/environment.md#GCM_AZREPOS_CREDENTIALTYPE-experimental) +environment variable). + ### Azure DevOps personal access tokens Historically, the only option supported by the Azure Repos host provider was Azure DevOps Personal Access Tokens (PATs). These PATs are only used by Azure DevOps, and must be [managed through the Azure -DevOps user settings page](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page). +DevOps user settings page](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) or [REST API](https://docs.microsoft.com/en-gb/rest/api/azure/devops/tokens/pats). PATs have a limited lifetime and new tokens must be created once they expire. In Git Credential Manager, when a PAT expired (or was manually revoked) this diff --git a/docs/configuration.md b/docs/configuration.md index 4362b747b..f001dd589 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -256,7 +256,12 @@ git config --global credential.plaintextStorePath /mnt/external-drive/credential Specify which authentication flow should be used when performing Microsoft authentication and an interactive flow is required. -Defaults to the value `auto`. +Defaults to `auto`. + +**Note:** If [`credential.msauthUseBroker`](#credentialmsauthusebroker) is set +to `true` and the operating system authentication broker is available, all flows +will be delegated to the broker. If both of those things are true, then the +value of `credential.msauthFlow` has no effect. Value|Authentication Flow -|- @@ -275,6 +280,27 @@ git config --global credential.msauthFlow devicecode --- +### credential.msauthUseBroker + +Use the operating system account manager where available. + +Defaults to `false`. This default is subject to change in the future. + +Value|Description +-|- +`true`|Use the operating system account manager as an authentication broker. +`false` _(default)_|Do not use the broker. + +#### Example + +```shell +git config --global credential.msauthUseBroker true +``` + +**Also see: [GCM_MSAUTH_USEBROKER](environment.md#GCM_MSAUTH_USEBROKER)** + +--- + ### credential.useHttpPath Tells Git to pass the entire repository URL, rather than just the hostname, when calling out to a credential provider. (This setting [comes from Git itself](https://git-scm.com/docs/gitcredentials/#Documentation/gitcredentials.txt-useHttpPath), not GCM Core.) diff --git a/docs/development.md b/docs/development.md index a30ed148c..20c19551b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,6 +6,8 @@ Start by cloning this repository: git clone https://github.com/microsoft/Git-Credential-Manager-Core ``` +You also need the latest version of the .NET SDK which can be downloaded and installed from [here](https://dotnet.microsoft.com/). + ## Building The `Git-Credential-Manager.sln` solution can be opened and built in Visual Studio, Visual Studio for Mac, Visual Studio Code, or JetBrains Rider. @@ -31,8 +33,7 @@ To build from inside an IDE, make sure to select the `WindowsDebug` or `WindowsR To build from the command line, run: ```powershell -msbuild /t:restore /p:Configuration=WindowsDebug -msbuild /p:Configuration=WindowsDebug +dotnet build -c WindowsDebug ``` You can find a copy of the installer .exe file in `out\windows\Installer.Windows\bin\Debug\net472`. diff --git a/docs/environment.md b/docs/environment.md index 5b953a860..77c083489 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -1,6 +1,6 @@ # Environment variables -[Git Credential Manager Core](usage.md) work out of the box for most users. Configuration options are available to customize or tweak behavior. +[Git Credential Manager Core](usage.md) works out of the box for most users. Configuration options are available to customize or tweak behavior. Git Credential Manager Core (GCM Core) can be configured using environment variables. **Environment variables take precedence over [configuration](configuration.md) options.** @@ -402,7 +402,12 @@ export GCM_PLAINTEXT_STORE_PATH=/mnt/external-drive/credentials Specify which authentication flow should be used when performing Microsoft authentication and an interactive flow is required. -Defaults to the value `auto`. +Defaults to `auto`. + +**Note:** If [`GCM_MSAUTH_USEBROKER`](#gcm_msauth_usebroker) is set to `true` +and the operating system authentication broker is available, all flows will be +delegated to the broker. If both of those things are true, then the value of +`GCM_MSAUTH_FLOW` has no effect. Value|Authentication Flow -|- @@ -427,6 +432,33 @@ export GCM_MSAUTH_FLOW="devicecode" --- +### GCM_MSAUTH_USEBROKER + +Use the operating system account manager where available. + +Defaults to `false`. This default is subject to change in the future. + +Value|Description +-|- +`true`|Use the operating system account manager as an authentication broker. +`false` _(default)_|Do not use the broker. + +##### Windows + +```batch +SET GCM_MSAUTH_USEBROKER="true" +``` + +##### macOS/Linux + +```bash +export GCM_MSAUTH_USEBROKER="false" +``` + +**Also see: [credential.msauthUseBroker](configuration.md#credentialmsauthusebroker)** + +--- + ### GCM_AZREPOS_CREDENTIALTYPE _(experimental)_ Specify the type of credential the Azure Repos host provider should return. diff --git a/src/osx/Installer.Mac/scripts/postinstall b/src/osx/Installer.Mac/scripts/postinstall index 86922a7a5..cb73b07db 100755 --- a/src/osx/Installer.Mac/scripts/postinstall +++ b/src/osx/Installer.Mac/scripts/postinstall @@ -30,7 +30,8 @@ fi mkdir -p /usr/local/bin /bin/ln -Fs "$INSTALL_DESTINATION/git-credential-manager-core" /usr/local/bin/git-credential-manager-core -# Configure GCM for the current user -"$INSTALL_DESTINATION/git-credential-manager-core" configure +# Configure GCM for the current user (running as the current user to avoid root +# from taking ownership of ~/.gitconfig) +sudo -u ${USER} "$INSTALL_DESTINATION/git-credential-manager-core" configure exit 0 diff --git a/src/osx/Installer.Mac/uninstall.sh b/src/osx/Installer.Mac/uninstall.sh index 2657046d0..989ed9956 100755 --- a/src/osx/Installer.Mac/uninstall.sh +++ b/src/osx/Installer.Mac/uninstall.sh @@ -10,9 +10,9 @@ then exit $? fi -# Unconfigure +# Unconfigure (as the current user) echo "Unconfiguring credential helper..." -"$GCMBIN" unconfigure +sudo -u `/usr/bin/logname` "$GCMBIN" unconfigure # Remove symlink if [ -L /usr/local/bin/git-credential-manager-core ] diff --git a/src/shared/Git-Credential-Manager/Program.cs b/src/shared/Git-Credential-Manager/Program.cs index 1dc953202..a9befbbce 100644 --- a/src/shared/Git-Credential-Manager/Program.cs +++ b/src/shared/Git-Credential-Manager/Program.cs @@ -15,8 +15,8 @@ public static class Program public static void Main(string[] args) { string appPath = GetApplicationPath(); - using (var context = new CommandContext()) - using (var app = new Application(context, appPath)) + using (var context = new CommandContext(appPath)) + using (var app = new Application(context)) { // Register all supported host providers at the normal priority. // The generic provider should never win against a more specific one, so register it with low priority. diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index 468aad804..fcee71301 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -18,6 +18,7 @@ public class AzureReposHostProviderTests $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; private static readonly string AzDevUseHttpPathKey = $"{Constants.GitConfiguration.Credential.SectionName}.https://dev.azure.com.{Constants.GitConfiguration.Credential.UseHttpPath}"; + private static readonly string OrgName = "org"; [Fact] public void AzureReposProvider_IsSupported_AzureHost_UnencryptedHttp_ReturnsTrue() @@ -143,7 +144,6 @@ public async Task AzureReposProvider_GetCredentialAsync_UnencryptedHttp_ThrowsEx [Fact] public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_VsComUrlUser_ReturnsCredential() { - var orgName = "org"; var urlAccount = "jane.doe"; var input = new InputArguments(new Dictionary @@ -176,7 +176,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); - authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl); + authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl); var userMgrMock = new Mock(MockBehavior.Strict); @@ -192,7 +192,6 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ [Fact] public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_DevAzureUrlUser_ReturnsCredential() { - var orgName = "org"; var urlAccount = "jane.doe"; var input = new InputArguments(new Dictionary @@ -226,7 +225,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); - authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl); + authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl); var userMgrMock = new Mock(MockBehavior.Strict); @@ -242,7 +241,6 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ [Fact] public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_DevAzureUrlOrgName_ReturnsCredential() { - var orgName = "org"; var input = new InputArguments(new Dictionary { @@ -276,10 +274,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); - authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl); + authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl); var userMgrMock = new Mock(MockBehavior.Strict); - userMgrMock.Setup(x => x.GetBinding(orgName)).Returns((AzureReposBinding)null); + userMgrMock.Setup(x => x.GetBinding(OrgName)).Returns((AzureReposBinding)null); var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); @@ -302,7 +300,6 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var expectedOrgUri = new Uri("https://dev.azure.com/org"); var remoteUri = new Uri("https://dev.azure.com/org/proj/_git/repo"); - var orgName = "org"; var authorityUrl = "https://login.microsoftonline.com/common"; var expectedClientId = AzureDevOpsConstants.AadClientId; var expectedRedirectUri = AzureDevOpsConstants.AadRedirectUri; @@ -324,10 +321,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); - authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl); + authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl); var userMgrMock = new Mock(MockBehavior.Strict); - userMgrMock.Setup(x => x.GetBinding(orgName)).Returns((AzureReposBinding)null); + userMgrMock.Setup(x => x.GetBinding(OrgName)).Returns((AzureReposBinding)null); var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); @@ -341,7 +338,6 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ [Fact] public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_BoundUser_ReturnsCredential() { - var orgName = "org"; var input = new InputArguments(new Dictionary { @@ -373,11 +369,11 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); - authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl); + authorityCacheMock.Setup(x => x.GetAuthority(OrgName)).Returns(authorityUrl); var userMgrMock = new Mock(MockBehavior.Strict); - userMgrMock.Setup(x => x.GetBinding(orgName)) - .Returns(new AzureReposBinding(orgName, account, null)); + userMgrMock.Setup(x => x.GetBinding(OrgName)) + .Returns(new AzureReposBinding(OrgName, account, null)); var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); @@ -391,7 +387,6 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ [Fact] public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthority_NoUser_ReturnsCredential() { - var orgName = "org"; var input = new InputArguments(new Dictionary { @@ -425,10 +420,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit var authorityCacheMock = new Mock(MockBehavior.Strict); authorityCacheMock.Setup(x => x.GetAuthority(It.IsAny())).Returns((string)null); - authorityCacheMock.Setup(x => x.UpdateAuthority(orgName, authorityUrl)); + authorityCacheMock.Setup(x => x.UpdateAuthority(OrgName, authorityUrl)); var userMgrMock = new Mock(MockBehavior.Strict); - userMgrMock.Setup(x => x.GetBinding(orgName)).Returns((AzureReposBinding)null); + userMgrMock.Setup(x => x.GetBinding(OrgName)).Returns((AzureReposBinding)null); var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object, userMgrMock.Object); @@ -442,7 +437,6 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit [Fact] public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_GeneratesCredential() { - var orgName = "org"; var input = new InputArguments(new Dictionary { diff --git a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs index 4219eb11d..bd43065ec 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs @@ -174,31 +174,33 @@ public IEnumerable GetBindings(string orgName = null) string orgPrefix = $"{AzureDevOpsConstants.UrnOrgPrefix}/"; - config.Enumerate( - Constants.GitConfiguration.Credential.SectionName, - Constants.GitConfiguration.Credential.UserName, - entry => + bool ExtractUserBinding(GitConfigurationEntry entry, IDictionary dict) + { + if (GitConfigurationKeyComparer.TrySplit(entry.Key, out _, out string scope, out _) && + Uri.TryCreate(scope, UriKind.Absolute, out Uri uri) && + uri.Scheme == AzureDevOpsConstants.UrnScheme && uri.AbsolutePath.StartsWith(orgPrefix)) { - if (GitConfigurationKeyComparer.TrySplit(entry.Key, out _, out string scope, out _) && - Uri.TryCreate(scope, UriKind.Absolute, out Uri uri) && - uri.Scheme == AzureDevOpsConstants.UrnScheme && uri.AbsolutePath.StartsWith(orgPrefix)) + string entryOrgName = uri.AbsolutePath.Substring(orgPrefix.Length); + if (orgName is null || StringComparer.OrdinalIgnoreCase.Equals(entryOrgName, orgName)) { - string entryOrgName = uri.AbsolutePath.Substring(orgPrefix.Length); - if (orgName is null || StringComparer.OrdinalIgnoreCase.Equals(entryOrgName, orgName)) - { - if (entry.Level == GitConfigurationLevel.Local) - { - localUsers[entryOrgName] = entry.Value; - } - else - { - globalUsers[entryOrgName] = entry.Value; - } - } + dict[entryOrgName] = entry.Value; } + } + + return true; + } - return true; - }); + config.Enumerate( + GitConfigurationLevel.Local, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.UserName, + entry => ExtractUserBinding(entry, localUsers)); + + config.Enumerate( + GitConfigurationLevel.Global, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.UserName, + entry => ExtractUserBinding(entry, globalUsers)); foreach (string org in globalUsers.Keys.Union(localUsers.Keys)) { diff --git a/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs b/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs index 3d8b7bbd3..797c98193 100644 --- a/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs +++ b/src/shared/Microsoft.Git.CredentialManager.Tests/ApplicationTests.cs @@ -16,8 +16,8 @@ public async Task Application_ConfigureAsync_NoHelpers_AddsEmptyAndGcm() const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); await application.ConfigureAsync(ConfigurationTarget.User); Assert.Single(context.Git.Configuration.Global); @@ -34,8 +34,8 @@ public async Task Application_ConfigureAsync_Gcm_AddsEmptyBeforeGcm() const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List {executablePath}; @@ -55,8 +55,8 @@ public async Task Application_ConfigureAsync_EmptyAndGcm_DoesNothing() const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List { @@ -80,8 +80,8 @@ public async Task Application_ConfigureAsync_EmptyAndGcmWithOthersBefore_DoesNot const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List { @@ -106,8 +106,8 @@ public async Task Application_ConfigureAsync_EmptyAndGcmWithOthersAfter_DoesNoth const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List { @@ -133,8 +133,8 @@ public async Task Application_ConfigureAsync_EmptyAndGcmWithOthersBeforeAndAfter const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List { @@ -160,8 +160,8 @@ public async Task Application_ConfigureAsync_EmptyAndGcmWithEmptyAfter_RemovesEx const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List { @@ -186,8 +186,8 @@ public async Task Application_UnconfigureAsync_NoHelpers_DoesNothing() const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); await application.UnconfigureAsync(ConfigurationTarget.User); Assert.Empty(context.Git.Configuration.Global); @@ -199,8 +199,8 @@ public async Task Application_UnconfigureAsync_Gcm_RemovesGcm() const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List {executablePath}; @@ -216,8 +216,8 @@ public async Task Application_UnconfigureAsync_EmptyAndGcm_RemovesEmptyAndGcm() const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List {emptyHelper, executablePath}; @@ -234,8 +234,8 @@ public async Task Application_UnconfigureAsync_EmptyAndGcmWithOthersBefore_Remov const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List { @@ -258,8 +258,8 @@ public async Task Application_UnconfigureAsync_EmptyAndGcmWithOthersAfterBefore_ const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List { @@ -284,8 +284,8 @@ public async Task Application_UnconfigureAsync_EmptyAndGcmWithOthersBeforeAndAft const string executablePath = "/usr/local/share/gcm-core/git-credential-manager-core"; string key = $"{Constants.GitConfiguration.Credential.SectionName}.{Constants.GitConfiguration.Credential.Helper}"; - var context = new TestCommandContext(); - IConfigurableComponent application = new Application(context, executablePath); + var context = new TestCommandContext {AppPath = executablePath}; + IConfigurableComponent application = new Application(context); context.Git.Configuration.Global[key] = new List { diff --git a/src/shared/Microsoft.Git.CredentialManager/Application.cs b/src/shared/Microsoft.Git.CredentialManager/Application.cs index fbbefe61a..76863010f 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Application.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Application.cs @@ -17,27 +17,23 @@ namespace Microsoft.Git.CredentialManager { public class Application : ApplicationBase, IConfigurableComponent { - private readonly string _appPath; private readonly IHostProviderRegistry _providerRegistry; private readonly IConfigurationService _configurationService; private readonly IList _providerCommands = new List(); - public Application(ICommandContext context, string appPath) - : this(context, new HostProviderRegistry(context), new ConfigurationService(context), appPath) + public Application(ICommandContext context) + : this(context, new HostProviderRegistry(context), new ConfigurationService(context)) { } internal Application(ICommandContext context, IHostProviderRegistry providerRegistry, - IConfigurationService configurationService, - string appPath) + IConfigurationService configurationService) : base(context) { EnsureArgument.NotNull(providerRegistry, nameof(providerRegistry)); EnsureArgument.NotNull(configurationService, nameof(configurationService)); - EnsureArgument.NotNullOrWhiteSpace(appPath, nameof(appPath)); - _appPath = appPath; _providerRegistry = providerRegistry; _configurationService = configurationService; @@ -84,7 +80,7 @@ protected override async Task RunInternalAsync(string[] args) Context.Trace.WriteLine($"Version: {Constants.GcmVersion}"); Context.Trace.WriteLine($"Runtime: {info.ClrVersion}"); Context.Trace.WriteLine($"Platform: {info.OperatingSystemType} ({info.CpuArchitecture})"); - Context.Trace.WriteLine($"AppPath: {_appPath}"); + Context.Trace.WriteLine($"AppPath: {Context.ApplicationPath}"); Context.Trace.WriteLine($"Arguments: {string.Join(" ", args)}"); var parser = new CommandLineBuilder(rootCommand) @@ -243,18 +239,18 @@ private string GetGitConfigAppName() { const string gitCredentialPrefix = "git-credential-"; - string appName = Path.GetFileNameWithoutExtension(_appPath); + string appName = Path.GetFileNameWithoutExtension(Context.ApplicationPath); if (appName != null && appName.StartsWith(gitCredentialPrefix, StringComparison.OrdinalIgnoreCase)) { return appName.Substring(gitCredentialPrefix.Length); } - return _appPath; + return Context.ApplicationPath; } private string GetGitConfigAppPath() { - string path = _appPath; + string path = Context.ApplicationPath; // On Windows we must use UNIX style path separators if (PlatformUtils.IsWindows()) diff --git a/src/shared/Microsoft.Git.CredentialManager/Authentication/AuthenticationBase.cs b/src/shared/Microsoft.Git.CredentialManager/Authentication/AuthenticationBase.cs index 0106d0bfb..6fcf4178a 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Authentication/AuthenticationBase.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Authentication/AuthenticationBase.cs @@ -121,7 +121,7 @@ protected bool TryFindHelperExecutablePath(string envar, string configName, stri } else { - string executableDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string executableDirectory = Path.GetDirectoryName(Context.ApplicationPath); path = Path.Combine(executableDirectory!, helperName); } diff --git a/src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs b/src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs index e001be3c3..525b8a0c6 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Authentication/MicrosoftAuthentication.cs @@ -9,6 +9,10 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; +#if NETFRAMEWORK +using Microsoft.Identity.Client.Desktop; +#endif + namespace Microsoft.Git.CredentialManager.Authentication { public interface IMicrosoftAuthentication @@ -48,7 +52,15 @@ public MicrosoftAuthentication(ICommandContext context) public async Task GetTokenAsync( string authority, string clientId, Uri redirectUri, string[] scopes, string userName) { - IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri); + // Check if we can and should use OS broker authentication + bool useBroker = CanUseBroker(); + if (useBroker) + { + Context.Trace.WriteLine("OS broker is available and enabled."); + } + + // Create the public client application for authentication + IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri, useBroker); AuthenticationResult result = null; @@ -65,6 +77,9 @@ public MicrosoftAuthentication(ICommandContext context) // If the user has expressed a preference in how the want to perform the interactive authentication flows then we respect that. // Otherwise, depending on the current platform and session type we try to show the most appropriate authentication interface: // + // On Windows 10 & .NET Framework, MSAL supports the Web Account Manager (WAM) broker - we try to use that if possible + // in the first instance. + // // On .NET Framework MSAL supports the WinForms based 'embedded' webview UI. For Windows + .NET Framework this is the // best and natural experience. // @@ -82,48 +97,61 @@ public MicrosoftAuthentication(ICommandContext context) // If the user has disabled interaction all we can do is fail at this point ThrowIfUserInteractionDisabled(); - // Check for a user flow preference - MicrosoftAuthenticationFlowType flowType = GetFlowType(); - switch (flowType) + // If we're using the OS broker then delegate everything to that + if (useBroker) { - case MicrosoftAuthenticationFlowType.Auto: - if (CanUseEmbeddedWebView()) - goto case MicrosoftAuthenticationFlowType.EmbeddedWebView; - - if (CanUseSystemWebView(app, redirectUri)) - goto case MicrosoftAuthenticationFlowType.SystemWebView; - - // Fall back to device code flow - goto case MicrosoftAuthenticationFlowType.DeviceCode; - - case MicrosoftAuthenticationFlowType.EmbeddedWebView: - Context.Trace.WriteLine("Performing interactive auth with embedded web view..."); - EnsureCanUseEmbeddedWebView(); - result = await app.AcquireTokenInteractive(scopes) - .WithPrompt(Prompt.SelectAccount) - .WithUseEmbeddedWebView(true) - .ExecuteAsync(); - break; - - case MicrosoftAuthenticationFlowType.SystemWebView: - Context.Trace.WriteLine("Performing interactive auth with system web view..."); - EnsureCanUseSystemWebView(app, redirectUri); - result = await app.AcquireTokenInteractive(scopes) - .WithPrompt(Prompt.SelectAccount) - .WithSystemWebViewOptions(GetSystemWebViewOptions()) - .ExecuteAsync(); - break; - - case MicrosoftAuthenticationFlowType.DeviceCode: - Context.Trace.WriteLine("Performing interactive auth with device code..."); - // We don't have a way to display a device code without a terminal at the moment - // TODO: introduce a small GUI window to show a code if no TTY exists - ThrowIfTerminalPromptsDisabled(); - result = await app.AcquireTokenWithDeviceCode(scopes, ShowDeviceCodeInTty).ExecuteAsync(); - break; - - default: - goto case MicrosoftAuthenticationFlowType.Auto; + Context.Trace.WriteLine("Performing interactive auth with broker..."); + result = await app.AcquireTokenInteractive(scopes) + .WithPrompt(Prompt.SelectAccount) + // We must configure the system webview as a fallback + .WithSystemWebViewOptions(GetSystemWebViewOptions()) + .ExecuteAsync(); + } + else + { + // Check for a user flow preference if they've specified one + MicrosoftAuthenticationFlowType flowType = GetFlowType(); + switch (flowType) + { + case MicrosoftAuthenticationFlowType.Auto: + if (CanUseEmbeddedWebView()) + goto case MicrosoftAuthenticationFlowType.EmbeddedWebView; + + if (CanUseSystemWebView(app, redirectUri)) + goto case MicrosoftAuthenticationFlowType.SystemWebView; + + // Fall back to device code flow + goto case MicrosoftAuthenticationFlowType.DeviceCode; + + case MicrosoftAuthenticationFlowType.EmbeddedWebView: + Context.Trace.WriteLine("Performing interactive auth with embedded web view..."); + EnsureCanUseEmbeddedWebView(); + result = await app.AcquireTokenInteractive(scopes) + .WithPrompt(Prompt.SelectAccount) + .WithUseEmbeddedWebView(true) + .ExecuteAsync(); + break; + + case MicrosoftAuthenticationFlowType.SystemWebView: + Context.Trace.WriteLine("Performing interactive auth with system web view..."); + EnsureCanUseSystemWebView(app, redirectUri); + result = await app.AcquireTokenInteractive(scopes) + .WithPrompt(Prompt.SelectAccount) + .WithSystemWebViewOptions(GetSystemWebViewOptions()) + .ExecuteAsync(); + break; + + case MicrosoftAuthenticationFlowType.DeviceCode: + Context.Trace.WriteLine("Performing interactive auth with device code..."); + // We don't have a way to display a device code without a terminal at the moment + // TODO: introduce a small GUI window to show a code if no TTY exists + ThrowIfTerminalPromptsDisabled(); + result = await app.AcquireTokenWithDeviceCode(scopes, ShowDeviceCodeInTty).ExecuteAsync(); + break; + + default: + goto case MicrosoftAuthenticationFlowType.Auto; + } } } @@ -179,7 +207,8 @@ private async Task GetAccessTokenSilentlyAsync(IPublicClie } } - private async Task CreatePublicClientApplicationAsync(string authority, string clientId, Uri redirectUri) + private async Task CreatePublicClientApplicationAsync( + string authority, string clientId, Uri redirectUri, bool enableBroker) { var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); @@ -197,6 +226,23 @@ private async Task CreatePublicClientApplicationAsync( appBuilder.WithLogging(OnMsalLogMessage, LogLevel.Verbose, enablePiiLogging, false); } + // If we have a parent window ID we should tell MSAL about it so it can parent any authentication dialogs + // correctly. We only support this on Windows right now as MSAL only supports embedded/dialogs on Windows. + if (PlatformUtils.IsWindows() && !string.IsNullOrWhiteSpace(Context.Settings.ParentWindowId) && + int.TryParse(Context.Settings.ParentWindowId, out int hWndInt) && hWndInt > 0) + { + appBuilder.WithParentActivityOrWindow(() => new IntPtr(hWndInt)); + } + + // On Windows 10 & .NET Framework try and use the WAM broker + if (enableBroker && PlatformUtils.IsWindows10()) + { +#if NETFRAMEWORK + appBuilder.WithExperimentalFeatures(); + appBuilder.WithWindowsBroker(); +#endif + } + IPublicClientApplication app = appBuilder.Build(); // Register the application token cache @@ -221,14 +267,12 @@ private async Task RegisterTokenCacheAsync(IPublicClientApplication app) return; } - string clientId = app.AppConfig.ClientId; - // We use the MSAL extension library to provide us consistent cache file access semantics (synchronisation, etc) // as other Microsoft developer tools such as the Azure PowerShell CLI. MsalCacheHelper helper = null; try { - var storageProps = CreateTokenCacheProps(clientId, useLinuxFallback: false); + var storageProps = CreateTokenCacheProps(useLinuxFallback: false); helper = await MsalCacheHelper.CreateAsync(storageProps); // Test that cache access is working correctly @@ -254,7 +298,7 @@ private async Task RegisterTokenCacheAsync(IPublicClientApplication app) // On Linux the SecretService/keyring might not be available so we must fall-back to a plaintext file. Context.Streams.Error.WriteLine("warning: using plain-text fallback token cache"); Context.Trace.WriteLine("Using fall-back plaintext token cache on Linux."); - var storageProps = CreateTokenCacheProps(clientId, useLinuxFallback: true); + var storageProps = CreateTokenCacheProps(useLinuxFallback: true); helper = await MsalCacheHelper.CreateAsync(storageProps); } } @@ -271,7 +315,7 @@ private async Task RegisterTokenCacheAsync(IPublicClientApplication app) } } - private StorageCreationProperties CreateTokenCacheProps(string clientId, bool useLinuxFallback) + private StorageCreationProperties CreateTokenCacheProps(bool useLinuxFallback) { const string cacheFileName = "msal.cache"; string cacheDirectory; @@ -290,7 +334,7 @@ private StorageCreationProperties CreateTokenCacheProps(string clientId, bool us } // The keychain is used on macOS with the following service & account names - var builder = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory, clientId) + var builder = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory) .WithMacKeyChain("Microsoft.Developer.IdentityService", "MSALCache"); if (useLinuxFallback) @@ -352,6 +396,33 @@ public HttpClient GetHttpClient() #region Auth flow capability detection + private bool CanUseBroker() + { +#if NETFRAMEWORK + // We only support the broker on Windows 10 and require an interactive session + if (!Context.SessionManager.IsDesktopSession || !PlatformUtils.IsWindows10()) + { + return false; + } + + // Default to not using the OS broker + const bool defaultValue = false; + + if (Context.Settings.TryGetSetting(Constants.EnvironmentVariables.MsAuthUseBroker, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.MsAuthUseBroker, + out string valueStr)) + { + return valueStr.ToBooleanyOrDefault(defaultValue); + } + + return defaultValue; +#else + // OS broker requires .NET Framework right now until we migrate to .NET 5.0 (net5.0-windows10.x.y.z) + return false; +#endif + } + private bool CanUseEmbeddedWebView() { // If we're in an interactive session and on .NET Framework then MSAL can show the WinForms-based embedded UI diff --git a/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs b/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs index 981ba5ddb..7ec5900f6 100644 --- a/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs +++ b/src/shared/Microsoft.Git.CredentialManager/CommandContext.cs @@ -14,6 +14,11 @@ namespace Microsoft.Git.CredentialManager /// public interface ICommandContext : IDisposable { + /// + /// Absolute path the application entry executable. + /// + string ApplicationPath { get; } + /// /// Settings and configuration for Git Credential Manager. /// @@ -75,8 +80,11 @@ public interface ICommandContext : IDisposable /// public class CommandContext : DisposableObject, ICommandContext { - public CommandContext() + public CommandContext(string appPath) { + EnsureArgument.NotNullOrWhiteSpace(appPath, nameof (appPath)); + + ApplicationPath = appPath; Streams = new StandardStreams(); Trace = new Trace(); @@ -165,6 +173,8 @@ private static string GetGitPath(IEnvironment environment, IFileSystem fileSyste #region ICommandContext + public string ApplicationPath { get; } + public ISettings Settings { get; } public IStandardStreams Streams { get; } diff --git a/src/shared/Microsoft.Git.CredentialManager/Constants.cs b/src/shared/Microsoft.Git.CredentialManager/Constants.cs index e6ae24610..4d694aa79 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Constants.cs +++ b/src/shared/Microsoft.Git.CredentialManager/Constants.cs @@ -52,6 +52,7 @@ public static class EnvironmentVariables public const string GcmInteractive = "GCM_INTERACTIVE"; public const string GcmParentWindow = "GCM_MODAL_PARENTHWND"; public const string MsAuthFlow = "GCM_MSAUTH_FLOW"; + public const string MsAuthUseBroker = "GCM_MSAUTH_USEBROKER"; public const string GcmCredNamespace = "GCM_NAMESPACE"; public const string GcmCredentialStore = "GCM_CREDENTIAL_STORE"; public const string GcmCredCacheOptions = "GCM_CREDENTIAL_CACHE_OPTIONS"; @@ -83,6 +84,7 @@ public static class Credential public const string UseHttpPath = "useHttpPath"; public const string Interactive = "interactive"; public const string MsAuthFlow = "msauthFlow"; + public const string MsAuthUseBroker = "msauthUseBroker"; public const string CredNamespace = "namespace"; public const string CredentialStore = "credentialStore"; public const string CredCacheOptions = "cacheOptions"; diff --git a/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs b/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs index 3fef4f9fe..a73c8bfa1 100644 --- a/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs +++ b/src/shared/Microsoft.Git.CredentialManager/GitConfiguration.cs @@ -117,7 +117,7 @@ internal GitProcessConfiguration(ITrace trace, GitProcess git) public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) { string levelArg = GetLevelFilterArg(level); - using (Process git = _git.CreateProcess($"config --null {levelArg} --list --show-scope")) + using (Process git = _git.CreateProcess($"config --null {levelArg} --list")) { git.Start(); // To avoid deadlocks, always read the output stream first and then wait @@ -134,31 +134,14 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa throw GitProcess.CreateGitException(git, "Failed to enumerate all Git configuration entries"); } - var scope = new StringBuilder(); var name = new StringBuilder(); var value = new StringBuilder(); int i = 0; while (i < data.Length) { - scope.Clear(); name.Clear(); value.Clear(); - // Read config scope (null terminated) - while (i < data.Length && data[i] != '\0') - { - scope.Append(data[i++]); - } - - if (i >= data.Length) - { - _trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after scope."); - break; - } - - // Skip the null terminator - i++; - // Read key name (LF terminated) while (i < data.Length && data[i] != '\n') { @@ -189,24 +172,7 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa // Skip the null terminator i++; - GitConfigurationLevel entryLevel; - switch (scope.ToString()) - { - case "system": - entryLevel = GitConfigurationLevel.System; - break; - case "global": - entryLevel = GitConfigurationLevel.Global; - break; - case "local": - entryLevel = GitConfigurationLevel.Local; - break; - default: - entryLevel = GitConfigurationLevel.Unknown; - break; - } - - var entry = new GitConfigurationEntry(entryLevel, name.ToString(), value.ToString()); + var entry = new GitConfigurationEntry(name.ToString(), value.ToString()); if (!cb(entry)) { diff --git a/src/shared/Microsoft.Git.CredentialManager/GitConfigurationEntry.cs b/src/shared/Microsoft.Git.CredentialManager/GitConfigurationEntry.cs index 0dddf70df..e57c855e4 100644 --- a/src/shared/Microsoft.Git.CredentialManager/GitConfigurationEntry.cs +++ b/src/shared/Microsoft.Git.CredentialManager/GitConfigurationEntry.cs @@ -4,14 +4,12 @@ namespace Microsoft.Git.CredentialManager { public class GitConfigurationEntry { - public GitConfigurationEntry(GitConfigurationLevel level, string key, string value) + public GitConfigurationEntry(string key, string value) { - Level = level; Key = key; Value = value; } - public GitConfigurationLevel Level { get; } public string Key { get; } public string Value { get; } } diff --git a/src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj b/src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj index c2e97ab38..663c318d5 100644 --- a/src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj +++ b/src/shared/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj @@ -13,12 +13,13 @@ + - - + + diff --git a/src/shared/Microsoft.Git.CredentialManager/PlatformUtils.cs b/src/shared/Microsoft.Git.CredentialManager/PlatformUtils.cs index 8c6e32760..a05ddae22 100644 --- a/src/shared/Microsoft.Git.CredentialManager/PlatformUtils.cs +++ b/src/shared/Microsoft.Git.CredentialManager/PlatformUtils.cs @@ -20,6 +20,26 @@ public static PlatformInformation GetPlatformInformation() return new PlatformInformation(osType, cpuArch, clrVersion); } + public static bool IsWindows10() + { + if (!IsWindows()) + { + return false; + } + + // Implementation of version checking was taken from: + // https://github.com/dotnet/runtime/blob/6578f257e3be2e2144a65769706e981961f0130c/src/libraries/System.Private.CoreLib/src/System/Environment.Windows.cs#L110-L122 + // + // Note that we cannot use Environment.OSVersion in .NET Framework (or Core versions less than 5.0) as + // the implementation in those versions "lies" about Windows versions > 8.1 if there is no application manifest. + if (RtlGetVersionEx(out RTL_OSVERSIONINFOEX osvi) != 0) + { + return false; + } + + return (int) osvi.dwMajorVersion == 10; + } + /// /// Check if the current Operating System is macOS. /// @@ -169,6 +189,34 @@ private static string GetClrVersion() } #endregion + + #region Windows Native Version APIs + + // Interop code sourced from the .NET Runtime as of version 5.0: + // https://github.com/dotnet/runtime/blob/6578f257e3be2e2144a65769706e981961f0130c/src/libraries/Common/src/Interop/Windows/NtDll/Interop.RtlGetVersion.cs + + [DllImport("ntdll.dll", ExactSpelling = true)] + private static extern int RtlGetVersion(ref RTL_OSVERSIONINFOEX lpVersionInformation); + + private static unsafe int RtlGetVersionEx(out RTL_OSVERSIONINFOEX osvi) + { + osvi = default; + osvi.dwOSVersionInfoSize = (uint)sizeof(RTL_OSVERSIONINFOEX); + return RtlGetVersion(ref osvi); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private unsafe struct RTL_OSVERSIONINFOEX + { + internal uint dwOSVersionInfoSize; + internal uint dwMajorVersion; + internal uint dwMinorVersion; + internal uint dwBuildNumber; + internal uint dwPlatformId; + internal fixed char szCSDVersion[128]; + } + + #endregion } public struct PlatformInformation diff --git a/src/shared/TestInfrastructure/Objects/TestCommandContext.cs b/src/shared/TestInfrastructure/Objects/TestCommandContext.cs index 6947e70ea..b42aba9d7 100644 --- a/src/shared/TestInfrastructure/Objects/TestCommandContext.cs +++ b/src/shared/TestInfrastructure/Objects/TestCommandContext.cs @@ -12,6 +12,10 @@ public class TestCommandContext : ICommandContext { public TestCommandContext() { + AppPath = PlatformUtils.IsWindows() + ? @"C:\Program Files\Git Credential Manager Core\git-credential-manager-core.exe" + : "/usr/local/bin/git-credential-manager-core"; + Streams = new TestStandardStreams(); Terminal = new TestTerminal(); SessionManager = new TestSessionManager(); @@ -26,6 +30,7 @@ public TestCommandContext() Settings = new TestSettings {Environment = Environment, GitConfiguration = Git.Configuration}; } + public string AppPath { get; set; } public TestSettings Settings { get; set; } public TestStandardStreams Streams { get; set; } public TestTerminal Terminal { get; set; } @@ -40,6 +45,8 @@ public TestCommandContext() #region ICommandContext + string ICommandContext.ApplicationPath => AppPath; + IStandardStreams ICommandContext.Streams => Streams; ISettings ICommandContext.Settings => Settings; diff --git a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs index af9dee1d5..c9a15a6ce 100644 --- a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs +++ b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs @@ -26,7 +26,7 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa { foreach (var value in kvp.Value) { - var entry = new GitConfigurationEntry(dictLevel, kvp.Key, value); + var entry = new GitConfigurationEntry(kvp.Key, value); if (!cb(entry)) { break; diff --git a/src/windows/Installer.Windows/Setup.iss b/src/windows/Installer.Windows/Setup.iss index 36484e0df..672cab282 100644 --- a/src/windows/Installer.Windows/Setup.iss +++ b/src/windows/Installer.Windows/Setup.iss @@ -19,7 +19,7 @@ #define GcmAppId "{{aa76d31d-432c-42ee-844c-bc0bc801cef3}}" #define GcmLongName "Git Credential Manager Core (User)" #define GcmSetupExe "gcmcoreuser" - #define GcmConfigureCmdArgs "--user" + #define GcmConfigureCmdArgs "" #elif InstallTarget == "system" #define GcmAppId "{{fdfae50a-1bc1-4ead-9228-1e1c275e8d12}}" #define GcmLongName "Git Credential Manager Core" @@ -112,14 +112,19 @@ Source: "{#PayloadDir}\GitHub.UI.exe.config"; DestDir: Source: "{#PayloadDir}\Microsoft.AzureRepos.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\Microsoft.Git.CredentialManager.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\Microsoft.Git.CredentialManager.UI.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#PayloadDir}\Microsoft.Identity.Client.Desktop.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\Microsoft.Identity.Client.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\Microsoft.Identity.Client.Extensions.Msal.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#PayloadDir}\Microsoft.Web.WebView2.Core.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#PayloadDir}\Microsoft.Web.WebView2.WinForms.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#PayloadDir}\Microsoft.Web.WebView2.Wpf.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\Newtonsoft.Json.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\System.Buffers.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\System.CommandLine.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\System.Memory.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\System.Numerics.Vectors.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#PayloadDir}\System.Runtime.CompilerServices.Unsafe.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#PayloadDir}\WebView2Loader.dll"; DestDir: "{app}"; Flags: ignoreversion [Code] // Don't allow installing conflicting architectures