diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index fe7d53095..864ae2f73 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 - - uses: DavidAnson/markdownlint-cli2-action@d57f8bd57670b9c1deedf71219dd494614ff3335 + - uses: DavidAnson/markdownlint-cli2-action@5b7c9f74fec47e6b15667b2cc23c63dff11e449e with: globs: | "**/*.md" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70ff1d6a3..83516cd7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,7 +384,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v3 with: - name: tmp.linux-build + name: linux-build path: | linux-build @@ -399,7 +399,11 @@ jobs: - name: Download artifacts uses: actions/download-artifact@v3 with: - name: tmp.linux-build + name: linux-build + + - name: Remove symbols + run: | + rm tar/*symbols* - uses: azure/login@v1 with: @@ -423,6 +427,12 @@ jobs: run: | python .github/run_esrp_signing.py deb $env:LINUX_KEY_CODE $env:LINUX_OP_CODE python .github/run_esrp_signing.py tar $env:LINUX_KEY_CODE $env:LINUX_OP_CODE + + - name: Re-name tarball signature file + shell: bash + run: | + signaturepath=$(find signed/*.tar.gz) + mv "$signaturepath" "${signaturepath%.tar.gz}.asc" - name: Upload signed tarball and Debian package uses: actions/upload-artifact@v3 @@ -624,9 +634,15 @@ jobs: - os: ubuntu-latest artifact: linux-sign command: git-credential-manager + description: debian + - os: ubuntu-latest + artifact: linux-build + command: git-credential-manager + description: tarball - os: macos-latest artifact: osx-x64-sign command: git-credential-manager + description: osx-x64 - os: windows-latest artifact: win-sign # Even when a standalone GCM version is installed, GitHub actions @@ -634,9 +650,11 @@ jobs: # Windows due to its placement on the PATH. For this reason, we use # the full path to our installation to validate the Windows version. command: "$PROGRAMFILES (x86)/Git Credential Manager/git-credential-manager.exe" + description: windows - os: ubuntu-latest artifact: dotnet-tool-sign command: git-credential-manager + description: dotnet-tool runs-on: ${{ matrix.component.os }} needs: [ osx-sign, win-sign, linux-sign, dotnet-tool-sign ] steps: @@ -654,7 +672,7 @@ jobs: name: ${{ matrix.component.artifact }} - name: Install Windows - if: contains(matrix.component.os, 'windows') + if: contains(matrix.component.description, 'windows') shell: pwsh run: | $exePaths = Get-ChildItem -Path ./signed/*.exe | %{$_.FullName} @@ -663,22 +681,30 @@ jobs: Start-Process -Wait -FilePath "$exePath" -ArgumentList "/SILENT /VERYSILENT /NORESTART" } - - name: Install Linux - if: contains(matrix.component.os, 'ubuntu') && contains(matrix.component.artifact, 'linux') + - name: Install Linux (Debian package) + if: contains(matrix.component.description, 'debian') run: | debpath=$(find ./*.deb) sudo apt install $debpath "${{ matrix.component.command }}" configure + + - name: Install Linux (tarball) + if: contains(matrix.component.description, 'tarball') + run: | + # Ensure we find only the source tarball, not the symbols + tarpath=$(find ./tar -name '*[[:digit:]].tar.gz') + tar -xvf $tarpath -C /usr/local/bin + "${{ matrix.component.command }}" configure - name: Install macOS - if: contains(matrix.component.os, 'macos') + if: contains(matrix.component.description, 'osx-x64') run: | # Only validate x64, given arm64 agents are not available pkgpath=$(find ./*.pkg) sudo installer -pkg $pkgpath -target / - name: Install .NET tool - if: contains(matrix.component.os, 'ubuntu') && contains(matrix.component.artifact, 'dotnet-tool') + if: contains(matrix.component.description, 'dotnet-tool') run: | nupkgpath=$(find ./*.nupkg) dotnet tool install -g --add-source $(dirname "$nupkgpath") git-credential-manager @@ -787,6 +813,7 @@ jobs: uploadDirectoryToRelease('osx-payload-and-symbols'), // Upload Linux artifacts + uploadDirectoryToRelease('linux-build/tar'), uploadDirectoryToRelease('linux-sign'), // Upload .NET tool package @@ -795,5 +822,5 @@ jobs: - name: Publish .NET tool to nuget.org run: | - dotnet nuget push dotnet-tool-sign/signed/*.nupkg \ + dotnet nuget push dotnet-tool-sign/*.nupkg \ --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json diff --git a/docs/README.md b/docs/README.md index 3076e1935..3c08cfdfc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ The following are links to GCM user support documentation: - [Host provider specification][gcm-host-provider] - [Azure Repos OAuth tokens][gcm-azure-tokens] - [GitLab support][gcm-gitlab] +- [Generic OAuth support][gcm-oauth] [gcm-azure-tokens]: azrepos-users-and-tokens.md [gcm-config]: configuration.md @@ -23,4 +24,5 @@ The following are links to GCM user support documentation: [gcm-gitlab]: gitlab.md [gcm-host-provider]: hostprovider.md [gcm-net-config]: netconfig.md -[gcm-usage]: usage.md \ No newline at end of file +[gcm-oauth]: generic-oauth.md +[gcm-usage]: usage.md diff --git a/docs/configuration.md b/docs/configuration.md index 03272592a..d77819731 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -442,7 +442,7 @@ _(unset)_|Windows: `wincredman`, macOS: `keychain`, Linux: _(none)_|- `keychain`|macOS Keychain.|macOS `secretservice`|[freedesktop.org Secret Service API][freedesktop-ss] via [libsecret][libsecret] (requires a graphical interface to unlock secret collections).|Linux `gpg`|Use GPG to store encrypted files that are compatible with the [pass][pass] (requires GPG and `pass` to initialize the store).|macOS, Linux -`cache`|Git's built-in [credential cache][credential-cache].|Windows, macOS, Linux +`cache`|Git's built-in [credential cache][credential-cache].|macOS, Linux `plaintext`|Store credentials in plaintext files (**UNSECURE**). Customize the plaintext store location with [`credential.plaintextStorePath`][credential-plaintextstorepath].|Windows, macOS, Linux #### Example diff --git a/docs/generic-oauth.md b/docs/generic-oauth.md new file mode 100644 index 000000000..6620134fc --- /dev/null +++ b/docs/generic-oauth.md @@ -0,0 +1,116 @@ +# Generic Host Provider OAuth + +Many Git hosts use the popular standard OAuth2 or OpenID Connect (OIDC) +authentication mechanisms to secure repositories they host. +Git Credential Manager supports any generic OAuth2-based Git host by simply +setting some configuration. + +## Registering an OAuth application + +In order to use GCM with a Git host that supports OAuth you must first have +registered an OAuth application with your host. The instructions on how to do +this can be found with your Git host provider's documentation. + +When registering a new application, you should make sure to set an HTTP-based +redirect URL that points to `localhost`; for example: + +```text +http://localhost +http://localhost: +http://127.0.0.1 +http://127.0.0.1: +``` + +Note that you cannot use an HTTPS redirect URL. GCM does not require a specific +port number be used; if your Git host requires you to specify a port number in +the redirect URL then GCM will use that. Otherwise an available port will be +selected at the point authentication starts. + +You must ensure that all scopes required to read and write to Git repositories +have been granted for the application or else credentials that are generated +will cause errors when pushing or fetching using Git. + +As part of the registration process you should also be given a Client ID and, +optionally, a Client Secret. You will need both of these to configure GCM. + +## Configure GCM + +In order to configure GCM to use OAuth with your Git host you need to set the +following values in your Git configuration: + +- Client ID +- Client Secret (optional) +- Redirect URL +- Scopes (optional) +- OAuth Endpoints + - Authorization Endpoint + - Token Endpoint + - Device Code Authorization Endpoint (optional) + +OAuth endpoints can be found by consulting your Git host's OAuth app development +documentation. The URLs can be either absolute or relative to the host name; +for example: `https://example.com/oauth/authorize` or `/oauth/authorize`. + +In order to set these values, you can run the following commands, where `` +is the hostname of your Git host: + +```shell +git config --global credential..oauthClientId +git config --global credential..oauthClientSecret +git config --global credential..oauthRedirectUri +git config --global credential..oauthAuthorizeEndpoint +git config --global credential..oauthTokenEndpoint +git config --global credential..oauthScopes +git config --global credential..oauthDeviceEndpoint +``` + +**Example commands:** + +- `git config --global credential.https://example.com.oauthClientId C33F2751FB76` + +- `git config --global credential.https://example.com.oauthScopes "code:write profile:read"` + +**Example Git configuration** + +```ini +[credential "https://example.com"] + oauthClientId = 9d886e36-5771-4f2b-8c8b-420c68ad5baa + oauthClientSecret = 4BC5BD4704EAE28FD832 + oauthRedirectUri = "http://127.0.0.1" + oauthAuthorizeEndpoint = "/login/oauth/authorize" + oauthTokenEndpoint = "/login/oauth/token" + oauthDeviceEndpoint = "/login/oauth/device" + oauthScopes = "code:write profile:read" + oauthDefaultUserName = "OAUTH" + oauthUseClientAuthHeader = false +``` + +### Additional configuration + +Depending on the specific implementation of OAuth with your Git host you may +also need to specify additional behavior. + +#### Token user name + +If your Git host requires that you specify a username to use with OAuth tokens +you can either include the username in the Git remote URL, or specify a default +option via Git configuration. + +Example Git remote with username: `https://username@example.com/repo.git`. +In order to use special characters you need to URL encode the values; for +example `@` becomes `%40`. + +By default GCM uses the value `OAUTH-USER` unless specified in the remote URL, +or overriden using the `credential..oauthDefaultUserName` configuration. + +#### Include client authentication in headers + +If your Git host's OAuth implementation has specific requirements about whether +the client ID and secret should or should not be included in an `Authorization` +header during OAuth requests, you can control this using the following setting: + +```shell +git config --global credential..oauthUseClientAuthHeader +``` + +The default behavior is to include these values; i.e., `true`. diff --git a/docs/gitlab.md b/docs/gitlab.md index 58975b6cd..04b122e1c 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -2,21 +2,21 @@ Git Credential Manager supports [gitlab.com][gitlab] out the box. -## Using on a another instance +## Using on another instance To use on another instance, eg. `https://gitlab.example.com` requires setup and configuration: 1. [Create an OAuth application][gitlab-oauth]. This can be at the user, group or instance level. Specify a name and use a redirect URI of `http://127.0.0.1/`. -_Unselect_ the 'Confidential' option. Set the 'write_repository' and -'read_repository' scopes. +_Unselect_ the 'Confidential' option. Set the 'read_repository' and +'write_repository' scopes. 1. Copy the application ID and configure -`git config --global credential.https://gitlab.example.com.GitLabDevClientId ` +`git config --global credential.https://gitlab.example.com.gitLabDevClientId ` 1. Copy the application secret and configure -`git config --global credential.https://gitlab.example.com.GitLabDevClientSecret +`git config --global credential.https://gitlab.example.com.gitLabDevClientSecret ` -1. Configure authentication modes to include 'browser' +1. Optional if you want to force browser auth: `git config --global credential.https://gitlab.example.com.gitLabAuthModes browser` 1. For good measure, configure `git config --global credential.https://gitlab.example.com.provider gitlab`. @@ -27,8 +27,8 @@ This may be necessary to recognise the domain as a GitLab instance. ### Clearing config ```console - git config --global --unset-all credential.https://gitlab.example.com.GitLabDevClientId - git config --global --unset-all credential.https://gitlab.example.com.GitLabDevClientSecret + git config --global --unset-all credential.https://gitlab.example.com.gitLabDevClientId + git config --global --unset-all credential.https://gitlab.example.com.gitLabDevClientSecret git config --global --unset-all credential.https://gitlab.example.com.provider ``` @@ -39,23 +39,23 @@ instances, provided by community member [hickford](https://github.com/hickford/) ```console # https://gitlab.freedesktop.org/ -git config --global credential.https://gitlab.freedesktop.org.gitlabdevclientid 6503d8c5a27187628440d44e0352833a2b49bce540c546c22a3378c8f5b74d45 -git config --global credential.https://gitlab.freedesktop.org.gitlabdevclientsecret 2ae9343a034ff1baadaef1e7ce3197776b00746a02ddf0323bb34aca8bff6dc1 +git config --global credential.https://gitlab.freedesktop.org.gitLabDevClientId 6503d8c5a27187628440d44e0352833a2b49bce540c546c22a3378c8f5b74d45 +git config --global credential.https://gitlab.freedesktop.org.gitLabDevClientSecret 2ae9343a034ff1baadaef1e7ce3197776b00746a02ddf0323bb34aca8bff6dc1 # https://gitlab.gnome.org/ -git config --global credential.https://gitlab.gnome.org.gitlabdevclientid adf21361d32eddc87bf6baf8366f242dfe07a7d4335b46e8e101303364ccc470 -git config --global credential.https://gitlab.gnome.org.gitlabdevclientsecret cdca4678f64e5b0be9febc0d5e7aab0d81d27696d7adb1cf8022ccefd0a58fc0 +git config --global credential.https://gitlab.gnome.org.gitLabDevClientId adf21361d32eddc87bf6baf8366f242dfe07a7d4335b46e8e101303364ccc470 +git config --global credential.https://gitlab.gnome.org.gitLabDevClientSecret cdca4678f64e5b0be9febc0d5e7aab0d81d27696d7adb1cf8022ccefd0a58fc0 # https://invent.kde.org/ -git config --global credential.https://invent.kde.org.gitlabdevclientid cd7cb4342c7cd83d8c2fcc22c87320f88d0bde14984432ffca07ee24d0bf0699 -git config --global credential.https://invent.kde.org.gitlabdevclientsecret 9cc8440b280c792ac429b3615ae1c8e0702e6b2479056f899d314f05afd94211 +git config --global credential.https://invent.kde.org.gitLabDevClientId cd7cb4342c7cd83d8c2fcc22c87320f88d0bde14984432ffca07ee24d0bf0699 +git config --global credential.https://invent.kde.org.gitLabDevClientSecret 9cc8440b280c792ac429b3615ae1c8e0702e6b2479056f899d314f05afd94211 # https://salsa.debian.org/ -git config --global credential.https://salsa.debian.org.gitlabdevclientid 213f5fd32c6a14a0328048c0a77cc12c19138cc165ab957fb83d0add74656f89 -git config --global credential.https://salsa.debian.org.gitlabdevclientsecret 3616b974b59451ecf553f951cb7b8e6e3c91c6d84dd3247dcb0183dac93c2a26 +git config --global credential.https://salsa.debian.org.gitLabDevClientId 213f5fd32c6a14a0328048c0a77cc12c19138cc165ab957fb83d0add74656f89 +git config --global credential.https://salsa.debian.org.gitLabDevClientSecret 3616b974b59451ecf553f951cb7b8e6e3c91c6d84dd3247dcb0183dac93c2a26 # https://gitlab.haskell.org/ -git config --global credential.https://gitlab.haskell.org.gitlabdevclientid 57de5eaab72b3dc447fca8c19cea39527a08e82da5377c2d10a8ebb30b08fa5f -git config --global credential.https://gitlab.haskell.org.gitlabdevclientsecret 5170a480da8fb7341e0daac94223d4fff549c702efb2f8873d950bb2b88e434f +git config --global credential.https://gitlab.haskell.org.gitLabDevClientId 57de5eaab72b3dc447fca8c19cea39527a08e82da5377c2d10a8ebb30b08fa5f +git config --global credential.https://gitlab.haskell.org.gitLabDevClientSecret 5170a480da8fb7341e0daac94223d4fff549c702efb2f8873d950bb2b88e434f # https://code.videolan.org/ -git config --global credential.https://code.videolan.org.gitlabdevclientid f35c379241cc20bf9dffecb47990491b62757db4fb96080cddf2461eacb40375 -git config --global credential.https://code.videolan.org.gitlabdevclientsecret 631558ec973c5ef65b78db9f41103f8247dc68d979c86f051c0fe4389e1995e8 +git config --global credential.https://code.videolan.org.gitLabDevClientId f35c379241cc20bf9dffecb47990491b62757db4fb96080cddf2461eacb40375 +git config --global credential.https://code.videolan.org.gitLabDevClientSecret 631558ec973c5ef65b78db9f41103f8247dc68d979c86f051c0fe4389e1995e8 ``` See also [issue #677](https://github.com/GitCredentialManager/git-credential-manager/issues/677). @@ -74,7 +74,7 @@ If you have a preferred authentication mode, you can specify [credential.gitLabAuthModes][config-gitlab-auth-modes]: ```console -git config --global credential.gitlabauthmodes browser +git config --global credential.gitLabAuthModes browser ``` ## Caveats diff --git a/docs/install.md b/docs/install.md index 6233eb3c9..cc51f9805 100644 --- a/docs/install.md +++ b/docs/install.md @@ -64,7 +64,7 @@ sudo /usr/local/share/gcm-core/uninstall.sh ### .NET tool :star: -See the [.NET tool](#.NET-tool) section below for instructions on this +See the [.NET tool](#net-tool) section below for instructions on this installation method. --- diff --git a/docs/rename.md b/docs/rename.md index b35d164e7..b69e1c2f1 100644 --- a/docs/rename.md +++ b/docs/rename.md @@ -3,7 +3,7 @@ In November 2021, _"Git Credential Manager Core"_ was [renamed][rename-pr] to simply _"Git Credential Manager"_, dropping the "Core" moniker. We announced the new name in a [GitHub blog post][rename-blog], along with the new home for the -project in it's own [organization][gcm-org]. +project in its own [organization][gcm-org]. ![Git Credential Manager Core renamed](img/gcmcore-rename.png) @@ -133,7 +133,7 @@ Look out for entries that include `git-credential-manager-core` or or `manager` respectively. > **Note:** When updating the Git configuration file in your home directory -> (`$HOME/.gitconfig` or `%USERPROFILE\.gitconfig%`) you should ensure there are +> (`$HOME/.gitconfig` or `%USERPROFILE%\.gitconfig`) you should ensure there are > is an additional blank entry for `credential.helper` before the GCM entry. > > **Mac/Linux** diff --git a/src/linux/Packaging.Linux/build.sh b/src/linux/Packaging.Linux/build.sh index c407fd892..308530afe 100755 --- a/src/linux/Packaging.Linux/build.sh +++ b/src/linux/Packaging.Linux/build.sh @@ -44,6 +44,7 @@ fi OUTDIR="$INSTALLER_OUT/$CONFIGURATION" PAYLOAD="$OUTDIR/payload" +SYMBOLS="$OUTDIR/payload.sym" # Lay out payload "$INSTALLER_SRC/layout.sh" --configuration="$CONFIGURATION" || exit 1 @@ -78,7 +79,7 @@ if [ $INSTALL_FROM_SOURCE = true ]; then echo "Install complete." else # Pack - "$INSTALLER_SRC/pack.sh" --configuration="$CONFIGURATION" --payload="$PAYLOAD" --version="$VERSION" || exit 1 + "$INSTALLER_SRC/pack.sh" --configuration="$CONFIGURATION" --payload="$PAYLOAD" --symbols="$SYMBOLS" --version="$VERSION" || exit 1 fi echo "Build of Packaging.Linux complete." diff --git a/src/linux/Packaging.Linux/pack.sh b/src/linux/Packaging.Linux/pack.sh index c853af72d..3c1072d55 100755 --- a/src/linux/Packaging.Linux/pack.sh +++ b/src/linux/Packaging.Linux/pack.sh @@ -12,10 +12,6 @@ OUT="$ROOT/out" PROJ_OUT="$OUT/linux/Packaging.Linux" INSTALLER_SRC="$SRC/osx/Installer.Mac" -# Product information -IDENTIFIER="com.microsoft.gitcredentialmanager" -INSTALL_LOCATION="/usr/local/share/gcm-core" - # Parse script arguments for i in "$@" do @@ -28,6 +24,10 @@ case "$i" in PAYLOAD="${i#*=}" shift # past argument=value ;; + --symbols=*) + SYMBOLS="${i#*=}" + shift # past argument=value + ;; --configuration=*) CONFIGURATION="${i#*=}" shift # past argument=value @@ -48,9 +48,16 @@ if [ -z "$PAYLOAD" ]; then elif [ ! -d "$PAYLOAD" ]; then die "Could not find '$PAYLOAD'. Did you run layout.sh first?" fi +if [ -z "$SYMBOLS" ]; then + die "--symbols was not set" +fi ARCH="`dpkg-architecture -q DEB_HOST_ARCH`" +if test -z "$ARCH"; then + die "Could not determine host architecture!" +fi + TAROUT="$PROJ_OUT/$CONFIGURATION/tar/" TARBALL="$TAROUT/gcm-linux_$ARCH.$VERSION.tar.gz" SYMTARBALL="$TAROUT/gcm-linux_$ARCH.$VERSION-symbols.tar.gz" @@ -60,10 +67,6 @@ DEBROOT="$DEBOUT/root" DEBPKG="$DEBOUT/gcm-linux_$ARCH.$VERSION.deb" mkdir -p "$DEBROOT" -if test -z "$ARCH"; then - die "Could not determine host architecture!" -fi - # Set full read, write, execute permissions for owner and just read and execute permissions for group and other echo "Setting file permissions..." /bin/chmod -R 755 "$PAYLOAD" || exit 1 @@ -87,7 +90,7 @@ popd # Build symbols tarball echo "Building symbols tarball..." -pushd "$SYMBOLOUT" +pushd "$SYMBOLS" tar -czvf "$SYMTARBALL" * || exit 1 popd @@ -114,8 +117,6 @@ Description: Cross Platform Git Credential Manager command line utility. For more information see https://aka.ms/gcm EOF -mkdir -p "$INSTALL_TO" "$LINK_TO" - # Copy all binaries and shared libraries to target installation location cp -R "$PAYLOAD"/* "$INSTALL_TO" || exit 1 @@ -131,6 +132,6 @@ if [ ! -f "$LINK_TO/git-credential-manager-core" ]; then "$LINK_TO/git-credential-manager-core" || exit 1 fi -dpkg-deb --build "$DEBROOT" "$DEBPKG" || exit 1 +dpkg-deb -Zxz --build "$DEBROOT" "$DEBPKG" || exit 1 echo $MESSAGE diff --git a/src/shared/Atlassian.Bitbucket.UI.Avalonia/Program.cs b/src/shared/Atlassian.Bitbucket.UI.Avalonia/Program.cs index bfec51c08..be0ba8044 100644 --- a/src/shared/Atlassian.Bitbucket.UI.Avalonia/Program.cs +++ b/src/shared/Atlassian.Bitbucket.UI.Avalonia/Program.cs @@ -1,5 +1,5 @@ using System; -using System.CommandLine; +using System.Diagnostics; using System.Threading; using Atlassian.Bitbucket.UI.Commands; using Atlassian.Bitbucket.UI.Controls; @@ -45,9 +45,7 @@ private static void AppMain(object o) { string[] args = (string[]) o; - string appPath = ApplicationBase.GetEntryApplicationPath(); - string installDir = ApplicationBase.GetInstallationDirectory(); - using (var context = new CommandContext(appPath, installDir)) + using (var context = new CommandContext(args)) using (var app = new HelperApplication(context)) { app.RegisterCommand(new CredentialsCommandImpl(context)); @@ -57,6 +55,7 @@ private static void AppMain(object o) .GetAwaiter() .GetResult(); + context.Trace2.Stop(exitCode); Environment.Exit(exitCode); } } diff --git a/src/shared/Core.Tests/EnvironmentTests.cs b/src/shared/Core.Tests/EnvironmentTests.cs index 9c8eae028..bd7a8c99b 100644 --- a/src/shared/Core.Tests/EnvironmentTests.cs +++ b/src/shared/Core.Tests/EnvironmentTests.cs @@ -150,5 +150,37 @@ public void MacOSEnvironment_TryLocateExecutable_Paths_Are_Ignored() Assert.True(actualResult); Assert.Equal(expectedPath, actualPath); } + + [PlatformFact(Platforms.Posix)] + public void PosixEnvironment_SetEnvironmentVariable_Sets_Expected_Value() + { + var variable = "FOO_BAR"; + var value = "baz"; + + var fs = new TestFileSystem(); + var envars = new Dictionary(); + var env = new PosixEnvironment(fs, envars); + + env.SetEnvironmentVariable(variable, value); + + Assert.Contains(env.Variables, item + => item.Key.Equals(variable) && item.Value.Equals(value)); + } + + [PlatformFact(Platforms.Windows)] + public void WindowsEnvironment_SetEnvironmentVariable_Sets_Expected_Value() + { + var variable = "FOO_BAR"; + var value = "baz"; + + var fs = new TestFileSystem(); + var envars = new Dictionary(); + var env = new WindowsEnvironment(fs, envars); + + env.SetEnvironmentVariable(variable, value); + + Assert.Contains(env.Variables, item + => item.Key.Equals(variable) && item.Value.Equals(value)); + } } } diff --git a/src/shared/Core.Tests/GenericHostProviderTests.cs b/src/shared/Core.Tests/GenericHostProviderTests.cs index 42dc62177..39ed85cfe 100644 --- a/src/shared/Core.Tests/GenericHostProviderTests.cs +++ b/src/shared/Core.Tests/GenericHostProviderTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Threading.Tasks; using GitCredentialManager.Authentication; +using GitCredentialManager.Authentication.OAuth; using GitCredentialManager.Tests.Objects; using Moq; using Xunit; @@ -87,8 +89,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); @@ -121,8 +124,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); @@ -152,8 +156,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_Retu .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); @@ -182,6 +187,90 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotSupported_Retu await TestCreateCredentialAsync_ReturnsBasicCredential(wiaSupported: false); } + [Fact] + public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAuthConfig_UsesOAuth() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "git.example.com", + ["path"] = "foo" + }); + + const string testUserName = "TEST_OAUTH_USER"; + const string testAcessToken = "OAUTH_TOKEN"; + const string testRefreshToken = "OAUTH_REFRESH_TOKEN"; + const string testResource = "https://git.example.com/foo"; + const string expectedRefreshTokenService = "https://refresh_token.git.example.com/foo"; + + var authMode = OAuthAuthenticationModes.Browser; + string[] scopes = { "code:write", "code:read" }; + string clientId = "3eadfc62-9e91-45d3-8c60-20ccd6d0c7cf"; + string clientSecret = "C1DA8B93CCB5F5B93DA"; + string redirectUri = "http://localhost"; + string authzEndpoint = "/oauth/authorize"; + string tokenEndpoint = "/oauth/token"; + string deviceEndpoint = "/oauth/device"; + + string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}"; + + var context = new TestCommandContext + { + Git = + { + Configuration = + { + Global = + { + [GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { clientId }, + [GetKey(Constants.GitConfiguration.Credential.OAuthClientSecret)] = new[] { clientSecret }, + [GetKey(Constants.GitConfiguration.Credential.OAuthRedirectUri)] = new[] { redirectUri }, + [GetKey(Constants.GitConfiguration.Credential.OAuthScopes)] = new[] { string.Join(' ', scopes) }, + [GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { authzEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { tokenEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthDeviceEndpoint)] = new[] { deviceEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthDefaultUserName)] = new[] { testUserName }, + } + } + }, + Settings = + { + RemoteUri = new Uri(testResource) + } + }; + + var basicAuthMock = new Mock(); + var wiaAuthMock = new Mock(); + var oauthMock = new Mock(); + oauthMock.Setup(x => + x.GetAuthenticationModeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(authMode); + oauthMock.Setup(x => x.GetTokenByBrowserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new OAuth2TokenResult(testAcessToken, "access_token") + { + Scopes = scopes, + RefreshToken = testRefreshToken + }); + + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); + + ICredential credential = await provider.GenerateCredentialAsync(input); + + Assert.NotNull(credential); + Assert.Equal(testUserName, credential.Account); + Assert.Equal(testAcessToken, credential.Password); + + Assert.True(context.CredentialStore.TryGet(expectedRefreshTokenService, null, out TestCredential refreshToken)); + Assert.Equal(testUserName, refreshToken.Account); + Assert.Equal(testRefreshToken, refreshToken.Password); + + oauthMock.Verify(x => x.GetAuthenticationModeAsync(testResource, OAuthAuthenticationModes.All), Times.Once); + oauthMock.Verify(x => x.GetTokenByBrowserAsync(It.IsAny(), scopes), Times.Once); + oauthMock.Verify(x => x.GetTokenByDeviceCodeAsync(It.IsAny(), scopes), Times.Never); + wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny()), Times.Never); + basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + #region Helpers private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool wiaSupported) @@ -199,8 +288,9 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool var wiaAuthMock = new Mock(); wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny())) .ReturnsAsync(wiaSupported); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); @@ -230,8 +320,9 @@ private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(bool var wiaAuthMock = new Mock(); wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny())) .ReturnsAsync(wiaSupported); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); diff --git a/src/shared/Core.Tests/GenericOAuthConfigTests.cs b/src/shared/Core.Tests/GenericOAuthConfigTests.cs new file mode 100644 index 000000000..08dfacab4 --- /dev/null +++ b/src/shared/Core.Tests/GenericOAuthConfigTests.cs @@ -0,0 +1,61 @@ +using System; +using GitCredentialManager.Tests.Objects; +using Xunit; + +namespace GitCredentialManager.Tests +{ + public class GenericOAuthConfigTests + { + [Fact] + public void GenericOAuthConfig_TryGet_Valid_ReturnsTrue() + { + var remoteUri = new Uri("https://example.com"); + const string expectedClientId = "115845b0-77f8-4c06-a3dc-7d277381fad1"; + const string expectedClientSecret = "4D35385D9F24"; + const string expectedUserName = "TEST_USER"; + const string authzEndpoint = "/oauth/authorize"; + const string tokenEndpoint = "/oauth/token"; + const string deviceEndpoint = "/oauth/device"; + string[] expectedScopes = { "scope1", "scope2" }; + var expectedRedirectUri = new Uri("http://localhost:12345"); + var expectedAuthzEndpoint = new Uri(remoteUri, authzEndpoint); + var expectedTokenEndpoint = new Uri(remoteUri, tokenEndpoint); + var expectedDeviceEndpoint = new Uri(remoteUri, deviceEndpoint); + + string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}"; + + var trace = new NullTrace(); + var settings = new TestSettings + { + GitConfiguration = new TestGitConfiguration + { + Global = + { + [GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { expectedClientId }, + [GetKey(Constants.GitConfiguration.Credential.OAuthClientSecret)] = new[] { expectedClientSecret }, + [GetKey(Constants.GitConfiguration.Credential.OAuthRedirectUri)] = new[] { expectedRedirectUri.ToString() }, + [GetKey(Constants.GitConfiguration.Credential.OAuthScopes)] = new[] { string.Join(' ', expectedScopes) }, + [GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { authzEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { tokenEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthDeviceEndpoint)] = new[] { deviceEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthDefaultUserName)] = new[] { expectedUserName }, + } + }, + RemoteUri = remoteUri + }; + + bool result = GenericOAuthConfig.TryGet(trace, settings, remoteUri, out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(expectedClientId, config.ClientId); + Assert.Equal(expectedClientSecret, config.ClientSecret); + Assert.Equal(expectedRedirectUri, config.RedirectUri); + Assert.Equal(expectedScopes, config.Scopes); + Assert.Equal(expectedAuthzEndpoint, config.Endpoints.AuthorizationEndpoint); + Assert.Equal(expectedTokenEndpoint, config.Endpoints.TokenEndpoint); + Assert.Equal(expectedDeviceEndpoint, config.Endpoints.DeviceAuthorizationEndpoint); + Assert.Equal(expectedUserName, config.DefaultUserName); + Assert.True(config.UseAuthHeader); + } + } +} diff --git a/src/shared/Core.Tests/StringExtensionsTests.cs b/src/shared/Core.Tests/StringExtensionsTests.cs index ba9c32ccc..24fb99b21 100644 --- a/src/shared/Core.Tests/StringExtensionsTests.cs +++ b/src/shared/Core.Tests/StringExtensionsTests.cs @@ -245,6 +245,7 @@ public void StringExtensions_TrimUntilLastIndexOf_Character_Null_ThrowsArgumentN [InlineData("foo://", "://", "")] [InlineData("foo://bar", "://", "bar")] [InlineData("foo://bar/", "://", "bar/")] + [InlineData("foo:/bar/baz", ":", "/bar/baz")] public void StringExtensions_TrimUntilLastIndexOf_String(string input, string trim, string expected) { string actual = StringExtensions.TrimUntilLastIndexOf(input, trim); diff --git a/src/shared/Core.Tests/Trace2Tests.cs b/src/shared/Core.Tests/Trace2Tests.cs new file mode 100644 index 000000000..da3d6d95a --- /dev/null +++ b/src/shared/Core.Tests/Trace2Tests.cs @@ -0,0 +1,58 @@ +using System; +using System.Text.RegularExpressions; +using GitCredentialManager.Tests.Objects; +using Xunit; + +namespace GitCredentialManager.Tests; + +public class Trace2Tests +{ + [PlatformTheory(Platforms.Posix)] + [InlineData("af_unix:foo", "foo")] + [InlineData("af_unix:stream:foo-bar", "foo-bar")] + [InlineData("af_unix:dgram:foo-bar-baz", "foo-bar-baz")] + public void TryParseEventTarget_Posix_Returns_Expected_Value(string input, string expected) + { + var environment = new TestEnvironment(); + var settings = new TestSettings(); + + var trace2 = new Trace2(environment, settings.GetTrace2Settings(), new []{""}, DateTimeOffset.UtcNow); + var isSuccessful = trace2.TryGetPipeName(input, out var actual); + + Assert.True(isSuccessful); + Assert.Matches(actual, expected); + } + + [PlatformTheory(Platforms.Windows)] + [InlineData("\\\\.\\pipe\\git-foo", "git-foo")] + [InlineData("\\\\.\\pipe\\git-foo-bar", "git-foo-bar")] + [InlineData("\\\\.\\pipe\\foo\\git-bar", "git-bar")] + public void TryParseEventTarget_Windows_Returns_Expected_Value(string input, string expected) + { + var environment = new TestEnvironment(); + var settings = new TestSettings(); + + var trace2 = new Trace2(environment, settings.GetTrace2Settings(), new []{""}, DateTimeOffset.UtcNow); + var isSuccessful = trace2.TryGetPipeName(input, out var actual); + + Assert.True(isSuccessful); + Assert.Matches(actual, expected); + } + + [Theory] + [InlineData("20190408T191610.507018Z-H9b68c35f-P000059a8")] + [InlineData("")] + public void SetSid_Envar_Returns_Expected_Value(string parentSid) + { + Regex rx = new Regex(@$"{parentSid}\/[\d\w-]*"); + + var environment = new TestEnvironment(); + environment.Variables.Add("GIT_TRACE2_PARENT_SID", parentSid); + + var settings = new TestSettings(); + var trace2 = new Trace2(environment, settings.GetTrace2Settings(), new []{""}, DateTimeOffset.UtcNow); + var sid = trace2.SetSid(); + + Assert.Matches(rx, sid); + } +} diff --git a/src/shared/Core.Tests/TraceUtilsTests.cs b/src/shared/Core.Tests/TraceUtilsTests.cs new file mode 100644 index 000000000..9ab18c215 --- /dev/null +++ b/src/shared/Core.Tests/TraceUtilsTests.cs @@ -0,0 +1,18 @@ +using System; +using System.IO; +using System.Text; +using Xunit; + +namespace GitCredentialManager.Tests; + +public class TraceUtilsTests +{ + [Theory] + [InlineData("/foo/bar/baz/boo", 10, "...baz/boo")] + [InlineData("thisfileshouldbetruncated", 12, "...truncated")] + public void FormatSource_ReturnsExpectedSourceValues(string path, int sourceColumnMaxWidth, string expectedSource) + { + string actualSource = TraceUtils.FormatSource(path, sourceColumnMaxWidth); + Assert.Equal(actualSource, expectedSource); + } +} \ No newline at end of file diff --git a/src/shared/Core.UI/Commands/DeviceCodeCommand.cs b/src/shared/Core.UI/Commands/DeviceCodeCommand.cs new file mode 100644 index 000000000..863e24a1d --- /dev/null +++ b/src/shared/Core.UI/Commands/DeviceCodeCommand.cs @@ -0,0 +1,52 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.UI.ViewModels; + +namespace GitCredentialManager.UI.Commands +{ + public abstract class DeviceCodeCommand : HelperCommand + { + protected DeviceCodeCommand(ICommandContext context) + : base(context, "device", "Show device code prompt.") + { + AddOption( + new Option("--code", "User code.") + ); + + AddOption( + new Option("--url", "Verification URL.") + ); + + AddOption( + new Option("--no-logo", "Hide the Git Credential Manager logo and logotype.") + ); + + Handler = CommandHandler.Create(ExecuteAsync); + } + + private async Task ExecuteAsync(string code, string url, bool noLogo) + { + var viewModel = new DeviceCodeViewModel(Context.Environment) + { + UserCode = code, + VerificationUrl = url, + }; + + viewModel.ShowProductHeader = !noLogo; + + await ShowAsync(viewModel, CancellationToken.None); + + if (!viewModel.WindowResult) + { + throw new Exception("User cancelled dialog."); + } + + return 0; + } + + protected abstract Task ShowAsync(DeviceCodeViewModel viewModel, CancellationToken ct); + } +} diff --git a/src/shared/Core.UI/Commands/OAuthCommand.cs b/src/shared/Core.UI/Commands/OAuthCommand.cs new file mode 100644 index 000000000..aa48d7eb1 --- /dev/null +++ b/src/shared/Core.UI/Commands/OAuthCommand.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.Authentication; +using GitCredentialManager.UI.ViewModels; + +namespace GitCredentialManager.UI.Commands +{ + public abstract class OAuthCommand : HelperCommand + { + protected OAuthCommand(ICommandContext context) + : base(context, "oauth", "Show OAuth authentication prompt.") + { + AddOption( + new Option("--title", "Window title (optional).") + ); + + AddOption( + new Option("--resource", "Resource name or URL (optional).") + ); + + AddOption( + new Option("--browser", "Show browser authentication option.") + ); + + AddOption( + new Option("--device-code", "Show device code authentication option.") + ); + + AddOption( + new Option("--no-logo", "Hide the Git Credential Manager logo and logotype.") + ); + + Handler = CommandHandler.Create(ExecuteAsync); + } + + private class CommandOptions + { + public string Title { get; set; } + public string Resource { get; set; } + public bool Browser { get; set; } + public bool DeviceCode { get; set; } + public bool NoLogo { get; set; } + } + + private async Task ExecuteAsync(CommandOptions options) + { + var viewModel = new OAuthViewModel(); + + viewModel.Title = !string.IsNullOrWhiteSpace(options.Title) + ? options.Title + : "Git Credential Manager"; + + viewModel.Description = !string.IsNullOrWhiteSpace(options.Resource) + ? $"Sign in to '{options.Resource}'" + : "Select a sign-in option"; + + viewModel.ShowBrowserLogin = options.Browser; + viewModel.ShowDeviceCodeLogin = options.DeviceCode; + viewModel.ShowProductHeader = !options.NoLogo; + + await ShowAsync(viewModel, CancellationToken.None); + + if (!viewModel.WindowResult) + { + throw new Exception("User cancelled dialog."); + } + + var result = new Dictionary(); + switch (viewModel.SelectedMode) + { + case OAuthAuthenticationModes.Browser: + result["mode"] = "browser"; + break; + + case OAuthAuthenticationModes.DeviceCode: + result["mode"] = "devicecode"; + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + WriteResult(result); + return 0; + } + + protected abstract Task ShowAsync(OAuthViewModel viewModel, CancellationToken ct); + } +} diff --git a/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs b/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs index c93c8ff29..7f40b8bea 100644 --- a/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs +++ b/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs @@ -56,7 +56,7 @@ public string Description public bool ShowProductHeader { get => _showProductHeader; - set => _showProductHeader = value; + set => SetAndRaisePropertyChanged(ref _showProductHeader, value); } public RelayCommand SignInCommand diff --git a/src/shared/Core.UI/ViewModels/DeviceCodeViewModel.cs b/src/shared/Core.UI/ViewModels/DeviceCodeViewModel.cs new file mode 100644 index 000000000..d44a9f2c7 --- /dev/null +++ b/src/shared/Core.UI/ViewModels/DeviceCodeViewModel.cs @@ -0,0 +1,58 @@ +using System.Windows.Input; + +namespace GitCredentialManager.UI.ViewModels +{ + public class DeviceCodeViewModel : WindowViewModel + { + private readonly IEnvironment _environment; + + private ICommand _verificationUrlCommand; + private string _verificationUrl; + private string _userCode; + private bool _showProductHeader; + + public DeviceCodeViewModel() + { + // Constructor the XAML designer + } + + public DeviceCodeViewModel(IEnvironment environment) + { + EnsureArgument.NotNull(environment, nameof(environment)); + + _environment = environment; + + Title = "Device code authentication"; + VerificationUrlCommand = new RelayCommand(OpenVerificationUrl); + } + + private void OpenVerificationUrl() + { + BrowserUtils.OpenDefaultBrowser(_environment, VerificationUrl); + } + + public string UserCode + { + get => _userCode; + set => SetAndRaisePropertyChanged(ref _userCode, value); + } + + public string VerificationUrl + { + get => _verificationUrl; + set => SetAndRaisePropertyChanged(ref _verificationUrl, value); + } + + public ICommand VerificationUrlCommand + { + get => _verificationUrlCommand; + set => SetAndRaisePropertyChanged(ref _verificationUrlCommand, value); + } + + public bool ShowProductHeader + { + get => _showProductHeader; + set => SetAndRaisePropertyChanged(ref _showProductHeader, value); + } + } +} diff --git a/src/shared/Core.UI/ViewModels/OAuthViewModel.cs b/src/shared/Core.UI/ViewModels/OAuthViewModel.cs new file mode 100644 index 000000000..607642f31 --- /dev/null +++ b/src/shared/Core.UI/ViewModels/OAuthViewModel.cs @@ -0,0 +1,70 @@ +using GitCredentialManager.Authentication; + +namespace GitCredentialManager.UI.ViewModels +{ + public class OAuthViewModel : WindowViewModel + { + private string _description; + private bool _showProductHeader; + private bool _showBrowserLogin; + private bool _showDeviceCodeLogin; + private RelayCommand _signInBrowserCommand; + private RelayCommand _signInDeviceCodeCommand; + + public OAuthViewModel() + { + SignInBrowserCommand = new RelayCommand(SignInWithBrowser); + SignInDeviceCodeCommand = new RelayCommand(SignInWithDeviceCode); + } + + private void SignInWithBrowser() + { + SelectedMode = OAuthAuthenticationModes.Browser; + Accept(); + } + + private void SignInWithDeviceCode() + { + SelectedMode = OAuthAuthenticationModes.DeviceCode; + Accept(); + } + + public string Description + { + get => _description; + set => SetAndRaisePropertyChanged(ref _description, value); + } + + public bool ShowProductHeader + { + get => _showProductHeader; + set => SetAndRaisePropertyChanged(ref _showProductHeader, value); + } + + public bool ShowBrowserLogin + { + get => _showBrowserLogin; + set => SetAndRaisePropertyChanged(ref _showBrowserLogin, value); + } + + public bool ShowDeviceCodeLogin + { + get => _showDeviceCodeLogin; + set => SetAndRaisePropertyChanged(ref _showDeviceCodeLogin, value); + } + + public RelayCommand SignInBrowserCommand + { + get => _signInBrowserCommand; + set => SetAndRaisePropertyChanged(ref _signInBrowserCommand, value); + } + + public RelayCommand SignInDeviceCodeCommand + { + get => _signInDeviceCodeCommand; + set => SetAndRaisePropertyChanged(ref _signInDeviceCodeCommand, value); + } + + public OAuthAuthenticationModes SelectedMode { get; private set; } + } +} diff --git a/src/shared/Core/ApplicationBase.cs b/src/shared/Core/ApplicationBase.cs index c2e1c05b6..f5e2e25db 100644 --- a/src/shared/Core/ApplicationBase.cs +++ b/src/shared/Core/ApplicationBase.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Reflection; +using System.IO.Pipes; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -75,6 +77,9 @@ public Task RunAsync(string[] args) Context.Trace.WriteLine("Tracing of secrets is enabled. Trace output may contain sensitive information."); } + // Enable TRACE2 tracing + Context.Trace2.Start(Context.Streams.Error, Context.FileSystem, Context.ApplicationPath); + return RunInternalAsync(args); } @@ -82,18 +87,6 @@ public Task RunAsync(string[] args) #region Helpers - public static string GetEntryApplicationPath() - { - return PlatformUtils.GetNativeEntryPath() ?? - Process.GetCurrentProcess().MainModule?.FileName ?? - Environment.GetCommandLineArgs()[0]; - } - - public static string GetInstallationDirectory() - { - return AppContext.BaseDirectory; - } - /// /// Wait until a debugger has attached to the currently executing process. /// diff --git a/src/shared/Core/AssemblyUtils.cs b/src/shared/Core/AssemblyUtils.cs new file mode 100644 index 000000000..f2d66a753 --- /dev/null +++ b/src/shared/Core/AssemblyUtils.cs @@ -0,0 +1,24 @@ +using System.Reflection; + +namespace GitCredentialManager; + +public static class AssemblyUtils +{ + public static bool TryGetAssemblyVersion(out string version) + { + try + { + var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); + var assemblyVersionAttribute = assembly.GetCustomAttribute(); + version = assemblyVersionAttribute is null + ? assembly.GetName().Version.ToString() + : assemblyVersionAttribute.InformationalVersion; + return true; + } + catch + { + version = null; + return false; + } + } +} diff --git a/src/shared/Core/Authentication/OAuthAuthentication.cs b/src/shared/Core/Authentication/OAuthAuthentication.cs new file mode 100644 index 000000000..8da18faad --- /dev/null +++ b/src/shared/Core/Authentication/OAuthAuthentication.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.Authentication.OAuth; + +namespace GitCredentialManager.Authentication +{ + [Flags] + public enum OAuthAuthenticationModes + { + None = 0, + Browser = 1 << 0, + DeviceCode = 1 << 1, + + All = Browser | DeviceCode + } + + public interface IOAuthAuthentication + { + Task GetAuthenticationModeAsync(string resource, OAuthAuthenticationModes modes); + + Task GetTokenByBrowserAsync(OAuth2Client client, string[] scopes); + + Task GetTokenByDeviceCodeAsync(OAuth2Client client, string[] scopes); + } + + public class OAuthAuthentication : AuthenticationBase, IOAuthAuthentication + { + public OAuthAuthentication(ICommandContext context) + : base (context) { } + + public async Task GetAuthenticationModeAsync( + string resource, OAuthAuthenticationModes modes) + { + EnsureArgument.NotNullOrWhiteSpace(resource, nameof(resource)); + + ThrowIfUserInteractionDisabled(); + + // Browser requires a desktop session! + if (!Context.SessionManager.IsDesktopSession) + { + modes &= ~OAuthAuthenticationModes.Browser; + } + + // We need at least one mode! + if (modes == OAuthAuthenticationModes.None) + { + throw new ArgumentException(@$"Must specify at least one {nameof(OAuthAuthenticationModes)}", nameof(modes)); + } + + // If there is no mode choice to be made then just return that result + if (modes == OAuthAuthenticationModes.Browser || + modes == OAuthAuthenticationModes.DeviceCode) + { + return modes; + } + + if (Context.Settings.IsGuiPromptsEnabled && Context.SessionManager.IsDesktopSession && + TryFindHelperCommand(out string command, out string args)) + { + var promptArgs = new StringBuilder(args); + promptArgs.Append("oauth"); + + if (!string.IsNullOrWhiteSpace(resource)) + { + promptArgs.AppendFormat(" --resource {0}", QuoteCmdArg(resource)); + } + + if ((modes & OAuthAuthenticationModes.Browser) != 0) + { + promptArgs.Append(" --browser"); + } + + if ((modes & OAuthAuthenticationModes.DeviceCode) != 0) + { + promptArgs.Append(" --device-code"); + } + + IDictionary resultDict = await InvokeHelperAsync(command, promptArgs.ToString()); + + if (!resultDict.TryGetValue("mode", out string responseMode)) + { + throw new Exception("Missing 'mode' in response"); + } + + switch (responseMode.ToLowerInvariant()) + { + case "browser": + return OAuthAuthenticationModes.Browser; + + case "devicecode": + return OAuthAuthenticationModes.DeviceCode; + + default: + throw new Exception($"Unknown mode value in response '{responseMode}'"); + } + } + else + { + ThrowIfTerminalPromptsDisabled(); + + switch (modes) + { + case OAuthAuthenticationModes.Browser: + return OAuthAuthenticationModes.Browser; + + case OAuthAuthenticationModes.DeviceCode: + return OAuthAuthenticationModes.DeviceCode; + + default: + var menuTitle = $"Select an authentication method for '{resource}'"; + var menu = new TerminalMenu(Context.Terminal, menuTitle); + + TerminalMenuItem browserItem = null; + TerminalMenuItem deviceItem = null; + + if ((modes & OAuthAuthenticationModes.Browser) != 0) browserItem = menu.Add("Web browser"); + if ((modes & OAuthAuthenticationModes.DeviceCode) != 0) deviceItem = menu.Add("Device code"); + + // Default to the 'first' choice in the menu + TerminalMenuItem choice = menu.Show(0); + + if (choice == browserItem) goto case OAuthAuthenticationModes.Browser; + if (choice == deviceItem) goto case OAuthAuthenticationModes.DeviceCode; + + throw new Exception(); + } + } + } + + public async Task GetTokenByBrowserAsync(OAuth2Client client, string[] scopes) + { + ThrowIfUserInteractionDisabled(); + + // We require a desktop session to launch the user's default web browser + if (!Context.SessionManager.IsDesktopSession) + { + throw new InvalidOperationException("Browser authentication requires a desktop session"); + } + + var browserOptions = new OAuth2WebBrowserOptions(); + var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions); + var authCode = await client.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None); + return await client.GetTokenByAuthorizationCodeAsync(authCode, CancellationToken.None); + } + + public async Task GetTokenByDeviceCodeAsync(OAuth2Client client, string[] scopes) + { + ThrowIfUserInteractionDisabled(); + + OAuth2DeviceCodeResult dcr = await client.GetDeviceCodeAsync(scopes, CancellationToken.None); + + // If we have a desktop session show the device code in a dialog + if (Context.Settings.IsGuiPromptsEnabled && Context.SessionManager.IsDesktopSession && + TryFindHelperCommand(out string command, out string args)) + { + var promptArgs = new StringBuilder(args); + promptArgs.Append("device"); + promptArgs.AppendFormat(" --code {0} ", QuoteCmdArg(dcr.UserCode)); + promptArgs.AppendFormat(" --url {0}", QuoteCmdArg(dcr.VerificationUri.ToString())); + + var promptCts = new CancellationTokenSource(); + var tokenCts = new CancellationTokenSource(); + + // Show the dialog with the device code but don't await its closure + Task promptTask = InvokeHelperAsync(command, promptArgs.ToString(), null, promptCts.Token); + + // Start the request for an OAuth token but don't wait + Task tokenTask = client.GetTokenByDeviceCodeAsync(dcr, tokenCts.Token); + + Task t = await Task.WhenAny(promptTask, tokenTask); + + // If the dialog was closed the user wishes to cancel the request + if (t == promptTask) + { + tokenCts.Cancel(); + } + + OAuth2TokenResult tokenResult; + try + { + tokenResult = await tokenTask; + } + catch (OperationCanceledException) + { + throw new Exception("User canceled device code authentication"); + } + + // Close the dialog + promptCts.Cancel(); + + return tokenResult; + } + else + { + ThrowIfTerminalPromptsDisabled(); + + string deviceMessage = $"To complete authentication please visit {dcr.VerificationUri} and enter the following code:" + + Environment.NewLine + + dcr.UserCode; + Context.Terminal.WriteLine(deviceMessage); + + return await client.GetTokenByDeviceCodeAsync(dcr, CancellationToken.None); + } + } + + private bool TryFindHelperCommand(out string command, out string args) + { + return TryFindHelperCommand( + Constants.EnvironmentVariables.GcmUiHelper, + Constants.GitConfiguration.Credential.UiHelper, + Constants.DefaultUiHelper, + out command, + out args); + } + } +} diff --git a/src/shared/Core/CommandContext.cs b/src/shared/Core/CommandContext.cs index 0ccd7bc33..d8a45fcb6 100644 --- a/src/shared/Core/CommandContext.cs +++ b/src/shared/Core/CommandContext.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using GitCredentialManager.Interop.Linux; using GitCredentialManager.Interop.MacOS; @@ -15,7 +17,7 @@ public interface ICommandContext : IDisposable /// /// Absolute path the application entry executable. /// - string ApplicationPath { get; } + string ApplicationPath { get; set; } /// /// Absolute path to the Git Credential Manager installation directory. @@ -47,6 +49,11 @@ public interface ICommandContext : IDisposable /// ITrace Trace { get; } + /// + /// Application TRACE2 tracing system. + /// + ITrace2 Trace2 { get; } + /// /// File system abstraction (exists mainly for testing). /// @@ -78,12 +85,11 @@ public interface ICommandContext : IDisposable /// public class CommandContext : DisposableObject, ICommandContext { - public CommandContext(string appPath, string installDir) + public CommandContext(string[] argv) { - EnsureArgument.NotNullOrWhiteSpace(appPath, nameof (appPath)); - - ApplicationPath = appPath; - InstallationDirectory = installDir; + var applicationStartTime = DateTimeOffset.UtcNow; + ApplicationPath = GetEntryApplicationPath(); + InstallationDirectory = GetInstallationDirectory(); Streams = new StandardStreams(); Trace = new Trace(); @@ -139,6 +145,7 @@ public CommandContext(string appPath, string installDir) throw new PlatformNotSupportedException(); } + Trace2 = new Trace2(Environment, Settings.GetTrace2Settings(), argv, applicationStartTime); HttpClientFactory = new HttpClientFactory(FileSystem, Trace, Settings, Streams); CredentialStore = new CredentialStore(this); } @@ -177,7 +184,7 @@ private static string GetGitPath(IEnvironment environment, IFileSystem fileSyste #region ICommandContext - public string ApplicationPath { get; } + public string ApplicationPath { get; set; } public string InstallationDirectory { get; } @@ -191,6 +198,8 @@ private static string GetGitPath(IEnvironment environment, IFileSystem fileSyste public ITrace Trace { get; } + public ITrace2 Trace2 { get; } + public IFileSystem FileSystem { get; } public ICredentialStore CredentialStore { get; } @@ -214,5 +223,17 @@ protected override void ReleaseManagedResources() } #endregion + + public static string GetEntryApplicationPath() + { + return PlatformUtils.GetNativeEntryPath() ?? + Process.GetCurrentProcess().MainModule?.FileName ?? + System.Environment.GetCommandLineArgs()[0]; + } + + public static string GetInstallationDirectory() + { + return AppContext.BaseDirectory; + } } } diff --git a/src/shared/Core/Commands/DiagnoseCommand.cs b/src/shared/Core/Commands/DiagnoseCommand.cs index 20a646dd8..b8b4aaa56 100644 --- a/src/shared/Core/Commands/DiagnoseCommand.cs +++ b/src/shared/Core/Commands/DiagnoseCommand.cs @@ -86,7 +86,7 @@ private async Task ExecuteAsync(string output) fullLog.WriteLine($"AppPath: {_context.ApplicationPath}"); fullLog.WriteLine($"InstallDir: {_context.InstallationDirectory}"); fullLog.WriteLine( - TryGetAssemblyVersion(out string version) + AssemblyUtils.TryGetAssemblyVersion(out string version) ? $"Version: {version}" : "Version: [!] Failed to get version information [!]" ); @@ -198,24 +198,6 @@ private async Task ExecuteAsync(string output) return numFailed; } - private bool TryGetAssemblyVersion(out string version) - { - try - { - var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); - var assemblyVersionAttribute = assembly.GetCustomAttribute(); - version = assemblyVersionAttribute is null - ? assembly.GetName().Version.ToString() - : assemblyVersionAttribute.InformationalVersion; - return true; - } - catch - { - version = null; - return false; - } - } - private static class ConsoleEx { public static void WriteLineIndent(string str) diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 54c8b1246..6fe006716 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -54,6 +54,8 @@ public static class EnvironmentVariables public const string GcmAuthority = "GCM_AUTHORITY"; public const string GitTerminalPrompts = "GIT_TERMINAL_PROMPT"; public const string GcmAllowWia = "GCM_ALLOW_WINDOWSAUTH"; + public const string GitTrace2Event = "GIT_TRACE2_EVENT"; + public const string GitTrace2Normal = "GIT_TRACE2"; /* * Unlike other environment variables, these proxy variables are normally lowercase only. @@ -89,6 +91,16 @@ public static class EnvironmentVariables public const string GcmAutoDetectTimeout = "GCM_AUTODETECT_TIMEOUT"; public const string GcmGuiPromptsEnabled = "GCM_GUI_PROMPT"; public const string GcmUiHelper = "GCM_UI_HELPER"; + public const string OAuthAuthenticationModes = "GCM_OAUTH_AUTHMODES"; + public const string OAuthClientId = "GCM_OAUTH_CLIENTID"; + public const string OAuthClientSecret = "GCM_OAUTH_CLIENTSECRET"; + public const string OAuthRedirectUri = "GCM_OAUTH_REDIRECTURI"; + public const string OAuthScopes = "GCM_OAUTH_SCOPES"; + public const string OAuthAuthzEndpoint = "GCM_OAUTH_AUTHORIZE_ENDPOINT"; + public const string OAuthTokenEndpoint = "GCM_OAUTH_TOKEN_ENDPOINT"; + public const string OAuthDeviceEndpoint = "GCM_OAUTH_DEVICE_ENDPOINT"; + public const string OAuthClientAuthHeader = "GCM_OAUTH_USE_CLIENT_AUTH_HEADER"; + public const string OAuthDefaultUserName = "GCM_OAUTH_DEFAULT_USERNAME"; } public static class Http @@ -125,6 +137,17 @@ public static class Credential public const string AutoDetectTimeout = "autoDetectTimeout"; public const string GuiPromptsEnabled = "guiPrompt"; public const string UiHelper = "uiHelper"; + + public const string OAuthAuthenticationModes = "oauthAuthModes"; + public const string OAuthClientId = "oauthClientId"; + public const string OAuthClientSecret = "oauthClientSecret"; + public const string OAuthRedirectUri = "oauthRedirectUri"; + public const string OAuthScopes = "oauthScopes"; + public const string OAuthAuthzEndpoint = "oauthAuthorizeEndpoint"; + public const string OAuthTokenEndpoint = "oauthTokenEndpoint"; + public const string OAuthDeviceEndpoint = "oauthDeviceEndpoint"; + public const string OAuthClientAuthHeader = "oauthUseClientAuthHeader"; + public const string OAuthDefaultUserName = "oauthDefaultUserName"; } public static class Http @@ -143,6 +166,13 @@ public static class Remote public const string FetchUrl = "url"; public const string PushUrl = "pushUrl"; } + + public static class Trace2 + { + public const string SectionName = "trace2"; + public const string EventTarget = "eventtarget"; + public const string NormalTarget = "normaltarget"; + } } public static class WindowsRegistry diff --git a/src/shared/Core/EnvironmentBase.cs b/src/shared/Core/EnvironmentBase.cs index a2aa36cf2..63790589a 100644 --- a/src/shared/Core/EnvironmentBase.cs +++ b/src/shared/Core/EnvironmentBase.cs @@ -56,6 +56,15 @@ public interface IEnvironment /// Working directory for the new process. /// object ready to start. Process CreateProcess(string path, string args, bool useShellExecute, string workingDirectory); + + /// + /// Set an environment variable at the specified target level. + /// + /// Name of the environment variable to set. + /// Value of the environment variable to set. + /// Target level of environment variable to set (Machine, Process, or User). + void SetEnvironmentVariable(string variable, string value, + EnvironmentVariableTarget target = EnvironmentVariableTarget.Process); } public abstract class EnvironmentBase : IEnvironment @@ -141,6 +150,16 @@ internal virtual bool TryLocateExecutable(string program, ICollection pa path = null; return false; } + + public void SetEnvironmentVariable(string variable, string value, + EnvironmentVariableTarget target = EnvironmentVariableTarget.Process) + { + if (Variables.Keys.Contains(variable)) return; + Environment.SetEnvironmentVariable(variable, value, target); + Variables = GetCurrentVariables(); + } + + protected abstract IReadOnlyDictionary GetCurrentVariables(); } public static class EnvironmentExtensions diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index 3f98eab0f..2b05537e1 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using GitCredentialManager.Authentication; +using GitCredentialManager.Authentication.OAuth; namespace GitCredentialManager { @@ -10,24 +13,27 @@ public class GenericHostProvider : HostProvider { private readonly IBasicAuthentication _basicAuth; private readonly IWindowsIntegratedAuthentication _winAuth; + private readonly IOAuthAuthentication _oauth; public GenericHostProvider(ICommandContext context) - : this(context, new BasicAuthentication(context), new WindowsIntegratedAuthentication(context)) { } + : this(context, new BasicAuthentication(context), new WindowsIntegratedAuthentication(context), + new OAuthAuthentication(context)) { } public GenericHostProvider(ICommandContext context, IBasicAuthentication basicAuth, - IWindowsIntegratedAuthentication winAuth) + IWindowsIntegratedAuthentication winAuth, + IOAuthAuthentication oauth) : base(context) { EnsureArgument.NotNull(basicAuth, nameof(basicAuth)); EnsureArgument.NotNull(winAuth, nameof(winAuth)); + EnsureArgument.NotNull(oauth, nameof(oauth)); _basicAuth = basicAuth; _winAuth = winAuth; + _oauth = oauth; } - #region HostProvider - public override string Id => "generic"; public override string Name => "Generic"; @@ -50,12 +56,29 @@ public override async Task GenerateCredentialAsync(InputArguments i Uri uri = input.GetRemoteUri(); - // Determine the if the host supports Windows Integration Authentication (WIA) + // Determine the if the host supports Windows Integration Authentication (WIA) or OAuth if (!StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "http") && !StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "https")) { - // Cannot check WIA support for non-HTTP based protocols + // Cannot check WIA or OAuth support for non-HTTP based protocols + } + // Check for an OAuth configuration for this remote + else if (GenericOAuthConfig.TryGet(Context.Trace, Context.Settings, uri, out GenericOAuthConfig oauthConfig)) + { + Context.Trace.WriteLine($"Found generic OAuth configuration for '{uri}':"); + Context.Trace.WriteLine($"\tAuthzEndpoint = {oauthConfig.Endpoints.AuthorizationEndpoint}"); + Context.Trace.WriteLine($"\tTokenEndpoint = {oauthConfig.Endpoints.TokenEndpoint}"); + Context.Trace.WriteLine($"\tDeviceEndpoint = {oauthConfig.Endpoints.DeviceAuthorizationEndpoint}"); + Context.Trace.WriteLine($"\tClientId = {oauthConfig.ClientId}"); + Context.Trace.WriteLine($"\tClientSecret = {oauthConfig.ClientSecret}"); + Context.Trace.WriteLine($"\tRedirectUri = {oauthConfig.RedirectUri}"); + Context.Trace.WriteLine($"\tScopes = [{string.Join(", ", oauthConfig.Scopes)}]"); + Context.Trace.WriteLine($"\tUseAuthHeader = {oauthConfig.UseAuthHeader}"); + Context.Trace.WriteLine($"\tDefaultUserName = {oauthConfig.DefaultUserName}"); + + return await GetOAuthAccessToken(uri, input.UserName, oauthConfig); } + // Try detecting WIA for this remote, if permitted else if (IsWindowsAuthAllowed) { if (PlatformUtils.IsWindows()) @@ -86,10 +109,103 @@ public override async Task GenerateCredentialAsync(InputArguments i Context.Trace.WriteLine("Windows Integrated Authentication detection has been disabled."); } + // Use basic authentication Context.Trace.WriteLine("Prompting for basic credentials..."); return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName); } + private async Task GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config) + { + // TODO: Determined user info from a webcall? ID token? Need OIDC support + string oauthUser = userName ?? config.DefaultUserName; + + var client = new OAuth2Client( + HttpClient, + config.Endpoints, + config.ClientId, + config.RedirectUri, + config.ClientSecret, + Context.Trace, + config.UseAuthHeader); + + // + // Prepend "refresh_token" to the hostname to get a (hopefully) unique service name that + // doesn't clash with an existing credential service. + // + // Appending "/refresh_token" to the end of the remote URI may not always result in a unique + // service because users may set credential.useHttpPath and include "/refresh_token" as a + // path name. + // + string refreshService = new UriBuilder(remoteUri) { Host = $"refresh_token.{remoteUri.Host}" } + .Uri.AbsoluteUri.TrimEnd('/'); + + // Try to use a refresh token if we have one + ICredential refreshToken = Context.CredentialStore.Get(refreshService, userName); + if (refreshToken != null) + { + var refreshResult = await client.GetTokenByRefreshTokenAsync(refreshToken.Password, CancellationToken.None); + + // Store new refresh token if we have been given one + if (!string.IsNullOrWhiteSpace(refreshResult.RefreshToken)) + { + Context.CredentialStore.AddOrUpdate(refreshService, refreshToken.Account, refreshToken.Password); + } + + // Return the new access token + return new GitCredential(oauthUser,refreshResult.AccessToken); + } + + // Determine which interactive OAuth mode to use. Start by checking for mode preference in config + var supportedModes = OAuthAuthenticationModes.All; + if (Context.Settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthAuthenticationModes, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthAuthenticationModes, + out string authModesStr)) + { + if (Enum.TryParse(authModesStr, true, out supportedModes) && supportedModes != OAuthAuthenticationModes.None) + { + Context.Trace.WriteLine($"Supported authentication modes override present: {supportedModes}"); + } + else + { + Context.Trace.WriteLine($"Invalid value for supported authentication modes override setting: '{authModesStr}'"); + } + } + + // If the server doesn't support device code we need to remove it as an option here + if (!config.SupportsDeviceCode) + { + supportedModes &= ~OAuthAuthenticationModes.DeviceCode; + } + + // Prompt the user to select a mode + OAuthAuthenticationModes mode = await _oauth.GetAuthenticationModeAsync(remoteUri.ToString(), supportedModes); + + OAuth2TokenResult tokenResult; + switch (mode) + { + case OAuthAuthenticationModes.Browser: + tokenResult = await _oauth.GetTokenByBrowserAsync(client, config.Scopes); + break; + + case OAuthAuthenticationModes.DeviceCode: + tokenResult = await _oauth.GetTokenByDeviceCodeAsync(client, config.Scopes); + break; + + default: + throw new Exception("No authentication mode selected!"); + } + + // Store the refresh token if we have one + if (!string.IsNullOrWhiteSpace(tokenResult.RefreshToken)) + { + Context.CredentialStore.AddOrUpdate(refreshService, oauthUser, tokenResult.RefreshToken); + } + + return new GitCredential(oauthUser, tokenResult.AccessToken); + } + /// /// Check if the user permits checking for Windows Integrated Authentication. /// @@ -115,12 +231,14 @@ private bool IsWindowsAuthAllowed } } + private HttpClient _httpClient; + private HttpClient HttpClient => _httpClient ?? (_httpClient = Context.HttpClientFactory.CreateClient()); + protected override void ReleaseManagedResources() { _winAuth.Dispose(); + _httpClient?.Dispose(); base.ReleaseManagedResources(); } - - #endregion } } diff --git a/src/shared/Core/GenericOAuthConfig.cs b/src/shared/Core/GenericOAuthConfig.cs new file mode 100644 index 000000000..0e2a74b75 --- /dev/null +++ b/src/shared/Core/GenericOAuthConfig.cs @@ -0,0 +1,138 @@ +using System; +using GitCredentialManager.Authentication.OAuth; + +namespace GitCredentialManager +{ + public class GenericOAuthConfig + { + public static bool TryGet(ITrace trace, ISettings settings, Uri remoteUri, out GenericOAuthConfig config) + { + config = new GenericOAuthConfig(); + + if (!settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthAuthzEndpoint, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthAuthzEndpoint, + out string authzEndpoint) || + !Uri.TryCreate(remoteUri, authzEndpoint, out Uri authzEndpointUri)) + { + trace.WriteLine($"Invalid OAuth configuration - missing/invalid authorize endpoint: {authzEndpoint}"); + config = null; + return false; + } + + if (!settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthTokenEndpoint, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthTokenEndpoint, + out string tokenEndpoint) || + !Uri.TryCreate(remoteUri, tokenEndpoint, out Uri tokenEndpointUri)) + { + trace.WriteLine($"Invalid OAuth configuration - missing/invalid token endpoint: {tokenEndpoint}"); + config = null; + return false; + } + + // Device code endpoint is optional + Uri deviceEndpointUri = null; + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthDeviceEndpoint, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthDeviceEndpoint, + out string deviceEndpoint)) + { + if (!Uri.TryCreate(remoteUri, deviceEndpoint, out deviceEndpointUri)) + { + trace.WriteLine($"Invalid OAuth configuration - invalid device endpoint: {deviceEndpoint}"); + } + } + + config.Endpoints = new OAuth2ServerEndpoints(authzEndpointUri, tokenEndpointUri) + { + DeviceAuthorizationEndpoint = deviceEndpointUri + }; + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthClientId, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthClientId, + out string clientId)) + { + config.ClientId = clientId; + } + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthClientSecret, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthClientSecret, + out string clientSecret)) + { + config.ClientSecret = clientSecret; + } + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthRedirectUri, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthRedirectUri, + out string redirectUrl) && + Uri.TryCreate(redirectUrl, UriKind.Absolute, out Uri redirectUri)) + { + config.RedirectUri = redirectUri; + } + else + { + trace.WriteLine($"Invalid OAuth configuration - missing/invalid redirect URI: {redirectUrl}"); + config = null; + return false; + } + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthScopes, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthScopes, + out string scopesStr) && !string.IsNullOrWhiteSpace(scopesStr)) + { + config.Scopes = scopesStr.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + } + else + { + config.Scopes = Array.Empty(); + } + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthClientAuthHeader, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthClientAuthHeader, + out string useHeader)) + { + config.UseAuthHeader = useHeader.IsTruthy(); + } + else + { + // Default to true + config.UseAuthHeader = true; + } + + config.DefaultUserName = settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthDefaultUserName, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthDefaultUserName, + out string userName) + ? userName + : "OAUTH_USER"; + + return true; + } + + + public OAuth2ServerEndpoints Endpoints { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public Uri RedirectUri { get; set; } + public string[] Scopes { get; set; } + public bool UseAuthHeader { get; set; } + public string DefaultUserName { get; set; } + + public bool SupportsDeviceCode => Endpoints.DeviceAuthorizationEndpoint != null; + } +} diff --git a/src/shared/Core/ITrace2Writer.cs b/src/shared/Core/ITrace2Writer.cs new file mode 100644 index 000000000..4474555cd --- /dev/null +++ b/src/shared/Core/ITrace2Writer.cs @@ -0,0 +1,10 @@ +using System; + +namespace GitCredentialManager; + +public interface ITrace2Writer : IDisposable +{ + bool Failed { get; } + + void Write(Trace2Message message); +} diff --git a/src/shared/Core/Interop/Linux/SecretServiceCollection.cs b/src/shared/Core/Interop/Linux/SecretServiceCollection.cs index 6939ea85d..6e87342a7 100644 --- a/src/shared/Core/Interop/Linux/SecretServiceCollection.cs +++ b/src/shared/Core/Interop/Linux/SecretServiceCollection.cs @@ -291,7 +291,6 @@ private static unsafe ICredential CreateCredentialFromItem(SecretItem* item) if (accountKeyPtr != IntPtr.Zero) Marshal.FreeHGlobal(accountKeyPtr); if (serviceKeyPtr != IntPtr.Zero) Marshal.FreeHGlobal(serviceKeyPtr); if (value != null) secret_value_unref(value); - if (passwordPtr != IntPtr.Zero) secret_password_free(passwordPtr); if (error != null) g_error_free(error); } } diff --git a/src/shared/Core/Interop/Posix/PosixEnvironment.cs b/src/shared/Core/Interop/Posix/PosixEnvironment.cs index da2e76c72..c725c18e1 100644 --- a/src/shared/Core/Interop/Posix/PosixEnvironment.cs +++ b/src/shared/Core/Interop/Posix/PosixEnvironment.cs @@ -1,18 +1,18 @@ using System; using System.Collections.Generic; +using System.Linq; namespace GitCredentialManager.Interop.Posix { public class PosixEnvironment : EnvironmentBase { public PosixEnvironment(IFileSystem fileSystem) - : this(fileSystem, GetCurrentVariables()) { } + : this(fileSystem, null) { } internal PosixEnvironment(IFileSystem fileSystem, IReadOnlyDictionary variables) : base(fileSystem) { - EnsureArgument.NotNull(variables, nameof(variables)); - Variables = variables; + Variables = variables ?? GetCurrentVariables(); } #region EnvironmentBase @@ -34,7 +34,7 @@ protected override string[] SplitPathVariable(string value) #endregion - private static IReadOnlyDictionary GetCurrentVariables() + protected override IReadOnlyDictionary GetCurrentVariables() { var dict = new Dictionary(); var variables = Environment.GetEnvironmentVariables(); diff --git a/src/shared/Core/Interop/Windows/WindowsEnvironment.cs b/src/shared/Core/Interop/Windows/WindowsEnvironment.cs index b85979d66..67aea7d64 100644 --- a/src/shared/Core/Interop/Windows/WindowsEnvironment.cs +++ b/src/shared/Core/Interop/Windows/WindowsEnvironment.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Text; namespace GitCredentialManager.Interop.Windows @@ -9,13 +10,12 @@ namespace GitCredentialManager.Interop.Windows public class WindowsEnvironment : EnvironmentBase { public WindowsEnvironment(IFileSystem fileSystem) - : this(fileSystem, GetCurrentVariables()) { } + : this(fileSystem, null) { } internal WindowsEnvironment(IFileSystem fileSystem, IReadOnlyDictionary variables) : base(fileSystem) { - EnsureArgument.NotNull(variables, nameof(variables)); - Variables = variables; + Variables = variables ?? GetCurrentVariables(); } #region EnvironmentBase @@ -84,7 +84,7 @@ public override Process CreateProcess(string path, string args, bool useShellExe #endregion - private static IReadOnlyDictionary GetCurrentVariables() + protected override IReadOnlyDictionary GetCurrentVariables() { // On Windows it is technically possible to get env vars which differ only by case // even though the general assumption is that they are case insensitive on Windows. diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs index 12b104515..3eafa0baa 100644 --- a/src/shared/Core/Settings.cs +++ b/src/shared/Core/Settings.cs @@ -166,6 +166,12 @@ public interface ISettings : IDisposable /// of host provider auto-detection. Use a zero or negative value to disable probing. /// int AutoDetectProviderTimeout { get; } + + /// + /// Get TRACE2 settings. + /// + /// TRACE2 settings object. + Trace2Settings GetTrace2Settings(); } public class ProxyConfiguration @@ -504,6 +510,25 @@ public bool IsInteractionAllowed public bool GetTracingEnabled(out string value) => _environment.Variables.TryGetValue(KnownEnvars.GcmTrace, out value) && !value.IsFalsey(); + public Trace2Settings GetTrace2Settings() + { + var settings = new Trace2Settings(); + + if (TryGetSetting(Constants.EnvironmentVariables.GitTrace2Event, KnownGitCfg.Trace2.SectionName, + Constants.GitConfiguration.Trace2.EventTarget, out string value)) + { + settings.FormatTargetsAndValues.Add(Trace2FormatTarget.Event, value); + } + + if (TryGetSetting(Constants.EnvironmentVariables.GitTrace2Normal, KnownGitCfg.Trace2.SectionName, + Constants.GitConfiguration.Trace2.NormalTarget, out value)) + { + settings.FormatTargetsAndValues.Add(Trace2FormatTarget.Normal, value); + } + + return settings; + } + public bool IsSecretTracingEnabled => _environment.Variables.GetBooleanyOrDefault(KnownEnvars.GcmTraceSecrets, false); public bool IsMsalTracingEnabled => _environment.Variables.GetBooleanyOrDefault(Constants.EnvironmentVariables.GcmTraceMsAuth, false); diff --git a/src/shared/Core/StringExtensions.cs b/src/shared/Core/StringExtensions.cs index caea3e9b3..5c9a37455 100644 --- a/src/shared/Core/StringExtensions.cs +++ b/src/shared/Core/StringExtensions.cs @@ -228,5 +228,17 @@ public static string TrimMiddle(this string str, string value, StringComparison return str; } + + /// + /// Check whether string contains a specified substring. + /// + /// String to check. + /// String to locate. + /// Comparison rule for comparing the strings. + /// True if the string contains the substring, false if not. + public static bool Contains(this string str, string value, StringComparison comparisonType) + { + return str?.IndexOf(value, comparisonType) >= 0; + } } } diff --git a/src/shared/Core/Trace.cs b/src/shared/Core/Trace.cs index a6a9fc5e8..34055d16f 100644 --- a/src/shared/Core/Trace.cs +++ b/src/shared/Core/Trace.cs @@ -307,22 +307,7 @@ private static string FormatText(string message, string filePath, int lineNumber if (source.Length > sourceColumnMaxWidth) { - int idx = 0; - int maxlen = sourceColumnMaxWidth - 3; - int srclen = source.Length; - - while (idx >= 0 && (srclen - idx) > maxlen) - { - idx = source.IndexOf('\\', idx + 1); - } - - // If we cannot find a path separator which allows the path to be long enough, just truncate the file name - if (idx < 0) - { - idx = srclen - maxlen; - } - - source = "..." + source.Substring(idx); + source = TraceUtils.FormatSource(source, sourceColumnMaxWidth); } // Git's trace format is "{timestamp,-15} {source,-23} trace: {details}" diff --git a/src/shared/Core/Trace2.cs b/src/shared/Core/Trace2.cs new file mode 100644 index 000000000..116bf6612 --- /dev/null +++ b/src/shared/Core/Trace2.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace GitCredentialManager; + +/// +/// The different event types tracked in the TRACE2 tracing +/// system. +/// +public enum Trace2Event +{ + [EnumMember(Value = "version")] + Version = 0, + [EnumMember(Value = "start")] + Start = 1, + [EnumMember(Value = "exit")] + Exit = 2 +} + +public class Trace2Settings +{ + public IDictionary FormatTargetsAndValues { get; set; } = + new Dictionary(); +} + +/// +/// Represents the application's TRACE2 tracing system. +/// +public interface ITrace2 : IDisposable +{ + /// + /// Initialize TRACE2 tracing by setting up any configured target formats and + /// writing Version and Start events. + /// + /// The standard error text stream connected back to the calling process. + /// File system abstraction. + /// The path to the GCM application. + /// Path of the file this method is called from. + /// Line number of file this method is called from. + void Start(TextWriter error, + IFileSystem fileSystem, + string appPath, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0); + + /// + /// Shut down TRACE2 tracing by writing Exit event and disposing of writers. + /// + /// The exit code of the GCM application. + /// Path of the file this method is called from. + /// Line number of file this method is called from. + void Stop(int exitCode, + [System.Runtime.CompilerServices.CallerFilePath] string filePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0); +} + +public class Trace2 : DisposableObject, ITrace2 +{ + private readonly object _writersLock = new object(); + private readonly Encoding _utf8NoBomEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + private const string GitSidVariable = "GIT_TRACE2_PARENT_SID"; + + private List _writers = new List(); + private IEnvironment _environment; + private Trace2Settings _settings; + private string[] _argv; + private DateTimeOffset _applicationStartTime; + private string _sid; + + public Trace2(IEnvironment environment, Trace2Settings settings, string[] argv, DateTimeOffset applicationStartTime) + { + _environment = environment; + _settings = settings; + _argv = argv; + _applicationStartTime = applicationStartTime; + + _sid = SetSid(); + } + + public void Start(TextWriter error, + IFileSystem fileSystem, + string appPath, + string filePath, + int lineNumber) + { + TryParseSettings(error, fileSystem); + + if (!AssemblyUtils.TryGetAssemblyVersion(out string version)) + { + // A version is required for TRACE2, so if this call fails + // manually set the version. + version = "0.0.0"; + } + WriteVersion(version, filePath, lineNumber); + WriteStart(appPath, filePath, lineNumber); + } + + public void Stop(int exitCode, string filePath, int lineNumber) + { + WriteExit(exitCode, filePath, lineNumber); + ReleaseManagedResources(); + } + + protected override void ReleaseManagedResources() + { + lock (_writersLock) + { + try + { + for (int i = 0; i < _writers.Count; i += 1) + { + using (var writer = _writers[i]) + { + _writers.Remove(writer); + } + } + } + catch + { + /* squelch */ + } + } + + base.ReleaseManagedResources(); + } + + internal string SetSid() + { + var sids = new List(); + if (_environment.Variables.TryGetValue(GitSidVariable, out string parentSid)) + { + sids.Add(parentSid); + } + + // Add GCM "child" sid + sids.Add(Guid.NewGuid().ToString("D")); + var combinedSid = string.Join("/", sids); + + _environment.SetEnvironmentVariable(GitSidVariable, combinedSid); + return combinedSid; + } + + internal bool TryGetPipeName(string eventTarget, out string name) + { + // Use prefixes to determine whether target is a named pipe/socket + if (eventTarget.Contains("af_unix:", StringComparison.OrdinalIgnoreCase) || + eventTarget.Contains("\\\\.\\pipe\\", StringComparison.OrdinalIgnoreCase) || + eventTarget.Contains("/./pipe/", StringComparison.OrdinalIgnoreCase)) + { + name = PlatformUtils.IsWindows() + ? eventTarget.TrimUntilLastIndexOf("\\") + : eventTarget.TrimUntilLastIndexOf(":"); + return true; + } + + name = ""; + return false; + } + + private void TryParseSettings(TextWriter error, IFileSystem fileSystem) + { + // Set up the correct writer for every enabled format target. + foreach (var formatTarget in _settings.FormatTargetsAndValues) + { + if (TryGetPipeName(formatTarget.Value, out string name)) // Write to named pipe/socket + { + AddWriter(new Trace2CollectorWriter(( + () => new NamedPipeClientStream(".", name, + PipeDirection.Out, + PipeOptions.Asynchronous) + ) + )); + } + else if (formatTarget.Value.IsTruthy()) // Write to stderr + { + AddWriter(new Trace2StreamWriter(error, formatTarget.Key)); + } + else if (Path.IsPathRooted(formatTarget.Value)) // Write to file + { + try + { + Stream stream = fileSystem.OpenFileStream(formatTarget.Value, FileMode.Append, + FileAccess.Write, FileShare.ReadWrite); + AddWriter(new Trace2StreamWriter(new StreamWriter(stream, _utf8NoBomEncoding, + 4096, leaveOpen: false), formatTarget.Key)); + } + catch (Exception ex) + { + error.WriteLine($"warning: unable to trace to file '{formatTarget.Value}': {ex.Message}"); + } + } + } + } + + private void WriteVersion( + string gcmVersion, + string filePath, + int lineNumber, + string eventFormatVersion = "3") + { + EnsureArgument.NotNull(gcmVersion, nameof(gcmVersion)); + + WriteMessage(new VersionMessage() + { + Event = Trace2Event.Version, + Sid = _sid, + Time = DateTimeOffset.UtcNow, + File = Path.GetFileName(filePath).ToLower(), + Line = lineNumber, + Evt = eventFormatVersion, + Exe = gcmVersion + }); + } + + private void WriteStart( + string appPath, + string filePath, + int lineNumber) + { + // Prepend GCM exe to arguments + var argv = new List() + { + Path.GetFileName(appPath), + }; + argv.AddRange(_argv); + + WriteMessage(new StartMessage() + { + Event = Trace2Event.Start, + Sid = _sid, + Time = DateTimeOffset.UtcNow, + File = Path.GetFileName(filePath).ToLower(), + Line = lineNumber, + Argv = argv, + ElapsedTime = (DateTimeOffset.UtcNow - _applicationStartTime).TotalSeconds + }); + } + + private void WriteExit(int code, string filePath = "", int lineNumber = 0) + { + EnsureArgument.NotNull(code, nameof(code)); + + WriteMessage(new ExitMessage() + { + Event = Trace2Event.Exit, + Sid = _sid, + Time = DateTimeOffset.Now, + File = Path.GetFileName(filePath).ToLower(), + Line = lineNumber, + Code = code, + ElapsedTime = (DateTimeOffset.UtcNow - _applicationStartTime).TotalSeconds + }); + } + + private void AddWriter(ITrace2Writer writer) + { + ThrowIfDisposed(); + + lock (_writersLock) + { + // Try not to add the same writer more than once + if (_writers.Contains(writer)) + return; + + _writers.Add(writer); + } + } + + private void WriteMessage(Trace2Message message) + { + ThrowIfDisposed(); + + lock (_writersLock) + { + if (_writers.Count == 0) + { + return; + } + + foreach (var writer in _writers) + { + if (!writer.Failed) + { + writer.Write(message); + } + } + } + } +} + +public abstract class Trace2Message +{ + protected const string TimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'ffffff'Z'"; + private const int SourceColumnMaxWidth = 23; + + [JsonProperty("event", Order = 1)] + public Trace2Event Event { get; set; } + + [JsonProperty("sid", Order = 2)] + public string Sid { get; set; } + + // TODO: Remove this default value when TRACE2 regions are introduced. + [JsonProperty("thread", Order = 3)] + public string Thread { get; set; } = "main"; + + [JsonProperty("time", Order = 4)] + public DateTimeOffset Time { get; set; } + + [JsonProperty("file", Order = 5)] + + public string File { get; set; } + + [JsonProperty("line", Order = 6)] + public int Line { get; set; } + + public abstract string ToJson(); + + public abstract string ToNormalString(); + + protected string BuildNormalString(string message) + { + // The normal format uses local time rather than UTC time. + string time = Time.ToLocalTime().ToString("HH:mm:ss.ffffff"); + + // Source column format is file:line + string source = $"{File.ToLower()}:{Line}"; + if (source.Length > SourceColumnMaxWidth) + { + source = TraceUtils.FormatSource(source, SourceColumnMaxWidth); + } + + // Git's TRACE2 normal format is: + // [