diff --git a/.github/set_up_esrp.ps1 b/.github/set_up_esrp.ps1 index ca56266e3..abe9183e0 100644 --- a/.github/set_up_esrp.ps1 +++ b/.github/set_up_esrp.ps1 @@ -1,5 +1,5 @@ # Install ESRP client -az storage blob download --file esrp.zip --auth-mode login --account-name esrpsigningstorage --container signing-resources --name microsoft.esrpclient.1.2.76.nupkg +az storage blob download --file esrp.zip --auth-mode login --account-name $env:AZURE_STORAGE_ACCOUNT --container $env:AZURE_STORAGE_CONTAINER --name $env:ESRP_TOOL Expand-Archive -Path esrp.zip -DestinationPath .\esrp # Install certificates diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 06a331d98..f9b3edea2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,7 +22,7 @@ jobs: language: [ 'csharp' ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index d80df6885..7562447fc 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -16,7 +16,7 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v3.2.0 @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v3.2.0 @@ -97,7 +97,7 @@ jobs: runtime: [ osx-x64, osx-arm64 ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v3.2.0 diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 34574458f..cbc4b241e 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -18,9 +18,9 @@ jobs: name: Lint markdown files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: DavidAnson/markdownlint-cli2-action@8f3516061301755c97ff833a8e933f09282cc5b5 + - uses: DavidAnson/markdownlint-cli2-action@ed4dec634fd2ef689c7061d5647371d8248064f1 with: globs: | "**/*.md" @@ -30,7 +30,7 @@ jobs: name: Check for broken links runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run link checker # For any troubleshooting, see: diff --git a/.github/workflows/release-homebrew.yaml b/.github/workflows/release-homebrew.yaml index 1b7951c1b..a27735d36 100644 --- a/.github/workflows/release-homebrew.yaml +++ b/.github/workflows/release-homebrew.yaml @@ -5,17 +5,21 @@ on: jobs: release: - runs-on: ubuntu-latest + runs-on: macos-latest environment: release + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_TOKEN }} steps: - - name: Update Homebrew tap - uses: mjcheetham/update-homebrew@v1.4 - with: - token: ${{ secrets.HOMEBREW_TOKEN }} - tap: Homebrew/homebrew-cask - name: git-credential-manager - type: cask - alwaysUsePullRequest: true - releaseAsset: | - gcm-osx-x64-(.*)\.pkg - gcm-osx-arm64-(.*)\.pkg + - name: Open PR against homebrew/homebrew-cask + run: | + # Get latest version + version=$(curl --silent "https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest" | + grep '"tag_name":' | + sed -E 's/.*"v([0-9\.]+).*/\1/') + + # Ensure local Homebrew repository is up to date + cd "$(brew --repository homebrew/cask)" + git pull + + # Open PR to update to latest version + brew bump-cask-pr git-credential-manager --version $version --no-audit --no-browse diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fc24ea3c..82ec3b1b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,27 +3,41 @@ name: release on: workflow_dispatch: +permissions: + id-token: write + contents: write + jobs: + prereqs: + name: Prerequisites + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "version=$(cat VERSION | sed -E 's/.[0-9]+$//')" >> $GITHUB_OUTPUT + id: version + # ================================ -# macOS +# macOS # ================================ - osx-build: - name: Build macOS + create-macos-artifacts: + name: Create macOS artifacts runs-on: macos-latest environment: release + needs: prereqs strategy: matrix: runtime: [ osx-x64, osx-arm64 ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up dotnet + - name: Set up .NET uses: actions/setup-dotnet@v3.2.0 with: - dotnet-version: 6.0.201 - - - name: Install dependencies - run: dotnet restore + dotnet-version: 7.0.x - name: Build run: | @@ -41,221 +55,104 @@ jobs: --configuration=MacRelease --output=payload \ --symbol-output=symbols --runtime=${{ matrix.runtime }} - - name: Create keychain + - name: Set up signing/notarization infrastructure env: - CERT_BASE64: ${{ secrets.DEVELOPER_CERTIFICATE_BASE64 }} - CERT_PASSPHRASE: ${{ secrets.DEVELOPER_CERTIFICATE_PASSWORD }} - run: | + A1: ${{ secrets.APPLICATION_CERTIFICATE_BASE64 }} + A2: ${{ secrets.APPLICATION_CERTIFICATE_PASSWORD }} + I1: ${{ secrets.INSTALLER_CERTIFICATE_BASE64 }} + I2: ${{ secrets.INSTALLER_CERTIFICATE_PASSWORD }} + N1: ${{ secrets.APPLE_TEAM_ID }} + N2: ${{ secrets.APPLE_DEVELOPER_ID }} + N3: ${{ secrets.APPLE_DEVELOPER_PASSWORD }} + N4: ${{ secrets.APPLE_KEYCHAIN_PROFILE }} + run: | + echo "Setting up signing certificates" security create-keychain -p pwd $RUNNER_TEMP/buildagent.keychain security default-keychain -s $RUNNER_TEMP/buildagent.keychain security unlock-keychain -p pwd $RUNNER_TEMP/buildagent.keychain - echo $CERT_BASE64 | base64 -D > $RUNNER_TEMP/cert.p12 - security import $RUNNER_TEMP/cert.p12 -k $RUNNER_TEMP/buildagent.keychain -P $CERT_PASSPHRASE -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k pwd $RUNNER_TEMP/buildagent.keychain - - name: Developer sign + echo $A1 | base64 -D > $RUNNER_TEMP/cert.p12 + security import $RUNNER_TEMP/cert.p12 \ + -k $RUNNER_TEMP/buildagent.keychain \ + -P $A2 \ + -T /usr/bin/codesign + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k pwd \ + $RUNNER_TEMP/buildagent.keychain + + echo $I1 | base64 -D > $RUNNER_TEMP/cert.p12 + security import $RUNNER_TEMP/cert.p12 \ + -k $RUNNER_TEMP/buildagent.keychain \ + -P $I2 \ + -T /usr/bin/productbuild + security set-key-partition-list \ + -S apple-tool:,apple:,productbuild: \ + -s -k pwd \ + $RUNNER_TEMP/buildagent.keychain + + echo "Setting up notarytool" + xcrun notarytool store-credentials \ + --team-id $N1 \ + --apple-id $N2 \ + --password $N3 \ + "$N4" + + - name: Run codesign against payload env: - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + A3: ${{ secrets.APPLE_APPLICATION_SIGNING_IDENTITY }} run: | - .github/run_developer_signing.sh payload $APPLE_TEAM_ID $GITHUB_WORKSPACE/src/osx/Installer.Mac/entitlements.xml - - - name: Upload macOS artifacts - uses: actions/upload-artifact@v3 - with: - name: tmp.${{ matrix.runtime }}-build - path: | - payload - symbols - - osx-payload-sign: - name: Sign macOS payload - # ESRP service requires signing to run on Windows - runs-on: windows-latest - environment: release - strategy: - matrix: - runtime: [ osx-x64, osx-arm64 ] - needs: osx-build - steps: - - uses: actions/checkout@v3 - - - name: Download payload - uses: actions/download-artifact@v3 - with: - name: tmp.${{ matrix.runtime }}-build - - - name: Zip unsigned payload - shell: pwsh - run: | - Compress-Archive -Path payload payload/payload.zip - cd payload - Get-ChildItem -Exclude payload.zip | Remove-Item -Recurse -Force - - - uses: azure/login@v1 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - - name: Set up ESRP client - shell: pwsh - env: - AZURE_VAULT: ${{ secrets.AZURE_VAULT }} - AUTH_CERT: ${{ secrets.AZURE_VAULT_AUTH_CERT_NAME }} - REQUEST_SIGNING_CERT: ${{ secrets.AZURE_VAULT_REQUEST_SIGNING_CERT_NAME }} - run: | - .github\set_up_esrp.ps1 - - - name: Run ESRP client - shell: pwsh - env: - AZURE_AAD_ID: ${{ secrets.AZURE_AAD_ID }} - APPLE_KEY_CODE: ${{ secrets.APPLE_KEY_CODE }} - APPLE_SIGNING_OP_CODE: ${{ secrets.APPLE_SIGNING_OPERATION_CODE }} - run: | - python .github\run_esrp_signing.py payload ` - $env:APPLE_KEY_CODE $env:APPLE_SIGNING_OP_CODE ` - --params 'Hardening' '--options=runtime' - - - name: Unzip signed payload - shell: pwsh - run: | - Expand-Archive signed/payload.zip -DestinationPath signed - Remove-Item signed/payload.zip - - - name: Upload signed payload - uses: actions/upload-artifact@v3 - with: - name: ${{ matrix.runtime }}-payload-sign - path: | - signed - - osx-pack: - name: Package macOS payload - runs-on: macos-latest - strategy: - matrix: - runtime: [ osx-x64, osx-arm64 ] - needs: osx-payload-sign - steps: - - uses: actions/checkout@v3 - - - name: Set version environment variable - run: echo "VERSION=$(cat VERSION | sed -E 's/.[0-9]+$//')" >> $GITHUB_ENV - - - name: Set up dotnet - uses: actions/setup-dotnet@v3.2.0 - with: - dotnet-version: 6.0.201 - - - name: Download signed payload - uses: actions/download-artifact@v3 - with: - name: ${{ matrix.runtime }}-payload-sign + ./src/osx/Installer.Mac/codesign.sh "payload" "$A3" \ + "$GITHUB_WORKSPACE/src/osx/Installer.Mac/entitlements.xml" - name: Create component package run: | - src/osx/Installer.Mac/pack.sh --payload=payload \ - --version=$VERSION \ - --output=components/com.microsoft.gitcredentialmanager.component.pkg - - - name: Create product archive - run: | - src/osx/Installer.Mac/dist.sh --package-path=components \ - --version=$VERSION --runtime=${{ matrix.runtime }} \ - --output=pkg/gcm-${{ matrix.runtime }}-$VERSION.pkg || exit 1 - - - name: Upload package - uses: actions/upload-artifact@v3 - with: - name: tmp.${{ matrix.runtime }}-pack - path: | - pkg - - osx-sign: - name: Sign and notarize macOS package - # ESRP service requires signing to run on Windows - runs-on: windows-latest - environment: release - strategy: - matrix: - runtime: [ osx-x64, osx-arm64 ] - needs: osx-pack - steps: - - uses: actions/checkout@v3 - - - name: Download unsigned package - uses: actions/download-artifact@v3 - with: - name: tmp.${{ matrix.runtime }}-pack - path: pkg - - - name: Zip unsigned package - shell: pwsh - run: | - Compress-Archive -Path pkg/*.pkg pkg/gcm-pkg.zip - cd pkg - Get-ChildItem -Exclude gcm-pkg.zip | Remove-Item -Recurse -Force - - - uses: azure/login@v1 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + src/osx/Installer.Mac/pack.sh --payload="payload" \ + --version="${{ needs.prereqs.outputs.version }}" \ + --output="components/com.microsoft.gitcredentialmanager.component.pkg" - - name: Set up ESRP client - shell: pwsh - env: - AZURE_VAULT: ${{ secrets.AZURE_VAULT }} - AUTH_CERT: ${{ secrets.AZURE_VAULT_AUTH_CERT_NAME }} - REQUEST_SIGNING_CERT: ${{ secrets.AZURE_VAULT_REQUEST_SIGNING_CERT_NAME }} - run: | - .github\set_up_esrp.ps1 - - - name: Sign package - shell: pwsh + - name: Create and sign product archive env: - AZURE_AAD_ID: ${{ secrets.AZURE_AAD_ID }} - APPLE_KEY_CODE: ${{ secrets.APPLE_KEY_CODE }} - APPLE_SIGNING_OP_CODE: ${{ secrets.APPLE_SIGNING_OPERATION_CODE }} + I3: ${{ secrets.APPLE_INSTALLER_SIGNING_IDENTITY }} run: | - python .github\run_esrp_signing.py pkg $env:APPLE_KEY_CODE $env:APPLE_SIGNING_OP_CODE - - - name: Unzip signed package - shell: pwsh - run: | - mkdir unsigned - Expand-Archive -LiteralPath signed\gcm-pkg.zip -DestinationPath .\unsigned -Force - Remove-Item signed\gcm-pkg.zip -Force + src/osx/Installer.Mac/dist.sh --package-path=components \ + --version="${{ needs.prereqs.outputs.version }}" \ + --runtime="${{ matrix.runtime }}" \ + --output="pkg/gcm-${{ matrix.runtime }}-${{ needs.prereqs.outputs.version }}.pkg" \ + --identity="$I3" || exit 1 - - name: Notarize signed package - shell: pwsh + - name: Notarize product archive env: - AZURE_AAD_ID: ${{ secrets.AZURE_AAD_ID }} - APPLE_KEY_CODE: ${{ secrets.APPLE_KEY_CODE }} - APPLE_NOTARIZATION_OP_CODE: ${{ secrets.APPLE_NOTARIZATION_OPERATION_CODE }} + N4: ${{ secrets.APPLE_KEYCHAIN_PROFILE }} run: | - python .github\run_esrp_signing.py unsigned $env:APPLE_KEY_CODE $env:APPLE_NOTARIZATION_OP_CODE --params 'BundleId' 'com.microsoft.gitcredentialmanager' + src/osx/Installer.Mac/notarize.sh \ + --package="pkg/gcm-${{ matrix.runtime }}-${{ needs.prereqs.outputs.version }}.pkg" \ + --keychain-profile="$N4" - - name: Publish signed package + - name: Upload artifacts uses: actions/upload-artifact@v3 with: - name: ${{ matrix.runtime }}-sign - path: signed/*.pkg + name: macos-${{ matrix.runtime }}-artifacts + path: | + ./pkg/* + ./symbols/* + ./payload/* # ================================ -# Windows +# Windows # ================================ - win-sign: - name: Build and Sign Windows + create-windows-artifacts: + name: Create Windows Artifacts runs-on: windows-latest environment: release + needs: prereqs steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up dotnet + - name: Set up .NET uses: actions/setup-dotnet@v3.2.0 with: - dotnet-version: 6.0.201 - - - name: Install dependencies - run: dotnet restore + dotnet-version: 7.0.x - name: Build run: | @@ -266,167 +163,145 @@ jobs: dotnet test --configuration=WindowsRelease - name: Lay out Windows payload and symbols - shell: pwsh run: | - cd src/windows/Installer.Windows/ - ./layout.ps1 -Configuration WindowsRelease -Output payload -SymbolOutput symbols - mkdir unsigned-payload - Get-ChildItem -Path payload/* -Include *.exe, *.dll | Move-Item -Destination unsigned-payload + cd $env:GITHUB_WORKSPACE\src\windows\Installer.Windows\ + ./layout.ps1 -Configuration WindowsRelease ` + -Output $env:GITHUB_WORKSPACE\payload ` + -SymbolOutput $env:GITHUB_WORKSPACE\symbols - - uses: azure/login@v1 + - name: Log into Azure + uses: azure/login@v1 with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Set up ESRP client - shell: pwsh - env: - AZURE_VAULT: ${{ secrets.AZURE_VAULT }} - AUTH_CERT: ${{ secrets.AZURE_VAULT_AUTH_CERT_NAME }} - REQUEST_SIGNING_CERT: ${{ secrets.AZURE_VAULT_REQUEST_SIGNING_CERT_NAME }} - run: | - .github\set_up_esrp.ps1 - - - name: Run ESRP client for unsigned payload - shell: pwsh - env: - AZURE_AAD_ID: ${{ secrets.AZURE_AAD_ID }} - WINDOWS_KEY_CODE: ${{ secrets.WINDOWS_KEY_CODE }} - WINDOWS_OP_CODE: ${{ secrets.WINDOWS_OPERATION_CODE }} - run: | - python .github\run_esrp_signing.py ` - src/windows/Installer.Windows/unsigned-payload ` - $env:WINDOWS_KEY_CODE $env:WINDOWS_OP_CODE ` - --params 'OpusName' 'Microsoft' ` - 'OpusInfo' 'http://www.microsoft.com' ` - 'FileDigest' '/fd "SHA256"' 'PageHash' '/NPH' ` - 'TimeStamp' '/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256' - - - name: Lay out signed payload - shell: pwsh - run: | - mkdir signed-payload - Move-Item -Path signed/* -Destination signed-payload - # ESRP will not sign the *.exe.config or NOTICE files, but they are needed to build the installers. - # Due to this, we copy them after signing. - Get-ChildItem -Path src/windows/Installer.Windows/payload/* -Include *.exe.config, NOTICE | Move-Item -Destination signed-payload - Remove-Item signed -Recurse -Force + - name: Sign payload files with Azure Code Signing + uses: azure/azure-code-signing-action@v0.2.21 + with: + endpoint: https://wus2.codesigning.azure.net/ + code-signing-account-name: git-fundamentals-signing + certificate-profile-name: git-fundamentals-windows-signing + files-folder: ${{ github.workspace }}\payload + files-folder-filter: exe,dll + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + # The Azure Code Signing action overrides the .NET version, so we reset it. + - name: Set up .NET + uses: actions/setup-dotnet@v3.2.0 + with: + dotnet-version: 7.0.x - name: Build with signed payload - shell: pwsh run: | - dotnet build src/windows/Installer.Windows /p:PayloadPath=$env:GITHUB_WORKSPACE/signed-payload /p:NoLayout=true --configuration=WindowsRelease + dotnet build $env:GITHUB_WORKSPACE\src\windows\Installer.Windows ` + /p:PayloadPath=$env:GITHUB_WORKSPACE\payload /p:NoLayout=true ` + --configuration=WindowsRelease + mkdir installers + Move-Item -Path .\out\windows\Installer.Windows\bin\Release\net472\*.exe ` + -Destination $env:GITHUB_WORKSPACE\installers - - name: Run ESRP client for installers - shell: pwsh - env: - AZURE_AAD_ID: ${{ secrets.AZURE_AAD_ID }} - WINDOWS_KEY_CODE: ${{ secrets.WINDOWS_KEY_CODE }} - WINDOWS_OP_CODE: ${{ secrets.WINDOWS_OPERATION_CODE }} - run: | - python .github\run_esrp_signing.py ` - .\out\windows\Installer.Windows\bin\WindowsRelease\net472 ` - $env:WINDOWS_KEY_CODE ` - $env:WINDOWS_OP_CODE ` - --params 'OpusName' 'Microsoft' ` - 'OpusInfo' 'http://www.microsoft.com' ` - 'FileDigest' '/fd "SHA256"' 'PageHash' '/NPH' ` - 'TimeStamp' '/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256' - - - name: Publish final artifacts + - name: Sign installers with Azure Code Signing + uses: azure/azure-code-signing-action@v0.2.21 + with: + endpoint: https://wus2.codesigning.azure.net/ + code-signing-account-name: git-fundamentals-signing + certificate-profile-name: git-fundamentals-windows-signing + files-folder: ${{ github.workspace }}\installers + files-folder-filter: exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Upload artifacts uses: actions/upload-artifact@v3 with: - name: win-sign + name: windows-artifacts path: | - signed - signed-payload - src/windows/Installer.Windows/symbols + payload + installers + symbols # ================================ # Linux # ================================ - linux-build: - name: Build Linux + create-linux-artifacts: + name: Create Linux Artifacts runs-on: ubuntu-latest + environment: release + needs: prereqs steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Setup .NET + - name: Set up .NET uses: actions/setup-dotnet@v3.2.0 with: - dotnet-version: 6.0.201 - - - name: Install dependencies - run: dotnet restore + dotnet-version: 7.0.x - name: Build run: dotnet build --configuration=LinuxRelease - - name: Lay out + - name: Run Linux unit tests run: | - mkdir -p linux-build/deb linux-build/tar - mv out/linux/Packaging.Linux/Release/deb/*.deb linux-build/deb - mv out/linux/Packaging.Linux/Release/tar/*.tar.gz linux-build/tar + dotnet test --configuration=LinuxRelease - - name: Upload artifacts - uses: actions/upload-artifact@v3 + - name: Log into Azure + uses: azure/login@v1 with: - name: linux-build - path: | - linux-build + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - linux-sign: - name: Sign Linux tarball and Debian package - needs: linux-build - # ESRP service requires signing to run on Windows - runs-on: windows-latest - environment: release - steps: - - uses: actions/checkout@v3 + - name: Prepare for GPG signing + env: + AZURE_VAULT: ${{ secrets.AZURE_VAULT }} + GPG_KEY_SECRET_NAME: ${{ secrets.GPG_KEY_SECRET_NAME }} + GPG_PASSPHRASE_SECRET_NAME: ${{ secrets.GPG_PASSPHRASE_SECRET_NAME }} + GPG_KEYGRIP_SECRET_NAME: ${{ secrets.GPG_KEYGRIP_SECRET_NAME }} + run: | + # Install debsigs + sudo apt install debsigs - - name: Download artifacts - uses: actions/download-artifact@v3 - with: - name: linux-build + # Download GPG key, passphrase, and keygrip from Azure Key Vault + key=$(az keyvault secret show --name $GPG_KEY_SECRET_NAME --vault-name $AZURE_VAULT --query "value") + passphrase=$(az keyvault secret show --name $GPG_PASSPHRASE_SECRET_NAME --vault-name $AZURE_VAULT --query "value") + keygrip=$(az keyvault secret show --name $GPG_KEYGRIP_SECRET_NAME --vault-name $AZURE_VAULT --query "value") - - name: Remove symbols - run: | - rm tar/*symbols* + # Remove quotes from downloaded values + key=$(sed -e 's/^"//' -e 's/"$//' <<<"$key") + passphrase=$(sed -e 's/^"//' -e 's/"$//' <<<"$passphrase") + keygrip=$(sed -e 's/^"//' -e 's/"$//' <<<"$keygrip") - - uses: azure/login@v1 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + # Import GPG key + echo "$key" | base64 -d | gpg --import --no-tty --batch --yes - - name: Set up ESRP client - shell: pwsh - env: - AZURE_VAULT: ${{ secrets.AZURE_VAULT }} - AUTH_CERT: ${{ secrets.AZURE_VAULT_AUTH_CERT_NAME }} - REQUEST_SIGNING_CERT: ${{ secrets.AZURE_VAULT_REQUEST_SIGNING_CERT_NAME }} - run: | - .github\set_up_esrp.ps1 + # Configure GPG + echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf + gpg-connect-agent RELOADAGENT /bye + /usr/lib/gnupg2/gpg-preset-passphrase --preset "$keygrip" <<<"$passphrase" - - name: Run ESRP client - shell: pwsh - env: - AZURE_AAD_ID: ${{ secrets.AZURE_AAD_ID }} - LINUX_KEY_CODE: ${{ secrets.LINUX_KEY_CODE }} - LINUX_OP_CODE: ${{ secrets.LINUX_OPERATION_CODE }} + - name: Sign Debian package and tarball 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 + # Sign Debian package + version=${{ needs.prereqs.outputs.version }} + mv out/linux/Packaging.Linux/Release/deb/gcm-linux_amd64.$version.deb . + debsigs --sign=origin --verify --check gcm-linux_amd64.$version.deb - - name: Re-name tarball signature file - shell: bash - run: | - signaturepath=$(find signed/*.tar.gz) - mv "$signaturepath" "${signaturepath%.tar.gz}.asc" + # Generate tarball signature file + mv -v out/linux/Packaging.Linux/Release/tar/* . + gpg --batch --yes --armor --output gcm-linux_amd64.$version.tar.gz.asc \ + --detach-sig gcm-linux_amd64.$version.tar.gz - - name: Upload signed tarball and Debian package + - name: Upload artifacts uses: actions/upload-artifact@v3 with: - name: linux-sign + name: linux-artifacts path: | - signed + ./*.deb + ./*.asc + ./*.tar.gz # ================================ # .NET Tool @@ -434,13 +309,14 @@ jobs: dotnet-tool-build: name: Build .NET tool runs-on: ubuntu-latest + needs: prereqs steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Setup .NET + - name: Set up .NET uses: actions/setup-dotnet@v3.2.0 with: - dotnet-version: 6.0.201 + dotnet-version: 7.0.x - name: Build .NET tool run: | @@ -460,7 +336,7 @@ jobs: environment: release needs: dotnet-tool-build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Download payload uses: actions/download-artifact@v3 @@ -474,14 +350,20 @@ jobs: cd payload Get-ChildItem -Exclude payload.zip | Remove-Item -Recurse -Force - - uses: azure/login@v1 + - name: Log into Azure + uses: azure/login@v1 with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Set up ESRP client shell: pwsh env: AZURE_VAULT: ${{ secrets.AZURE_VAULT }} + AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }} + AZURE_STORAGE_CONTAINER: ${{ secrets.AZURE_STORAGE_CONTAINER }} + ESRP_TOOL: ${{ secrets.ESRP_TOOL }} AUTH_CERT: ${{ secrets.AZURE_VAULT_AUTH_CERT_NAME }} REQUEST_SIGNING_CERT: ${{ secrets.AZURE_VAULT_REQUEST_SIGNING_CERT_NAME }} run: | @@ -515,14 +397,9 @@ jobs: dotnet-tool-pack: name: Package .NET tool runs-on: ubuntu-latest - needs: dotnet-tool-payload-sign + needs: [ prereqs, dotnet-tool-payload-sign ] steps: - - uses: actions/checkout@v3 - - - name: Set version environment variable - run: echo "VERSION=$(cat VERSION | sed -E 's/.[0-9]+$//')" >> $GITHUB_ENV - - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Download signed payload uses: actions/download-artifact@v3 @@ -530,15 +407,16 @@ jobs: name: dotnet-tool-payload-sign path: signed - - name: Setup .NET + - name: Set up .NET uses: actions/setup-dotnet@v3.2.0 with: - dotnet-version: 6.0.201 + dotnet-version: 7.0.x - name: Package tool run: | src/shared/DotnetTool/pack.sh --configuration=Release \ - --version=$VERSION --publish-dir=$(pwd)/signed + --version="${{ needs.prereqs.outputs.version }}" \ + --publish-dir=$(pwd)/signed - name: Upload unsigned package uses: actions/upload-artifact@v3 @@ -554,7 +432,7 @@ jobs: environment: release needs: dotnet-tool-pack steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Download unsigned package uses: actions/download-artifact@v3 @@ -569,14 +447,20 @@ jobs: cd nupkg Get-ChildItem -Exclude gcm-nupkg.zip | Remove-Item -Recurse -Force - - uses: azure/login@v1 + - name: Log into Azure + uses: azure/login@v1 with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Set up ESRP client shell: pwsh env: AZURE_VAULT: ${{ secrets.AZURE_VAULT }} + AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }} + AZURE_STORAGE_CONTAINER: ${{ secrets.AZURE_STORAGE_CONTAINER }} + ESRP_TOOL: ${{ secrets.ESRP_TOOL }} AUTH_CERT: ${{ secrets.AZURE_VAULT_AUTH_CERT_NAME }} REQUEST_SIGNING_CERT: ${{ secrets.AZURE_VAULT_REQUEST_SIGNING_CERT_NAME }} run: | @@ -612,19 +496,15 @@ jobs: matrix: component: - os: ubuntu-latest - artifact: linux-sign - command: git-credential-manager - description: debian - - os: ubuntu-latest - artifact: linux-build + artifact: linux-artifacts command: git-credential-manager - description: tarball + description: linux - os: macos-latest - artifact: osx-x64-sign + artifact: macos-osx-x64-artifacts command: git-credential-manager description: osx-x64 - os: windows-latest - artifact: win-sign + artifact: windows-artifacts # Even when a standalone GCM version is installed, GitHub actions # runners still only recognize the version bundled with Git for # Windows due to its placement on the PATH. For this reason, we use @@ -636,9 +516,14 @@ jobs: command: git-credential-manager description: dotnet-tool runs-on: ${{ matrix.component.os }} - needs: [ osx-sign, win-sign, linux-sign, dotnet-tool-sign ] + needs: [ create-macos-artifacts, create-windows-artifacts, create-linux-artifacts, dotnet-tool-sign ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v3.2.0 + with: + dotnet-version: 7.0.x - name: Download artifacts uses: actions/download-artifact@v3 @@ -649,24 +534,24 @@ jobs: if: contains(matrix.component.description, 'windows') shell: pwsh run: | - $exePaths = Get-ChildItem -Path ./signed/*.exe | %{$_.FullName} + $exePaths = Get-ChildItem -Path ./installers/*.exe | %{$_.FullName} foreach ($exePath in $exePaths) { Start-Process -Wait -FilePath "$exePath" -ArgumentList "/SILENT /VERYSILENT /NORESTART" } - name: Install Linux (Debian package) - if: contains(matrix.component.description, 'debian') + if: contains(matrix.component.description, 'linux') run: | debpath=$(find ./*.deb) sudo apt install $debpath "${{ matrix.component.command }}" configure - name: Install Linux (tarball) - if: contains(matrix.component.description, 'tarball') + if: contains(matrix.component.description, 'linux') run: | # Ensure we find only the source tarball, not the symbols - tarpath=$(find ./tar -name '*[[:digit:]].tar.gz') + tarpath=$(find . -name '*[[:digit:]].tar.gz') tar -xvf $tarpath -C /usr/local/bin "${{ matrix.component.command }}" configure @@ -674,7 +559,7 @@ jobs: if: contains(matrix.component.description, 'osx-x64') run: | # Only validate x64, given arm64 agents are not available - pkgpath=$(find ./*.pkg) + pkgpath=$(find ./pkg/*.pkg) sudo installer -pkg $pkgpath -target / - name: Install .NET tool @@ -697,46 +582,60 @@ jobs: create-github-release: name: Publish GitHub draft release runs-on: ubuntu-latest + env: + AZURE_VAULT: ${{ secrets.AZURE_VAULT }} + GPG_PUBLIC_KEY_SECRET_NAME: ${{ secrets.GPG_PUBLIC_KEY_SECRET_NAME }} environment: release - needs: [ validate ] + needs: [ prereqs, validate ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set version environment variable - run: | - # Remove the "revision" portion of the version - echo "VERSION=$(cat VERSION | sed -E 's/.[0-9]+$//')" >> $GITHUB_ENV - - - name: Set up dotnet + - name: Set up .NET uses: actions/setup-dotnet@v3.2.0 with: - dotnet-version: 6.0.201 + dotnet-version: 7.0.x - name: Download artifacts uses: actions/download-artifact@v3 - name: Archive macOS payload and symbols run: | + version="${{ needs.prereqs.outputs.version }}" mkdir osx-payload-and-symbols - tar -C osx-x64-payload-sign -czf osx-payload-and-symbols/gcm-osx-x64-$VERSION.tar.gz . - tar -C tmp.osx-x64-build/symbols -czf osx-payload-and-symbols/gcm-osx-x64-$VERSION-symbols.tar.gz . + tar -C macos-osx-x64-artifacts/payload -czf osx-payload-and-symbols/gcm-osx-x64-$version.tar.gz . + tar -C macos-osx-x64-artifacts/symbols -czf osx-payload-and-symbols/gcm-osx-x64-$version-symbols.tar.gz . - tar -C osx-arm64-payload-sign -czf osx-payload-and-symbols/gcm-osx-arm64-$VERSION.tar.gz . - tar -C tmp.osx-arm64-build/symbols -czf osx-payload-and-symbols/gcm-osx-arm64-$VERSION-symbols.tar.gz . + tar -C macos-osx-arm64-artifacts -czf osx-payload-and-symbols/gcm-osx-arm64-$version.tar.gz . + tar -C macos-osx-arm64-artifacts/symbols -czf osx-payload-and-symbols/gcm-osx-arm64-$version-symbols.tar.gz . - name: Archive Windows payload and symbols run: | + version="${{ needs.prereqs.outputs.version }}" mkdir win-x86-payload-and-symbols - zip -jr win-x86-payload-and-symbols/gcm-win-x86-$VERSION.zip win-sign/signed-payload - zip -jr win-x86-payload-and-symbols/gcm-win-x86-$VERSION-symbols.zip win-sign/src/windows/Installer.Windows/symbols + zip -jr win-x86-payload-and-symbols/gcm-win-x86-$version.zip windows-artifacts/payload + zip -jr win-x86-payload-and-symbols/gcm-win-x86-$version-symbols.zip windows-artifacts/symbols + + - name: Log into Azure + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Download GPG public key signature file + run: | + az keyvault secret show --name "$GPG_PUBLIC_KEY_SECRET_NAME" \ + --vault-name "$AZURE_VAULT" --query "value" \ + | sed -e 's/^"//' -e 's/"$//' | base64 -d >gcm-public.asc + mv gcm-public.asc linux-artifacts - uses: actions/github-script@v6 with: script: | const fs = require('fs'); const path = require('path'); - const version = process.env.VERSION + const version = "${{ needs.prereqs.outputs.version }}" var releaseMetadata = { owner: context.repo.owner, @@ -749,7 +648,7 @@ jobs: ...releaseMetadata, draft: true, tag_name: tagName, - tag_commitish: context.sha, + target_commitish: context.sha, name: `GCM ${version}` }); releaseMetadata.release_id = createdRelease.data.id; @@ -777,17 +676,16 @@ jobs: await Promise.all([ // Upload Windows artifacts - uploadDirectoryToRelease('win-sign/signed'), + uploadDirectoryToRelease('windows-artifacts/installers'), uploadDirectoryToRelease('win-x86-payload-and-symbols'), // Upload macOS artifacts - uploadDirectoryToRelease('osx-x64-sign'), - uploadDirectoryToRelease('osx-arm64-sign'), + uploadDirectoryToRelease('macos-osx-x64-artifacts/pkg'), + uploadDirectoryToRelease('macos-osx-arm64-artifacts/pkg'), uploadDirectoryToRelease('osx-payload-and-symbols'), // Upload Linux artifacts - uploadDirectoryToRelease('linux-build/tar'), - uploadDirectoryToRelease('linux-sign'), + uploadDirectoryToRelease('linux-artifacts'), // Upload .NET tool package uploadDirectoryToRelease('dotnet-tool-sign'), diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index 34335698b..ff28dc85c 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -36,7 +36,7 @@ jobs: dnf install which -y fi - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: | sh "${GITHUB_WORKSPACE}/src/linux/Packaging.Linux/install-from-source.sh" -y diff --git a/Git-Credential-Manager.sln b/Git-Credential-Manager.sln index 75e1254b7..a883e760e 100644 --- a/Git-Credential-Manager.sln +++ b/Git-Credential-Manager.sln @@ -35,24 +35,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atlassian.Bitbucket", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atlassian.Bitbucket.Tests", "src\shared\Atlassian.Bitbucket.Tests\Atlassian.Bitbucket.Tests.csproj", "{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.UI.Windows", "src\windows\Core.UI.Windows\Core.UI.Windows.csproj", "{2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Packaging.Linux", "src\linux\Packaging.Linux\Packaging.Linux.csproj", "{AD2A935F-3720-4802-8119-6A9B35B254DF}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "linux", "linux", "{8F9D7E67-7DD7-4E32-9134-423281AF00E9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.UI.Windows", "src\windows\GitHub.UI.Windows\GitHub.UI.Windows.csproj", "{0A86ED89-1FC5-42AA-925C-4578FA30607A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atlassian.Bitbucket.UI.Windows", "src\windows\Atlassian.Bitbucket.UI.Windows\Atlassian.Bitbucket.UI.Windows.csproj", "{3F015046-DAF2-4D2A-96EC-F9782F169E45}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitLab", "src\shared\GitLab\GitLab.csproj", "{570897DC-A85C-4598-B793-9A00CF710119}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitLab.Tests", "src\shared\GitLab.Tests\GitLab.Tests.csproj", "{1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitLab.UI.Windows", "src\windows\GitLab.UI.Windows\GitLab.UI.Windows.csproj", "{83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Git-Credential-Manager.UI.Windows", "src\windows\Git-Credential-Manager.UI.Windows\Git-Credential-Manager.UI.Windows.csproj", "{01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -245,16 +235,6 @@ Global {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.MacRelease|Any CPU.Build.0 = Release|Any CPU {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU {AD2A935F-3720-4802-8119-6A9B35B254DF}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU {AD2A935F-3720-4802-8119-6A9B35B254DF}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU {AD2A935F-3720-4802-8119-6A9B35B254DF}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU @@ -265,26 +245,6 @@ Global {AD2A935F-3720-4802-8119-6A9B35B254DF}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU {AD2A935F-3720-4802-8119-6A9B35B254DF}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {AD2A935F-3720-4802-8119-6A9B35B254DF}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU - {0A86ED89-1FC5-42AA-925C-4578FA30607A}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU - {3F015046-DAF2-4D2A-96EC-F9782F169E45}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU {570897DC-A85C-4598-B793-9A00CF710119}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {570897DC-A85C-4598-B793-9A00CF710119}.Debug|Any CPU.Build.0 = Debug|Any CPU {570897DC-A85C-4598-B793-9A00CF710119}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -317,26 +277,6 @@ Global {1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.MacRelease|Any CPU.Build.0 = Release|Any CPU {1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.WindowsRelease|Any CPU.ActiveCfg = Debug|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -357,15 +297,10 @@ Global {85903170-9E52-4B53-A6E4-3F416F684FAE} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} {B49881A6-E734-490E-8EA7-FB0D9E296CFB} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {025E5329-A0B1-4BA9-9203-B70B44A5F9E0} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} - {2B3CD8FF-84A6-4B53-A28B-D7A75B0AB4D7} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} {8F9D7E67-7DD7-4E32-9134-423281AF00E9} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E} {AD2A935F-3720-4802-8119-6A9B35B254DF} = {8F9D7E67-7DD7-4E32-9134-423281AF00E9} - {0A86ED89-1FC5-42AA-925C-4578FA30607A} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} - {3F015046-DAF2-4D2A-96EC-F9782F169E45} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} {570897DC-A85C-4598-B793-9A00CF710119} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} - {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} - {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0EF9FC65-E6BA-45D4-A455-262A9EA4366B} diff --git a/README.md b/README.md index 6a9663e44..18c9b1309 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ We're [MIT][gcm-license] licensed. When using GitHub logos, please be sure to follow the [GitHub logo guidelines][github-logos]. -[azure-devops]: https://dev.azure.com/ +[azure-devops]: https://azure.microsoft.com/en-us/products/devops [azure-devops-ssh]: https://docs.microsoft.com/en-us/azure/devops/repos/git/use-ssh-keys-to-authenticate?view=azure-devops [bitbucket]: https://bitbucket.org [bitbucket-ssh]: https://confluence.atlassian.com/bitbucket/ssh-keys-935365775.html diff --git a/VERSION b/VERSION index 5b3c26b59..c2fa7a287 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.2.0 +2.4.0.0 diff --git a/docs/configuration.md b/docs/configuration.md index 268e35c40..2439c9297 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -233,6 +233,28 @@ Defaults to enabled. --- +### credential.guiSoftwareRendering + +Force the use of software rendering for GUI prompts. + +This is currently only applicable on Windows. + +#### Example + +```shell +git config --global credential.guiSoftwareRendering true +``` + +Defaults to false (use hardware acceleration where available). + +> [!NOTE] +> Windows on ARM devices defaults to using software rendering to work around a +> known Avalonia issue: + +**Also see: [GCM_GUI_SOFTWARE_RENDERING][gcm-gui-software-rendering]** + +--- + ### credential.autoDetectTimeout Set the maximum length of time, in milliseconds, that GCM should wait for a @@ -793,6 +815,95 @@ git config --global credential.azreposCredentialType oauth --- +### credential.azreposManagedIdentity + +Use a [Managed Identity][managed-identity] to authenticate with Azure Repos. + +The value `system` will tell GCM to use the system-assigned Managed Identity. + +To specify a user-assigned Managed Identity, use the format `id://{clientId}` +where `{clientId}` is the client ID of the Managed Identity. Alternatively any +GUID-like value will also be interpreted as a user-assigned Managed Identity +client ID. + +To specify a Managed Identity associated with an Azure resource, you can use the +format `resource://{resourceId}` where `{resourceId}` is the ID of the resource. + +For more information about managed identities, see the Azure DevOps +[documentation][azrepos-sp-mid]. + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +```shell +git config --global credential.azreposManagedIdentity "id://11111111-1111-1111-1111-111111111111" +``` + +**Also see: [GCM_AZREPOS_MANAGEDIDENTITY][gcm-azrepos-credentialmanagedidentity]** + +--- + +### credential.azreposServicePrincipal + +Specify the client and tenant IDs of a [service principal][service-principal] +to use when performing Microsoft authentication for Azure Repos. + +The value of this setting should be in the format: `{tenantId}/{clientId}`. + +You must also set at least one authentication mechanism if you set this value: + +- [credential.azreposServicePrincipalSecret][credential-azrepos-sp-secret] +- [credential.azreposServicePrincipalCertificateThumbprint][credential-azrepos-sp-cert-thumbprint] + +For more information about service principals, see the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Example + +```shell +git config --global credential.azreposServicePrincipal "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222" +``` + +**Also see: [GCM_AZREPOS_SERVICE_PRINCIPAL][gcm-azrepos-service-principal]** + +--- + +### credential.azreposServicePrincipalSecret + +Specifies the client secret for the [service principal][service-principal] when +performing Microsoft authentication for Azure Repos with +[credential.azreposServicePrincipalSecret][credential-azrepos-sp] set. + +#### Example + +```shell +git config --global credential.azreposServicePrincipalSecret "da39a3ee5e6b4b0d3255bfef95601890afd80709" +``` + +**Also see: [GCM_AZREPOS_SP_SECRET][gcm-azrepos-sp-secret]** + +--- + +### credential.azreposServicePrincipalCertificateThumbprint + +Specifies the thumbprint of a certificate to use when authenticating as a +[service principal][service-principal] for Azure Repos when +[GCM_AZREPOS_SERVICE_PRINCIPAL][credential-azrepos-sp] is set. + +#### Example + +```shell +git config --global credential.azreposServicePrincipalCertificateThumbprint "9b6555292e4ea21cbc2ebd23e66e2f91ebbe92dc" +``` + +**Also see: [GCM_AZREPOS_SP_CERT_THUMBPRINT][gcm-azrepos-sp-cert-thumbprint]** + +--- + ### trace2.normalTarget Turns on Trace2 Normal Format tracing - see [Git's Trace2 Normal Format @@ -878,6 +989,7 @@ Defaults to disabled. [gcm-authority]: environment.md#GCM_AUTHORITY-deprecated [gcm-autodetect-timeout]: environment.md#GCM_AUTODETECT_TIMEOUT [gcm-azrepos-credentialtype]: environment.md#GCM_AZREPOS_CREDENTIALTYPE +[gcm-azrepos-credentialmanagedidentity]: environment.md#GCM_AZREPOS_MANAGEDIDENTITY [gcm-bitbucket-always-refresh-credentials]: environment.md#GCM_BITBUCKET_ALWAYS_REFRESH_CREDENTIALS [gcm-bitbucket-authmodes]: environment.md#GCM_BITBUCKET_AUTHMODES [gcm-credential-cache-options]: environment.md#GCM_CREDENTIAL_CACHE_OPTIONS @@ -888,6 +1000,7 @@ Defaults to disabled. [gcm-github-authmodes]: environment.md#GCM_GITHUB_AUTHMODES [gcm-gitlab-authmodes]:environment.md#GCM_GITLAB_AUTHMODES [gcm-gui-prompt]: environment.md#GCM_GUI_PROMPT +[gcm-gui-software-rendering]: environment.md#GCM_GUI_SOFTWARE_RENDERING [gcm-http-proxy]: environment.md#GCM_HTTP_PROXY-deprecated [gcm-interactive]: environment.md#GCM_INTERACTIVE [gcm-msauth-flow]: environment.md#GCM_MSAUTH_FLOW @@ -905,6 +1018,7 @@ Defaults to disabled. [http-proxy]: netconfig.md#http-proxy [autodetect]: autodetect.md [libsecret]: https://wiki.gnome.org/Projects/Libsecret +[managed-identity]: https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview [provider-migrate]: migration.md#gcm_authority [cache-options]: https://git-scm.com/docs/git-credential-cache#_options [pass]: https://www.passwordstore.org/ @@ -915,3 +1029,11 @@ Defaults to disabled. [trace2-performance-docs]: https://git-scm.com/docs/api-trace2#_the_performance_format_target [trace2-performance-env]: environment.md#GIT_TRACE2_PERF [wam]: windows-broker.md +[service-principal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals +[azrepos-sp-mid]: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +[credential-azrepos-sp]: #credentialazreposserviceprincipal +[credential-azrepos-sp-secret]: #credentialazreposserviceprincipalsecret +[credential-azrepos-sp-cert-thumbprint]: #credentialazreposserviceprincipalcertificatethumbprint +[gcm-azrepos-service-principal]: environment.md#GCM_AZREPOS_SERVICE_PRINCIPAL +[gcm-azrepos-sp-secret]: environment.md#GCM_AZREPOS_SP_SECRET +[gcm-azrepos-sp-cert-thumbprint]: environment.md#GCM_AZREPOS_SP_CERT_THUMBPRINT diff --git a/docs/environment.md b/docs/environment.md index f3d8a618e..18f3f05fe 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -272,6 +272,36 @@ Defaults to enabled. --- +### GCM_GUI_SOFTWARE_RENDERING + +Force the use of software rendering for GUI prompts. + +This is currently only applicable on Windows. + +#### Example + +##### Windows + +```batch +SET GCM_GUI_SOFTWARE_RENDERING=1 +``` + +##### macOS/Linux + +```bash +export GCM_GUI_SOFTWARE_RENDERING=1 +``` + +Defaults to false (use hardware acceleration where available). + +> [!NOTE] +> Windows on ARM devices defaults to using software rendering to work around a +> known Avalonia issue: + +**Also see: [credential.guiSoftwareRendering][credential-guisoftwarerendering]** + +--- + ### GCM_AUTODETECT_TIMEOUT Set the maximum length of time, in milliseconds, that GCM should wait for a @@ -894,6 +924,121 @@ export GCM_AZREPOS_CREDENTIALTYPE="oauth" --- +### GCM_AZREPOS_MANAGEDIDENTITY + +Use a [Managed Identity][managed-identity] to authenticate with Azure Repos. + +The value `system` will tell GCM to use the system-assigned Managed Identity. + +To specify a user-assigned Managed Identity, use the format `id://{clientId}` +where `{clientId}` is the client ID of the Managed Identity. Alternatively any +GUID-like value will also be interpreted as a user-assigned Managed Identity +client ID. + +To specify a Managed Identity associated with an Azure resource, you can use the +format `resource://{resourceId}` where `{resourceId}` is the ID of the resource. + +For more information about managed identities, see the Azure DevOps +[documentation][azrepos-sp-mid]. + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +#### Windows + +```batch +SET GCM_AZREPOS_MANAGEDIDENTITY="id://11111111-1111-1111-1111-111111111111" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_MANAGEDIDENTITY="id://11111111-1111-1111-1111-111111111111" +``` + +**Also see: [credential.azreposManagedIdentity][credential-azrepos-managedidentity]** + +--- + +### GCM_AZREPOS_SERVICE_PRINCIPAL + +Specify the client and tenant IDs of a [service principal][service-principal] +to use when performing Microsoft authentication for Azure Repos. + +The value of this setting should be in the format: `{tenantId}/{clientId}`. + +You must also set at least one authentication mechanism if you set this value: + +- [GCM_AZREPOS_SP_SECRET][gcm-azrepos-sp-secret] +- [GCM_AZREPOS_SP_CERT_THUMBPRINT][gcm-azrepos-sp-cert-thumbprint] + +For more information about service principals, see the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Windows + +```batch +SET GCM_AZREPOS_SERVICE_PRINCIPAL="11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_SERVICE_PRINCIPAL="11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222" +``` + +**Also see: [credential.azreposServicePrincipal][credential-azrepos-sp]** + +--- + +### GCM_AZREPOS_SP_SECRET + +Specifies the client secret for the [service principal][service-principal] when +performing Microsoft authentication for Azure Repos with +[GCM_AZREPOS_SERVICE_PRINCIPAL][gcm-azrepos-sp] set. + +#### Windows + +```batch +SET GCM_AZREPOS_SP_SECRET="da39a3ee5e6b4b0d3255bfef95601890afd80709" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_SP_SECRET="da39a3ee5e6b4b0d3255bfef95601890afd80709" +``` + +**Also see: [credential.azreposServicePrincipalSecret][credential-azrepos-sp-secret]** + +--- + +### GCM_AZREPOS_SP_CERT_THUMBPRINT + +Specifies the thumbprint of a certificate to use when authenticating as a +[service principal][service-principal] for Azure Repos when +[GCM_AZREPOS_SERVICE_PRINCIPAL][gcm-azrepos-sp] is set. + +#### Windows + +```batch +SET GCM_AZREPOS_SP_CERT_THUMBPRINT="9b6555292e4ea21cbc2ebd23e66e2f91ebbe92dc" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_SP_CERT_THUMBPRINT="9b6555292e4ea21cbc2ebd23e66e2f91ebbe92dc" +``` + +**Also see: [credential.azreposServicePrincipalCertificateThumbprint][credential-azrepos-sp-cert-thumbprint]** + +--- + ### GIT_TRACE2 Turns on Trace2 Normal Format tracing - see [Git's Trace2 Normal Format @@ -985,7 +1130,8 @@ Defaults to disabled. [credential-allowwindowsauth]: environment.md#credentialallowWindowsAuth [credential-authority]: configuration.md#credentialauthority-deprecated [credential-autodetecttimeout]: configuration.md#credentialautodetecttimeout -[credential-azrepos-credential-type]: configuration.md#azreposcredentialtype +[credential-azrepos-credential-type]: configuration.md#credentialazreposcredentialtype +[credential-azrepos-managedidentity]: configuration.md#credentialazreposmanagedidentity [credential-bitbucketauthmodes]: configuration.md#credentialbitbucketAuthModes [credential-cacheoptions]: configuration.md#credentialcacheoptions [credential-credentialstore]: configuration.md#credentialcredentialstore @@ -995,6 +1141,7 @@ Defaults to disabled. [credential-githubauthmodes]: configuration.md#credentialgitHubAuthModes [credential-gitlabauthmodes]: configuration.md#credentialgitLabAuthModes [credential-guiprompt]: configuration.md#credentialguiprompt +[credential-guisoftwarerendering]: configuration.md#credentialguisoftwarerendering [credential-httpproxy]: configuration.md#credentialhttpProxy-deprecated [credential-interactive]: configuration.md#credentialinteractive [credential-namespace]: configuration.md#credentialnamespace @@ -1022,6 +1169,7 @@ Defaults to disabled. [github-emu]: https://docs.github.com/en/enterprise-cloud@latest/admin/identity-and-access-management/using-enterprise-managed-users-for-iam/about-enterprise-managed-users [network-http-proxy]: netconfig.md#http-proxy [libsecret]: https://wiki.gnome.org/Projects/Libsecret +[managed-identity]: https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview [migration-guide]: migration.md#gcm_authority [passwordstore]: https://www.passwordstore.org/ [trace2-normal-docs]: https://git-scm.com/docs/api-trace2#_the_normal_format_target @@ -1031,3 +1179,11 @@ Defaults to disabled. [trace2-performance-docs]: https://git-scm.com/docs/api-trace2#_the_performance_format_target [trace2-performance-config]: configuration.md#trace2perfTarget [windows-broker]: windows-broker.md +[service-principal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals +[azrepos-sp-mid]: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +[gcm-azrepos-sp]: #gcm_azrepos_service_principal +[gcm-azrepos-sp-secret]: #gcm_azrepos_sp_secret +[gcm-azrepos-sp-cert-thumbprint]: #gcm_azrepos_sp_cert_thumbprint +[credential-azrepos-sp]: configuration.md#credentialazreposserviceprincipal +[credential-azrepos-sp-secret]: configuration.md#credentialazreposserviceprincipalsecret +[credential-azrepos-sp-cert-thumbprint]: configuration.md#credentialazreposserviceprincipalcertificatethumbprint diff --git a/docs/generic-oauth.md b/docs/generic-oauth.md index 6620134fc..08b735ccb 100644 --- a/docs/generic-oauth.md +++ b/docs/generic-oauth.md @@ -40,7 +40,7 @@ following values in your Git configuration: - Client ID - Client Secret (optional) -- Redirect URL +- Redirect URL (optional, defaults to `http://127.0.0.1`) - Scopes (optional) - OAuth Endpoints - Authorization Endpoint diff --git a/docs/install.md b/docs/install.md index b4989ba13..4268858bb 100644 --- a/docs/install.md +++ b/docs/install.md @@ -72,7 +72,7 @@ installation method. #### Install -Download the latest [.deb package][latest-release], and run the following: +Download the latest [.deb package][latest-release]*, and run the following: ```shell sudo dpkg -i @@ -86,13 +86,16 @@ git-credential-manager unconfigure sudo dpkg -r gcm ``` +*If you'd like to validate the package's signature after downloading, check out +the instructions [here][linux-validate-gpg-debian]. + --- ### Tarball #### Install -Download the latest [tarball][latest-release], and run the following: +Download the latest [tarball][latest-release]*, and run the following: ```shell tar -xvf -C /usr/local/bin @@ -106,6 +109,9 @@ git-credential-manager unconfigure rm $(command -v git-credential-manager) ``` +*If you would like to validate the tarball's signature after downloading, check +out the instructions [here][linux-validate-gpg-tarball]. + --- ### Install from source helper script @@ -238,4 +244,6 @@ dotnet tool uninstall -g git-credential-manager [git-for-windows-screenshot]: https://user-images.githubusercontent.com/5658207/140082529-1ac133c1-0922-4a24-af03-067e27b3988b.png [latest-release]: https://github.com/git-ecosystem/git-credential-manager/releases/latest [linux-uninstall]: linux-fromsrc-uninstall.md +[linux-validate-gpg-debian]: ./linux-validate-gpg.md#debian-package +[linux-validate-gpg-tarball]: ./linux-validate-gpg.md#tarball [ms-wsl]: https://aka.ms/wsl# diff --git a/docs/linux-validate-gpg.md b/docs/linux-validate-gpg.md new file mode 100644 index 000000000..49150c1e5 --- /dev/null +++ b/docs/linux-validate-gpg.md @@ -0,0 +1,85 @@ +# Validating GCM's GPG signature + +Follow the below instructions to import GCM's public key and use it to validate +the latest Debian package and/or tarball signature. + +## Debian package + +```shell +# Install needed packages +apt-get install -y curl debsig-verify + +# Download public key signature file +curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ +| grep -E 'browser_download_url.*gcm-public.asc' \ +| cut -d : -f 2,3 \ +| tr -d \" \ +| xargs -I 'url' curl -L -o gcm-public.asc 'url' + +# De-armor public key signature file +gpg --output gcm-public.gpg --dearmor gcm-public.asc + +# Note that the fingerprint of this key is "3C853823978B07FA", which you can +# determine by running: +gpg --show-keys gcm-public.asc | head -n 2 | tail -n 1 | tail -c 17 + +# Copy de-armored public key to debsig keyring folder +mkdir /usr/share/debsig/keyrings/3C853823978B07FA +mv gcm-public.gpg /usr/share/debsig/keyrings/3C853823978B07FA/ + +# Create an appropriate policy file +mkdir /etc/debsig/policies/3C853823978B07FA +cat > /etc/debsig/policies/3C853823978B07FA/generic.pol << EOL + + + + + + + + + + + + + + + +EOL + +# Download Debian package +curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ +| grep "browser_download_url.*deb" \ +| cut -d : -f 2,3 \ +| tr -d \" \ +| xargs -I 'url' curl -L -o gcm.deb 'url' + +# Verify +debsig-verify gcm.deb +``` + +## Tarball +```shell +# Download the public key signature file +curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ +| grep -E 'browser_download_url.*gcm-public.asc' \ +| cut -d : -f 2,3 \ +| tr -d \" \ +| xargs -I 'url' curl -L -o gcm-public.asc 'url' + +# Import the public key +gpg --import gcm-public.asc + +# Download the tarball and its signature file +curl -s https://api.github.com/repos/ldennington/git-credential-manager/releases/latest \ +| grep -E 'browser_download_url.*gcm-linux.*[0-9].[0-9].[0-9].tar.gz' \ +| cut -d : -f 2,3 \ +| tr -d \" \ +| xargs -I 'url' curl -LO 'url' + +# Trust the public key +echo -e "5\ny\n" | gpg --command-fd 0 --expert --edit-key 3C853823978B07FA trust + +# Verify the signature +gpg --verify gcm-linux_amd64*.tar.gz.asc gcm-linux*.tar.gz +``` diff --git a/docs/windows-broker.md b/docs/windows-broker.md index 1cc42f222..bfe5afcb0 100644 --- a/docs/windows-broker.md +++ b/docs/windows-broker.md @@ -216,7 +216,7 @@ In order to fix the problem, there are a few options: [azure-refresh-token-terms]: https://docs.microsoft.com/azure/active-directory/devices/concept-primary-refresh-token#key-terminology-and-components [azure-conditional-access]: https://docs.microsoft.com/azure/active-directory/conditional-access/overview -[azure-devops]: https://dev.azure.com +[azure-devops]: https://azure.microsoft.com/en-us/products/devops [GCM_MSAUTH_USEBROKER]: environment.md#GCM_MSAUTH_USEBROKER-experimental [GCM_MSAUTH_USEDEFAULTACCOUNT]: environment.md#GCM_MSAUTH_USEDEFAULTACCOUNT-experimental [credential.msauthUseBroker]: configuration.md#credentialmsauthusebroker-experimental diff --git a/src/linux/Packaging.Linux/Packaging.Linux.csproj b/src/linux/Packaging.Linux/Packaging.Linux.csproj index b9fb5ccd5..362ffc230 100644 --- a/src/linux/Packaging.Linux/Packaging.Linux.csproj +++ b/src/linux/Packaging.Linux/Packaging.Linux.csproj @@ -9,6 +9,7 @@ false + /usr/local @@ -23,8 +24,8 @@ - - + + diff --git a/src/linux/Packaging.Linux/build.sh b/src/linux/Packaging.Linux/build.sh index 3b179e22d..6672857d2 100755 --- a/src/linux/Packaging.Linux/build.sh +++ b/src/linux/Packaging.Linux/build.sh @@ -30,12 +30,21 @@ case "$i" in INSTALL_FROM_SOURCE="${i#*=}" shift # past argument=value ;; + --install-prefix=*) + INSTALL_PREFIX="${i#*=}" + shift # past argument=value + ;; *) # unknown option ;; esac done +# Ensure install prefix exists +if [! -d "$INSTALL_PREFIX" ]; then + mkdir -p "$INSTALL_PREFIX" +fi + # Perform pre-execution checks CONFIGURATION="${CONFIGURATION:=Debug}" if [ -z "$VERSION" ]; then @@ -50,14 +59,11 @@ SYMBOLS="$OUTDIR/payload.sym" "$INSTALLER_SRC/layout.sh" --configuration="$CONFIGURATION" || exit 1 if [ $INSTALL_FROM_SOURCE = true ]; then - INSTALL_LOCATION="/usr/local" - mkdir -p "$INSTALL_LOCATION" - - echo "Installing..." + echo "Installing to $INSTALL_PREFIX" # Install directories - INSTALL_TO="$INSTALL_LOCATION/share/gcm-core/" - LINK_TO="$INSTALL_LOCATION/bin/" + INSTALL_TO="$INSTALL_PREFIX/share/gcm-core/" + LINK_TO="$INSTALL_PREFIX/bin/" mkdir -p "$INSTALL_TO" "$LINK_TO" diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index e379a55f5..98fe7bc4d 100755 --- a/src/linux/Packaging.Linux/install-from-source.sh +++ b/src/linux/Packaging.Linux/install-from-source.sh @@ -13,15 +13,30 @@ for i in "$@"; do is_ci=true shift # Past argument=value ;; + --install-prefix=*) + installPrefix="${i#*=}" + shift # past argument=value + ;; esac done +# If install-prefix is not passed, use default value +if [ -z "$installPrefix" ]; then + installPrefix=/usr/local +fi + +# Ensure install directory exists +if [! -d "$installPrefix" ]; then + echo "The folder $installPrefix does not exist" + exit +fi + # In non-ci scenarios, advertise what we will be doing and # give user the option to exit. if [ -z $is_ci ]; then echo "This script will download, compile, and install Git Credential Manager to: - /usr/local/bin + $installPrefix/bin Git Credential Manager is licensed under the MIT License: https://aka.ms/gcm/license" @@ -225,5 +240,5 @@ if [ -z "$DOTNET_ROOT" ]; then fi cd "$toplevel_path" -$sudo_cmd env "PATH=$PATH" $DOTNET_ROOT/dotnet build ./src/linux/Packaging.Linux/Packaging.Linux.csproj -c Release -p:InstallFromSource=true -add_to_PATH "/usr/local/bin" +$sudo_cmd env "PATH=$PATH" $DOTNET_ROOT/dotnet build ./src/linux/Packaging.Linux/Packaging.Linux.csproj -c Release -p:InstallFromSource=true -p:installPrefix=$installPrefix +add_to_PATH "$installPrefix/bin" diff --git a/.github/run_developer_signing.sh b/src/osx/Installer.Mac/codesign.sh similarity index 90% rename from .github/run_developer_signing.sh rename to src/osx/Installer.Mac/codesign.sh index 8b3de88a3..d66c8acd9 100755 --- a/.github/run_developer_signing.sh +++ b/src/osx/Installer.Mac/codesign.sh @@ -26,9 +26,9 @@ for f in * do macho=$(file --mime $f | grep mach) # Runtime sign dylibs and Mach-O binaries - if [[ $f == *.dylib ]] || [ ! -z "$macho" ]; - then - echo "Runtime Signing $f" + if [[ $f == *.dylib ]] || [ ! -z "$macho" ]; + then + echo "Runtime Signing $f" codesign -s "$DEVELOPER_ID" $f --timestamp --force --options=runtime --entitlements $ENTITLEMENTS_FILE elif [ -d "$f" ]; then @@ -39,8 +39,8 @@ do codesign -s "$DEVELOPER_ID" $i --timestamp --force done cd .. - else + else echo "Signing $f" codesign -s "$DEVELOPER_ID" $f --timestamp --force fi -done \ No newline at end of file +done diff --git a/src/osx/Installer.Mac/dist.sh b/src/osx/Installer.Mac/dist.sh index c60d0767b..f26761e26 100755 --- a/src/osx/Installer.Mac/dist.sh +++ b/src/osx/Installer.Mac/dist.sh @@ -35,6 +35,10 @@ case "$i" in RUNTIME="${i#*=}" shift ;; + --identity=*) + IDENTITY="${i#*=}" + shift + ;; *) # unknown option ;; @@ -93,6 +97,7 @@ echo "Building product package..." --distribution "$DISTPATH" \ --identifier "$IDENTIFIER" \ --version "$VERSION" \ + ${IDENTITY:+"--sign"} ${IDENTITY:+"$IDENTITY"} \ "$DISTOUT" || exit 1 echo "Product build complete." diff --git a/src/osx/Installer.Mac/notarize.sh b/src/osx/Installer.Mac/notarize.sh new file mode 100755 index 000000000..9315d688a --- /dev/null +++ b/src/osx/Installer.Mac/notarize.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +for i in "$@" +do +case "$i" in + --package=*) + PACKAGE="${i#*=}" + shift # past argument=value + ;; + --keychain-profile=*) + KEYCHAIN_PROFILE="${i#*=}" + shift # past argument=value + ;; + *) + die "unknown option '$i'" + ;; +esac +done + +if [ -z "$PACKAGE" ]; then + echo "error: missing package argument" + exit 1 +elif [ -z "$KEYCHAIN_PROFILE" ]; then + echo "error: missing keychain profile argument" + exit 1 +fi + +# Exit as soon as any line fails +set -e + +# Send the notarization request +xcrun notarytool submit -v "$PACKAGE" -p "$KEYCHAIN_PROFILE" --wait + +# Staple the notarization ticket (to allow offline installation) +xcrun stapler staple -v "$PACKAGE" diff --git a/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs b/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs index 4e277d0f3..36b116615 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs @@ -55,6 +55,20 @@ public void BitbucketHostProvider_IsSupported(string protocol, string host, bool Assert.Equal(expected, provider.IsSupported(input)); } + [Theory] + [InlineData("Basic realm=\"Atlassian Bitbucket\"", true)] + [InlineData("Basic realm=\"GitSponge\"", false)] + public void BitbucketHostProvider_IsSupported_WWWAuth(string wwwauth, bool expected) + { + var input = new InputArguments(new Dictionary + { + ["wwwauth"] = wwwauth, + }); + + var provider = new BitbucketHostProvider(new TestCommandContext()); + Assert.Equal(expected, provider.IsSupported(input)); + } + [Fact] public void BitbucketHostProvider_IsSupported_FailsForNullInput() { diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs index f3f653f01..35472682c 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Atlassian.Bitbucket.Cloud; @@ -43,6 +44,11 @@ public bool IsSupported(InputArguments input) return false; } + if (input.WwwAuth.Any(x => x.Contains("realm=\"Atlassian Bitbucket\"", StringComparison.InvariantCultureIgnoreCase))) + { + return true; + } + // Split port number and hostname from host input argument if (!input.TryGetHostAndPort(out string hostName, out _)) { diff --git a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs index ef0f50a86..0e1a70659 100644 --- a/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs +++ b/src/shared/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs @@ -1,6 +1,8 @@ using System; +using System.Threading.Tasks; using GitCredentialManager.Authentication; using GitCredentialManager.Tests.Objects; +using Microsoft.Identity.Client.AppConfig; using Xunit; namespace GitCredentialManager.Tests.Authentication @@ -8,7 +10,7 @@ namespace GitCredentialManager.Tests.Authentication public class MicrosoftAuthenticationTests { [Fact] - public async System.Threading.Tasks.Task MicrosoftAuthentication_GetAccessTokenAsync_NoInteraction_ThrowsException() + public async Task MicrosoftAuthentication_GetTokenForUserAsync_NoInteraction_ThrowsException() { const string authority = "https://login.microsoftonline.com/common"; const string clientId = "C9E8FDA6-1D46-484C-917C-3DBD518F27C3"; @@ -24,7 +26,48 @@ public async System.Threading.Tasks.Task MicrosoftAuthentication_GetAccessTokenA var msAuth = new MicrosoftAuthentication(context); await Assert.ThrowsAsync( - () => msAuth.GetTokenAsync(authority, clientId, redirectUri, scopes, userName, false)); + () => msAuth.GetTokenForUserAsync(authority, clientId, redirectUri, scopes, userName, false)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("system")] + [InlineData("SYSTEM")] + [InlineData("sYsTeM")] + [InlineData("00000000-0000-0000-0000-000000000000")] + [InlineData("id://00000000-0000-0000-0000-000000000000")] + [InlineData("ID://00000000-0000-0000-0000-000000000000")] + [InlineData("Id://00000000-0000-0000-0000-000000000000")] + public void MicrosoftAuthentication_GetManagedIdentity_ValidSystemId_ReturnsSystemId(string str) + { + ManagedIdentityId actual = MicrosoftAuthentication.GetManagedIdentity(str); + Assert.Equal(ManagedIdentityId.SystemAssigned, actual); + } + + [Theory] + [InlineData("8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("id://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("ID://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("Id://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("resource://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("RESOURCE://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("rEsOuRcE://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("resource://00000000-0000-0000-0000-000000000000")] + public void MicrosoftAuthentication_GetManagedIdentity_ValidUserIdByClientId_ReturnsUserId(string str) + { + ManagedIdentityId actual = MicrosoftAuthentication.GetManagedIdentity(str); + Assert.NotNull(actual); + Assert.NotEqual(ManagedIdentityId.SystemAssigned, actual); + } + + [Theory] + [InlineData("unknown://8B49DCA0-1298-4A0D-AD6D-934E40230839")] + [InlineData("this is a string")] + public void MicrosoftAuthentication_GetManagedIdentity_Invalid_ThrowsArgumentException(string str) + { + Assert.Throws(() => MicrosoftAuthentication.GetManagedIdentity(str)); } } } diff --git a/src/shared/Core.Tests/GenericOAuthConfigTests.cs b/src/shared/Core.Tests/GenericOAuthConfigTests.cs index 08dfacab4..b05ae2e8b 100644 --- a/src/shared/Core.Tests/GenericOAuthConfigTests.cs +++ b/src/shared/Core.Tests/GenericOAuthConfigTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using GitCredentialManager.Tests.Objects; using Xunit; @@ -9,7 +10,9 @@ public class GenericOAuthConfigTests [Fact] public void GenericOAuthConfig_TryGet_Valid_ReturnsTrue() { - var remoteUri = new Uri("https://example.com"); + var protocol = "https"; + var host = "example.com"; + var remoteUri = new Uri($"{protocol}://{host}"); const string expectedClientId = "115845b0-77f8-4c06-a3dc-7d277381fad1"; const string expectedClientSecret = "4D35385D9F24"; const string expectedUserName = "TEST_USER"; @@ -44,7 +47,12 @@ public void GenericOAuthConfig_TryGet_Valid_ReturnsTrue() RemoteUri = remoteUri }; - bool result = GenericOAuthConfig.TryGet(trace, settings, remoteUri, out GenericOAuthConfig config); + var input = new InputArguments(new Dictionary { + {"protocol", protocol}, + {"host", host}, + }); + + bool result = GenericOAuthConfig.TryGet(trace, settings, input, out GenericOAuthConfig config); Assert.True(result); Assert.Equal(expectedClientId, config.ClientId); @@ -57,5 +65,39 @@ public void GenericOAuthConfig_TryGet_Valid_ReturnsTrue() Assert.Equal(expectedUserName, config.DefaultUserName); Assert.True(config.UseAuthHeader); } + + [Fact] + public void GenericOAuthConfig_TryGet_Gitea() + { + var protocol = "https"; + var host = "example.com"; + var remoteUri = new Uri($"{protocol}://{host}"); + const string expectedClientId = GenericOAuthConfig.WellKnown.GiteaClientId; + string[] expectedScopes = Array.Empty(); + var expectedRedirectUri = GenericOAuthConfig.WellKnown.LocalIPv4RedirectUri; + var expectedAuthzEndpoint = new Uri(remoteUri, GenericOAuthConfig.WellKnown.GiteaAuthzEndpoint); + var expectedTokenEndpoint = new Uri(remoteUri, GenericOAuthConfig.WellKnown.GiteaTokenEndpoint); + + var trace = new NullTrace(); + var settings = new TestSettings + { + RemoteUri = remoteUri + }; + + var input = new InputArguments(new Dictionary { + {"protocol", protocol}, + {"host", host}, + {"wwwauth", "Basic realm=\"Gitea\""} + }); + + bool result = GenericOAuthConfig.TryGet(trace, settings, input, out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(expectedClientId, config.ClientId); + Assert.Equal(expectedRedirectUri, config.RedirectUri); + Assert.Equal(expectedScopes, config.Scopes); + Assert.Equal(expectedAuthzEndpoint, config.Endpoints.AuthorizationEndpoint); + Assert.Equal(expectedTokenEndpoint, config.Endpoints.TokenEndpoint); + } } } diff --git a/src/shared/Core/ApplicationBase.cs b/src/shared/Core/ApplicationBase.cs index 42f1390e9..5ff692a4d 100644 --- a/src/shared/Core/ApplicationBase.cs +++ b/src/shared/Core/ApplicationBase.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using GitCredentialManager.UI; namespace GitCredentialManager { @@ -74,6 +75,12 @@ public Task RunAsync(string[] args) Context.Trace.WriteLine("Tracing of secrets is enabled. Trace output may contain sensitive information."); } + // Set software rendering if defined in settings + if (Context.Settings.UseSoftwareRendering) + { + AvaloniaUi.Initialize(win32SoftwareRendering: true); + } + return RunInternalAsync(args); } diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 06bd7330d..b39cc1a73 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using GitCredentialManager.Interop.Windows.Native; using Microsoft.Identity.Client; @@ -10,12 +11,12 @@ using System.Text; using System.Threading; using GitCredentialManager.UI; +using GitCredentialManager.UI.Controls; using GitCredentialManager.UI.ViewModels; using GitCredentialManager.UI.Views; +using Microsoft.Identity.Client.AppConfig; #if NETFRAMEWORK -using System.Drawing; -using System.Windows.Forms; using Microsoft.Identity.Client.Broker; #endif @@ -23,8 +24,74 @@ namespace GitCredentialManager.Authentication { public interface IMicrosoftAuthentication { - Task GetTokenAsync(string authority, string clientId, Uri redirectUri, + /// + /// Acquire an access token for a user principal. + /// + /// Azure authority. + /// Client ID. + /// Redirect URI for the client. + /// Set of scopes to request. + /// Optional user name for an existing account. + /// Use MSA-Passthrough behavior when authenticating. + /// Authentication result. + Task GetTokenForUserAsync(string authority, string clientId, Uri redirectUri, string[] scopes, string userName, bool msaPt = false); + + /// + /// Acquire an access token for the given service principal with the specified scopes. + /// + /// Service principal identity. + /// Scopes to request. + /// Authentication result. + Task GetTokenForServicePrincipalAsync(ServicePrincipalIdentity sp, string[] scopes); + + /// + /// Acquire a token using the managed identity in the current environment. + /// + /// Managed identity to use. + /// Resource to obtain an access token for. + /// Authentication result including access token. + /// + /// There are several formats for the parameter: + /// + /// - "system" - Use the system-assigned managed identity. + /// + /// - "{guid}" - Use the user-assigned managed identity with client ID {guid}. + /// + /// - "id://{guid}" - Use the user-assigned managed identity with client ID {guid}. + /// + /// - "resource://{guid}" - Use the user-assigned managed identity with resource ID {guid}. + /// + Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource); + } + + public class ServicePrincipalIdentity + { + /// + /// Client ID of the service principal. + /// + public string Id { get; set; } + + /// + /// Tenant ID of the service principal. + /// + public string TenantId { get; set; } + + /// + /// Certificate used to authenticate the service principal. + /// + /// + /// If both and are set, the certificate will be used. + /// + public X509Certificate2 Certificate { get; set; } + + /// + /// Secret used to authenticate the service principal. + /// + /// + /// If both and are set, the certificate will be used. + /// + public string ClientSecret { get; set; } } public interface IMicrosoftAuthenticationResult @@ -50,18 +117,16 @@ public class MicrosoftAuthentication : AuthenticationBase, IMicrosoftAuthenticat "live", "liveconnect", "liveid", }; -#if NETFRAMEWORK - private DummyWindow _dummyWindow; -#endif - public MicrosoftAuthentication(ICommandContext context) : base(context) { } #region IMicrosoftAuthentication - public async Task GetTokenAsync( + public async Task GetTokenForUserAsync( string authority, string clientId, Uri redirectUri, string[] scopes, string userName, bool msaPt) { + var uiCts = new CancellationTokenSource(); + // Check if we can and should use OS broker authentication bool useBroker = CanUseBroker(); Context.Trace.WriteLine(useBroker @@ -76,7 +141,7 @@ public async Task GetTokenAsync( try { // Create the public client application for authentication - IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri, useBroker, msaPt); + IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri, useBroker, msaPt, uiCts); AuthenticationResult result = null; @@ -193,10 +258,50 @@ public async Task GetTokenAsync( } finally { -#if NETFRAMEWORK - // If we created a dummy window during authentication we should dispose of it now that we're done - _dummyWindow?.Dispose(); -#endif + // If we created some global UI (e.g. progress) during authentication we should dismiss them now that we're done + uiCts.Cancel(); + } + } + + public async Task GetTokenForServicePrincipalAsync(ServicePrincipalIdentity sp, string[] scopes) + { + IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(sp); + + try + { + AuthenticationResult result = await app.AcquireTokenForClient(scopes).ExecuteAsync(); + return new MsalResult(result); + } + catch (Exception ex) + { + Context.Trace.WriteLine($"Failed to acquire token for service principal '{sp.TenantId}/{sp.TenantId}'."); + Context.Trace.WriteException(ex); + throw; + } + } + + public async Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource) + { + var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); + + ManagedIdentityId mid = GetManagedIdentity(managedIdentity); + + IManagedIdentityApplication app = ManagedIdentityApplicationBuilder.Create(mid) + .WithHttpClientFactory(httpFactoryAdaptor) + .Build(); + + try + { + AuthenticationResult result = await app.AcquireTokenForManagedIdentity(resource).ExecuteAsync(); + return new MsalResult(result); + } + catch (Exception ex) + { + Context.Trace.WriteLine(mid == ManagedIdentityId.SystemAssigned + ? "Failed to acquire token for system managed identity." + : $"Failed to acquire token for user managed identity '{managedIdentity:D}'."); + Context.Trace.WriteException(ex); + throw; } } @@ -341,8 +446,8 @@ private async Task GetAccessTokenSilentlyAsync( } } - private async Task CreatePublicClientApplicationAsync( - string authority, string clientId, Uri redirectUri, bool enableBroker, bool msaPt) + private async Task CreatePublicClientApplicationAsync(string authority, + string clientId, Uri redirectUri, bool enableBroker, bool msaPt, CancellationTokenSource uiCts) { var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); @@ -385,11 +490,8 @@ private async Task CreatePublicClientApplicationAsync( } else if (enableBroker) // Only actually need to set a parent window when using the Windows broker { -#if NETFRAMEWORK - Context.Trace.WriteLine($"Using dummy parent window for MSAL authentication dialogs."); - _dummyWindow = new DummyWindow(); - appBuilder.WithParentActivityOrWindow(_dummyWindow.ShowAndGetHandle); -#endif + Context.Trace.WriteLine("Using progress parent window for MSAL authentication dialogs."); + appBuilder.WithParentActivityOrWindow(() => ProgressWindow.ShowAndGetHandle(uiCts.Token)); } } } @@ -412,8 +514,39 @@ private async Task CreatePublicClientApplicationAsync( IPublicClientApplication app = appBuilder.Build(); - // Register the application token cache - await RegisterTokenCacheAsync(app, Context.Trace2); + // Register the user token cache + await RegisterTokenCacheAsync(app.UserTokenCache, CreateUserTokenCacheProps, Context.Trace2); + + return app; + } + + private async Task CreateConfidentialClientApplicationAsync(ServicePrincipalIdentity sp) + { + var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); + + Context.Trace.WriteLine($"Creating confidential client application for {sp.TenantId}/{sp.Id}..."); + var appBuilder = ConfidentialClientApplicationBuilder.Create(sp.Id) + .WithTenantId(sp.TenantId) + .WithHttpClientFactory(httpFactoryAdaptor); + + if (sp.Certificate is not null) + { + Context.Trace.WriteLineSecrets("Using certificate with thumbprint: '{0}'", new object[] { sp.Certificate.Thumbprint }); + appBuilder = appBuilder.WithCertificate(sp.Certificate); + } + else if (!string.IsNullOrWhiteSpace(sp.ClientSecret)) + { + Context.Trace.WriteLineSecrets("Using client secret: '{0}'", new object[] { sp.ClientSecret }); + appBuilder = appBuilder.WithClientSecret(sp.ClientSecret); + } + else + { + throw new InvalidOperationException("Service principal identity does not contain a certificate or client secret."); + } + + IConfidentialClientApplication app = appBuilder.Build(); + + await RegisterTokenCacheAsync(app.AppTokenCache, CreateAppTokenCacheProps, Context.Trace2); return app; } @@ -422,10 +555,11 @@ private async Task CreatePublicClientApplicationAsync( #region Helpers - private async Task RegisterTokenCacheAsync(IPublicClientApplication app, ITrace2 trace2) + private delegate StorageCreationProperties StoragePropertiesBuilder(bool useLinuxFallback); + + private async Task RegisterTokenCacheAsync(ITokenCache cache, StoragePropertiesBuilder propsBuilder, ITrace2 trace2) { - Context.Trace.WriteLine( - "Configuring Microsoft Authentication token cache to instance shared with Microsoft developer tools..."); + Context.Trace.WriteLine("Configuring MSAL token cache..."); if (!PlatformUtils.IsWindows() && !PlatformUtils.IsPosix()) { @@ -435,11 +569,11 @@ private async Task RegisterTokenCacheAsync(IPublicClientApplication app, ITrace2 } // 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. + // as other GCM processes, and other Microsoft developer tools such as the Azure PowerShell CLI. MsalCacheHelper helper = null; try { - var storageProps = CreateTokenCacheProps(useLinuxFallback: false); + StorageCreationProperties storageProps = propsBuilder(useLinuxFallback: false); helper = await MsalCacheHelper.CreateAsync(storageProps); // Test that cache access is working correctly @@ -467,24 +601,31 @@ private async Task RegisterTokenCacheAsync(IPublicClientApplication app, ITrace2 // 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(useLinuxFallback: true); + StorageCreationProperties storageProps = propsBuilder(useLinuxFallback: true); helper = await MsalCacheHelper.CreateAsync(storageProps); } } if (helper is null) { - Context.Streams.Error.WriteLine("error: failed to set up Microsoft Authentication token cache!"); - Context.Trace.WriteLine("Failed to integrate with shared token cache!"); + Context.Streams.Error.WriteLine("error: failed to set up token cache!"); + Context.Trace.WriteLine("Failed to integrate with token cache!"); } else { - helper.RegisterCache(app.UserTokenCache); - Context.Trace.WriteLine("Microsoft developer tools token cache configured."); + helper.RegisterCache(cache); + Context.Trace.WriteLine("Token cache configured."); } } - internal StorageCreationProperties CreateTokenCacheProps(bool useLinuxFallback) + /// + /// Create the properties for the user token cache. This is used by public client applications only. + /// This cache is shared between GCM processes, and also other Microsoft developer tools such as the Azure + /// PowerShell CLI. + /// + /// + /// + internal StorageCreationProperties CreateUserTokenCacheProps(bool useLinuxFallback) { const string cacheFileName = "msal.cache"; string cacheDirectory; @@ -522,6 +663,82 @@ internal StorageCreationProperties CreateTokenCacheProps(bool useLinuxFallback) return builder.Build(); } + internal static ManagedIdentityId GetManagedIdentity(string str) + { + // An empty string or "system" means system-assigned managed identity + if (string.IsNullOrWhiteSpace(str) || str.Equals("system", StringComparison.OrdinalIgnoreCase)) + { + return ManagedIdentityId.SystemAssigned; + } + + // + // A GUID-looking value means a user-assigned managed identity specified by the client ID. + // If the "{value}" is the empty GUID then we use the system-assigned MI. + // + if (Guid.TryParse(str, out Guid guid)) + { + return guid == Guid.Empty + ? ManagedIdentityId.SystemAssigned + : ManagedIdentityId.WithUserAssignedClientId(str); + } + + // + // A value of the form "id://{value}" means a user-assigned managed identity specified by the client ID. + // If the "{value}" is the empty GUID then we use the system-assigned MI. + // + // If the value is "resource://{value}" then it is a user-assigned managed identity specified + // by the resource ID. + // + if (Uri.TryCreate(str, UriKind.Absolute, out Uri uri)) + { + if (StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "id")) + { + return Guid.TryParse(uri.Host, out Guid g) && g == Guid.Empty + ? ManagedIdentityId.SystemAssigned + : ManagedIdentityId.WithUserAssignedClientId(uri.Host); + } + + if (StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "resource")) + { + return ManagedIdentityId.WithUserAssignedResourceId(uri.Host); + } + } + + throw new ArgumentException("Invalid managed identity value.", nameof(str)); + } + + /// + /// Create the properties for the application token cache. This is used by confidential client applications only + /// and is not shared between applications other than GCM. + /// + internal StorageCreationProperties CreateAppTokenCacheProps(bool useLinuxFallback) + { + const string cacheFileName = "app.cache"; + + // The confidential client MSAL cache is located at "%UserProfile%\.gcm\msal\app.cache" on Windows + // and at "~/.gcm/msal/app.cache" on UNIX. + string cacheDirectory = Path.Combine(Context.FileSystem.UserDataDirectoryPath, "msal"); + + // The keychain is used on macOS with the following service & account names + var builder = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory) + .WithMacKeyChain("GitCredentialManager.MSAL", "AppCache"); + + if (useLinuxFallback) + { + builder.WithLinuxUnprotectedFile(); + } + else + { + // The SecretService/keyring is used on Linux with the following collection name and attributes + builder.WithLinuxKeyring(cacheFileName, + "default", "AppCache", + new KeyValuePair("MsalClientID", "GitCredentialManager.MSAL"), + new KeyValuePair("GitCredentialManager.MSAL", "1.0.0.0")); + } + + return builder.Build(); + } + private static EmbeddedWebViewOptions GetEmbeddedWebViewOptions() { return new EmbeddedWebViewOptions @@ -672,75 +889,7 @@ public MsalResult(AuthenticationResult msalResult) } public string AccessToken => _msalResult.AccessToken; - public string AccountUpn => _msalResult.Account.Username; - } - -#if NETFRAMEWORK - private class DummyWindow : IDisposable - { - private readonly Thread _staThread; - private readonly ManualResetEventSlim _readyEvent; - private Form _window; - private IntPtr _handle; - - public DummyWindow() - { - _staThread = new Thread(ThreadProc); - _staThread.SetApartmentState(ApartmentState.STA); - _readyEvent = new ManualResetEventSlim(); - } - - public IntPtr ShowAndGetHandle() - { - _staThread.Start(); - _readyEvent.Wait(); - return _handle; - } - - public void Dispose() - { - _window?.Invoke(() => _window.Close()); - - if (_staThread.IsAlive) - { - _staThread.Join(); - } - } - - private void ThreadProc() - { - System.Windows.Forms.Application.EnableVisualStyles(); - _window = new Form - { - TopMost = true, - ControlBox = false, - MaximizeBox = false, - MinimizeBox = false, - ClientSize = new Size(182, 46), - FormBorderStyle = FormBorderStyle.None, - StartPosition = FormStartPosition.CenterScreen, - }; - - var progress = new ProgressBar - { - Style = ProgressBarStyle.Marquee, - Location = new Point(12, 12), - Size = new Size(158, 23), - MarqueeAnimationSpeed = 30, - }; - - _window.Controls.Add(progress); - _window.Shown += (s, e) => - { - _handle = _window.Handle; - _readyEvent.Set(); - }; - - _window.ShowDialog(); - _window.Dispose(); - _window = null; - } + public string AccountUpn => _msalResult.Account?.Username; } -#endif } } diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 03f41647a..ac609adaa 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -118,6 +118,7 @@ public static class EnvironmentVariables public const string OAuthClientAuthHeader = "GCM_OAUTH_USE_CLIENT_AUTH_HEADER"; public const string OAuthDefaultUserName = "GCM_OAUTH_DEFAULT_USERNAME"; public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS"; + public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING"; } public static class Http @@ -160,6 +161,7 @@ public static class Credential public const string UiHelper = "uiHelper"; public const string DevUseLegacyUiHelpers = "devUseLegacyUiHelpers"; public const string MsAuthUseDefaultAccount = "msauthUseDefaultAccount"; + public const string GuiSoftwareRendering = "guiSoftwareRendering"; public const string OAuthAuthenticationModes = "oauthAuthModes"; public const string OAuthClientId = "oauthClientId"; diff --git a/src/shared/Core/Core.csproj b/src/shared/Core/Core.csproj index d537e8c83..644d07e4e 100644 --- a/src/shared/Core/Core.csproj +++ b/src/shared/Core/Core.csproj @@ -14,24 +14,24 @@ - + - + - - - + + + - + diff --git a/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs b/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs index 05ed9200c..e4dba0822 100644 --- a/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs +++ b/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs @@ -20,7 +20,7 @@ protected override async Task RunInternalAsync(StringBuilder log, IList GenerateCredentialAsync(InputArguments i // 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)) + else if (GenericOAuthConfig.TryGet(Context.Trace, Context.Settings, input, out GenericOAuthConfig oauthConfig)) { Context.Trace.WriteLine($"Found generic OAuth configuration for '{uri}':"); Context.Trace.WriteLine($"\tAuthzEndpoint = {oauthConfig.Endpoints.AuthorizationEndpoint}"); diff --git a/src/shared/Core/GenericOAuthConfig.cs b/src/shared/Core/GenericOAuthConfig.cs index 0e2a74b75..522d89fec 100644 --- a/src/shared/Core/GenericOAuthConfig.cs +++ b/src/shared/Core/GenericOAuthConfig.cs @@ -1,32 +1,55 @@ using System; +using System.Collections.Generic; +using System.Linq; using GitCredentialManager.Authentication.OAuth; namespace GitCredentialManager { public class GenericOAuthConfig { - public static bool TryGet(ITrace trace, ISettings settings, Uri remoteUri, out GenericOAuthConfig config) + public static bool TryGet(ITrace trace, ISettings settings, InputArguments input, out GenericOAuthConfig config) { config = new GenericOAuthConfig(); + Uri authzEndpointUri = null; + Uri tokenEndpointUri = null; + var remoteUri = input.GetRemoteUri(); - if (!settings.TryGetSetting( + if (input.WwwAuth.Any(x => x.Contains("Basic realm=\"Gitea\"", StringComparison.OrdinalIgnoreCase))) + { + trace.WriteLine($"Using universal Gitea OAuth configuration"); + // https://docs.gitea.com/next/development/oauth2-provider?_highlight=oauth#pre-configured-applications + config.ClientId = WellKnown.GiteaClientId; + authzEndpointUri = new Uri(remoteUri, WellKnown.GiteaAuthzEndpoint); + tokenEndpointUri = new Uri(remoteUri, WellKnown.GiteaTokenEndpoint); + config.RedirectUri = WellKnown.LocalIPv4RedirectUri; + } + + if (settings.TryGetSetting( Constants.EnvironmentVariables.OAuthAuthzEndpoint, Constants.GitConfiguration.Credential.SectionName, Constants.GitConfiguration.Credential.OAuthAuthzEndpoint, - out string authzEndpoint) || - !Uri.TryCreate(remoteUri, authzEndpoint, out Uri authzEndpointUri)) + out string authzEndpoint)) + { + Uri.TryCreate(remoteUri, authzEndpoint, out authzEndpointUri); + } + + if (authzEndpointUri == null) { trace.WriteLine($"Invalid OAuth configuration - missing/invalid authorize endpoint: {authzEndpoint}"); config = null; return false; } - if (!settings.TryGetSetting( + if (settings.TryGetSetting( Constants.EnvironmentVariables.OAuthTokenEndpoint, Constants.GitConfiguration.Credential.SectionName, Constants.GitConfiguration.Credential.OAuthTokenEndpoint, - out string tokenEndpoint) || - !Uri.TryCreate(remoteUri, tokenEndpoint, out Uri tokenEndpointUri)) + out string tokenEndpoint)) + { + Uri.TryCreate(remoteUri, tokenEndpoint, out tokenEndpointUri); + } + + if (tokenEndpointUri == null) { trace.WriteLine($"Invalid OAuth configuration - missing/invalid token endpoint: {tokenEndpoint}"); config = null; @@ -74,16 +97,14 @@ public static bool TryGet(ITrace trace, ISettings settings, Uri remoteUri, out G Constants.EnvironmentVariables.OAuthRedirectUri, Constants.GitConfiguration.Credential.SectionName, Constants.GitConfiguration.Credential.OAuthRedirectUri, - out string redirectUrl) && - Uri.TryCreate(redirectUrl, UriKind.Absolute, out Uri redirectUri)) + out string redirectUrl) && Uri.TryCreate(redirectUrl, UriKind.Absolute, out Uri redirectUri)) { config.RedirectUri = redirectUri; } - else + + if (config.RedirectUri == null) { - trace.WriteLine($"Invalid OAuth configuration - missing/invalid redirect URI: {redirectUrl}"); - config = null; - return false; + config.RedirectUri = new Uri("http://127.0.0.1"); } if (settings.TryGetSetting( @@ -134,5 +155,15 @@ public static bool TryGet(ITrace trace, ISettings settings, Uri remoteUri, out G public string DefaultUserName { get; set; } public bool SupportsDeviceCode => Endpoints.DeviceAuthorizationEndpoint != null; + + public static class WellKnown + { + // https://docs.gitea.com/next/development/oauth2-provider?_highlight=oauth#pre-configured-applications + public const string GiteaClientId = "e90ee53c-94e2-48ac-9358-a874fb9e0662"; + // https://docs.gitea.com/next/development/oauth2-provider?_highlight=oauth#endpoints + public const string GiteaAuthzEndpoint = "/login/oauth/authorize"; + public const string GiteaTokenEndpoint = "/login/oauth/access_token"; + public static Uri LocalIPv4RedirectUri = new Uri("http://127.0.0.1"); + } } } diff --git a/src/shared/Core/PlatformUtils.cs b/src/shared/Core/PlatformUtils.cs index 212c13219..8872827d4 100644 --- a/src/shared/Core/PlatformUtils.cs +++ b/src/shared/Core/PlatformUtils.cs @@ -19,7 +19,7 @@ public static PlatformInformation GetPlatformInformation(ITrace2 trace2) string osType = GetOSType(); string osVersion = GetOSVersion(trace2); string cpuArch = GetCpuArchitecture(); - string clrVersion = GetClrVersion(); + string clrVersion = RuntimeInformation.FrameworkDescription; return new PlatformInformation(osType, osVersion, cpuArch, clrVersion); } @@ -53,6 +53,22 @@ public static bool IsDevBox() #endif } + /// + /// Returns true if the current process is running on an ARM processor. + /// + /// True if ARM(v6,hf) or ARM64, false otherwise + public static bool IsArm() + { + switch (RuntimeInformation.OSArchitecture) + { + case Architecture.Arm: + case Architecture.Arm64: + return true; + default: + return false; + } + } + public static bool IsWindowsBrokerSupported() { if (!IsWindows()) @@ -99,11 +115,7 @@ public static bool IsWindowsBrokerSupported() /// True if running on macOS, false otherwise. public static bool IsMacOS() { -#if NETFRAMEWORK - return Environment.OSVersion.Platform == PlatformID.MacOSX; -#else return RuntimeInformation.IsOSPlatform(OSPlatform.OSX); -#endif } /// @@ -112,11 +124,7 @@ public static bool IsMacOS() /// True if running on Windows, false otherwise. public static bool IsWindows() { -#if NETFRAMEWORK - return Environment.OSVersion.Platform == PlatformID.Win32NT; -#else return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); -#endif } /// @@ -125,11 +133,7 @@ public static bool IsWindows() /// True if running on a Linux distribution, false otherwise. public static bool IsLinux() { -#if NETFRAMEWORK - return Environment.OSVersion.Platform == PlatformID.Unix; -#else return RuntimeInformation.IsOSPlatform(OSPlatform.Linux); -#endif } /// @@ -459,9 +463,6 @@ string GetLinuxDistroVersion() private static string GetCpuArchitecture() { -#if NETFRAMEWORK - return Environment.Is64BitOperatingSystem ? "x86-64" : "x86"; -#else switch (RuntimeInformation.OSArchitecture) { case Architecture.Arm: @@ -475,16 +476,6 @@ private static string GetCpuArchitecture() default: return RuntimeInformation.OSArchitecture.ToString(); } -#endif - } - - private static string GetClrVersion() - { -#if NETFRAMEWORK - return $".NET Framework {Environment.Version}"; -#else - return RuntimeInformation.FrameworkDescription; -#endif } #endregion diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs index 1e60793c1..2aa71edf4 100644 --- a/src/shared/Core/Settings.cs +++ b/src/shared/Core/Settings.cs @@ -184,6 +184,11 @@ public interface ISettings : IDisposable /// bool UseMsAuthDefaultAccount { get; } + /// + /// True if software rendering should be used for graphical user interfaces, false otherwise. + /// + bool UseSoftwareRendering { get; } + /// /// Get TRACE2 settings. /// @@ -559,6 +564,22 @@ public bool GetTracingEnabled(out string value) => KnownGitCfg.Credential.Trace, out value) && !value.IsFalsey(); + public bool UseSoftwareRendering + { + get + { + // WORKAROUND: Some Windows ARM devices have a graphics driver issue that causes transparent windows + // when using hardware rendering. Until this is fixed, we will default to software rendering on these + // devices. Users can always override this setting back to HW-accelerated rendering if they wish. + bool defaultValue = PlatformUtils.IsWindows() && PlatformUtils.IsArm(); + + return TryGetSetting(KnownEnvars.GcmGuiSoftwareRendering, + KnownGitCfg.Credential.SectionName, + KnownGitCfg.Credential.GuiSoftwareRendering, + out string str) ? str.ToBooleanyOrDefault(defaultValue) : defaultValue; + } + } + public Trace2Settings GetTrace2Settings() { var settings = new Trace2Settings(); diff --git a/src/shared/Core/UI/AvaloniaUi.cs b/src/shared/Core/UI/AvaloniaUi.cs index f71d7dd18..34021c595 100644 --- a/src/shared/Core/UI/AvaloniaUi.cs +++ b/src/shared/Core/UI/AvaloniaUi.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; using GitCredentialManager.Interop.Windows.Native; using GitCredentialManager.UI.Controls; @@ -15,6 +14,24 @@ namespace GitCredentialManager.UI public static class AvaloniaUi { private static bool _isAppStarted; + private static bool _win32SoftwareRendering; + + /// + /// Configure the Avalonia application. + /// + /// True to enable software rendering on Windows, false otherwise. + /// + /// This must be invoked before the Avalonia application loop has started. + /// + public static void Initialize(bool win32SoftwareRendering) + { + if (_isAppStarted) + { + throw new InvalidOperationException("Setup must be called before the Avalonia application is started."); + } + + _win32SoftwareRendering = win32SoftwareRendering; + } public static Task ShowViewAsync(Func viewFunc, WindowViewModel viewModel, IntPtr parentHandle, CancellationToken ct) => ShowWindowAsync(() => new DialogWindow(viewFunc()), viewModel, parentHandle, ct); @@ -46,7 +63,18 @@ public static Task ShowWindowAsync(Func windowFunc, object dataContext, // This action only returns on our dispatcher shutdown. Dispatcher.MainThread.Post(appCancelToken => { - AppBuilder.Configure() + var appBuilder = AppBuilder.Configure(); + +#if NETFRAMEWORK + // Set custom rendering options and modes if required + if (PlatformUtils.IsWindows() && _win32SoftwareRendering) + { + appBuilder.With(new Win32PlatformOptions + { RenderingMode = new[] { Win32RenderingMode.Software } }); + } +#endif + + appBuilder #if NETFRAMEWORK .UseWin32() .UseSkia() @@ -54,14 +82,7 @@ public static Task ShowWindowAsync(Func windowFunc, object dataContext, .UsePlatformDetect() #endif .LogToTrace() - // Workaround https://github.com/AvaloniaUI/Avalonia/issues/10296 - // by always setting a application lifetime. - .SetupWithLifetime( - new ClassicDesktopStyleApplicationLifetime - { - ShutdownMode = ShutdownMode.OnExplicitShutdown - } - ); + .SetupWithoutStarting(); appInitialized.Set(); @@ -124,11 +145,11 @@ private static void SetParentExternal(Window window, IntPtr parentHandle) return; } - IntPtr ourHandle = window.PlatformImpl.Handle.Handle; + IntPtr ourHandle = window.TryGetPlatformHandle()!.Handle; // Get the desktop scaling factor from our window instance so we // can calculate rects correctly for both our window, and the parent window. - double scaling = window.PlatformImpl.DesktopScaling; + double scaling = window.RenderScaling; // Get our window rect var ourRect = new PixelRect( diff --git a/src/shared/Core/UI/Controls/ProgressWindow.axaml b/src/shared/Core/UI/Controls/ProgressWindow.axaml new file mode 100644 index 000000000..3bfc20f5c --- /dev/null +++ b/src/shared/Core/UI/Controls/ProgressWindow.axaml @@ -0,0 +1,14 @@ + + + diff --git a/src/shared/Core/UI/Controls/ProgressWindow.axaml.cs b/src/shared/Core/UI/Controls/ProgressWindow.axaml.cs new file mode 100644 index 000000000..7dc46dac6 --- /dev/null +++ b/src/shared/Core/UI/Controls/ProgressWindow.axaml.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace GitCredentialManager.UI.Controls; + +public partial class ProgressWindow : Window +{ + public ProgressWindow() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + public static IntPtr ShowAndGetHandle(CancellationToken ct) + { + var tsc = new TaskCompletionSource(); + + Window CreateWindow() + { + var window = new ProgressWindow(); + window.Loaded += (s, e) => tsc.SetResult(window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero); + return window; + } + + Task _ = AvaloniaUi.ShowWindowAsync(CreateWindow, IntPtr.Zero, ct); + + return tsc.Task.GetAwaiter().GetResult(); + } +} diff --git a/src/shared/Core/X509Utils.cs b/src/shared/Core/X509Utils.cs new file mode 100644 index 000000000..e1558d337 --- /dev/null +++ b/src/shared/Core/X509Utils.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography.X509Certificates; + +namespace GitCredentialManager; + +public static class X509Utils +{ + public static X509Certificate2 GetCertificateByThumbprint(string thumbprint) + { + foreach (var location in new[]{StoreLocation.CurrentUser, StoreLocation.LocalMachine}) + { + using var store = new X509Store(StoreName.My, location); + store.Open(OpenFlags.ReadOnly); + + X509Certificate2Collection certs = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (certs.Count > 0) + { + return certs[0]; + } + } + + return null; + } +} diff --git a/src/shared/GitHub.Tests/GitHubHostProviderTests.cs b/src/shared/GitHub.Tests/GitHubHostProviderTests.cs index aa085b34c..573ab4e08 100644 --- a/src/shared/GitHub.Tests/GitHubHostProviderTests.cs +++ b/src/shared/GitHub.Tests/GitHubHostProviderTests.cs @@ -15,8 +15,11 @@ public class GitHubHostProviderTests [InlineData("https://github.com", true)] [InlineData("https://gitHUB.CoM", true)] [InlineData("https://GITHUB.COM", true)] + [InlineData("https://gist.github.com", true)] [InlineData("https://foogithub.com", false)] [InlineData("https://api.github.com", false)] + [InlineData("https://api.gist.github.com", false)] + [InlineData("https://foogist.github.com", false)] public void GitHubHostProvider_IsGitHubDotCom(string input, bool expected) { Assert.Equal(expected, GitHubHostProvider.IsGitHubDotCom(new Uri(input))); @@ -98,6 +101,8 @@ public void GitHubHostProvider_GetCredentialServiceUrl(string protocol, string h [InlineData("https://GitHub.Com", "none", GitHubConstants.DotComAuthenticationModes)] [InlineData("https://github.com", null, GitHubConstants.DotComAuthenticationModes)] [InlineData("https://GitHub.Com", null, GitHubConstants.DotComAuthenticationModes)] + [InlineData("https://gist.github.com", null, GitHubConstants.DotComAuthenticationModes)] + [InlineData("https://GIST.GITHUB.COM", null, GitHubConstants.DotComAuthenticationModes)] public async Task GitHubHostProvider_GetSupportedAuthenticationModes(string uriString, string gitHubAuthModes, AuthenticationModes expectedModes) { var targetUri = new Uri(uriString); diff --git a/src/shared/GitHub.Tests/GitHubRestApiTests.cs b/src/shared/GitHub.Tests/GitHubRestApiTests.cs index 01f1cb0e8..c0c42e089 100644 --- a/src/shared/GitHub.Tests/GitHubRestApiTests.cs +++ b/src/shared/GitHub.Tests/GitHubRestApiTests.cs @@ -2,8 +2,6 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; using System.Threading.Tasks; using GitCredentialManager.Tests; using GitCredentialManager.Tests.Objects; @@ -13,6 +11,20 @@ namespace GitHub.Tests { public class GitHubRestApiTests { + [Theory] + [InlineData("https://github.com", "user", "https://api.github.com/user")] + [InlineData("https://github.com", "users/123", "https://api.github.com/users/123")] + [InlineData("https://gItHuB.cOm", "uSeRs/123", "https://api.github.com/uSeRs/123")] + [InlineData("https://gist.github.com", "user", "https://api.github.com/user")] + [InlineData("https://github.example.com", "user", "https://github.example.com/api/v3/user")] + [InlineData("https://raw.github.example.com", "user", "https://github.example.com/api/v3/user")] + [InlineData("https://gist.github.example.com", "user", "https://github.example.com/api/v3/user")] + public void GitHubRestApi_GetApiRequestUri(string targetUrl, string apiUrl, string expected) + { + Uri actualUri = GitHubRestApi.GetApiRequestUri(new Uri(targetUrl), apiUrl); + Assert.Equal(expected, actualUri.ToString()); + } + [Fact] public async Task GitHubRestApi_AcquireTokenAsync_NullUri_ThrowsException() { diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index 6e96e3621..918e859a0 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -487,10 +487,12 @@ public static bool IsGitHubDotCom(Uri targetUri) { EnsureArgument.AbsoluteUri(targetUri, nameof(targetUri)); - return StringComparer.OrdinalIgnoreCase.Equals(targetUri.Host, GitHubConstants.GitHubBaseUrlHost); + // github.com or gist.github.com are both considered dotcom + return StringComparer.OrdinalIgnoreCase.Equals(targetUri.Host, GitHubConstants.GitHubBaseUrlHost) || + StringComparer.OrdinalIgnoreCase.Equals(targetUri.Host, GitHubConstants.GistBaseUrlHost); } - private static Uri NormalizeUri(Uri uri) + internal static Uri NormalizeUri(Uri uri) { if (uri is null) { @@ -500,8 +502,9 @@ private static Uri NormalizeUri(Uri uri) // Special case for gist.github.com which are git backed repositories under the hood. // Credentials for these repositories are the same as the one stored with "github.com". // Same for gist.github[.subdomain].domain.tld. The general form was already checked via IsSupported. - int firstDot = uri.DnsSafeHost.IndexOf("."); - if (firstDot > -1 && uri.DnsSafeHost.Substring(0, firstDot).Equals("gist", StringComparison.OrdinalIgnoreCase)) { + int firstDot = uri.DnsSafeHost.IndexOf(".", StringComparison.Ordinal); + if (firstDot > -1 && uri.DnsSafeHost.Substring(0, firstDot).Equals("gist", StringComparison.OrdinalIgnoreCase)) + { return new Uri("https://" + uri.DnsSafeHost.Substring(firstDot+1)); } diff --git a/src/shared/GitHub/GitHubOAuth2Client.cs b/src/shared/GitHub/GitHubOAuth2Client.cs index 437b4c066..2eb4aae88 100644 --- a/src/shared/GitHub/GitHubOAuth2Client.cs +++ b/src/shared/GitHub/GitHubOAuth2Client.cs @@ -11,8 +11,11 @@ public GitHubOAuth2Client(HttpClient httpClient, ISettings settings, Uri baseUri : base(httpClient, CreateEndpoints(baseUri), GetClientId(settings), trace2, GetRedirectUri(settings, baseUri), GetClientSecret(settings)) { } - private static OAuth2ServerEndpoints CreateEndpoints(Uri baseUri) + private static OAuth2ServerEndpoints CreateEndpoints(Uri uri) { + // Ensure that the base URI is normalized to support Gist subdomains + Uri baseUri = GitHubHostProvider.NormalizeUri(uri); + Uri authEndpoint = new Uri(baseUri, GitHubConstants.OAuthAuthorizationEndpointRelativeUri); Uri tokenEndpoint = new Uri(baseUri, GitHubConstants.OAuthTokenEndpointRelativeUri); Uri deviceAuthEndpoint = new Uri(baseUri, GitHubConstants.OAuthDeviceEndpointRelativeUri); diff --git a/src/shared/GitHub/GitHubRestApi.cs b/src/shared/GitHub/GitHubRestApi.cs index 783812b8e..5051ed2bb 100644 --- a/src/shared/GitHub/GitHubRestApi.cs +++ b/src/shared/GitHub/GitHubRestApi.cs @@ -203,7 +203,7 @@ private async Task ParseSuccessResponseAsync(Uri targetUri } } - private Uri GetApiRequestUri(Uri targetUri, string apiUrl) + internal /* for testing */ static Uri GetApiRequestUri(Uri targetUri, string apiUrl) { if (GitHubHostProvider.IsGitHubDotCom(targetUri)) { @@ -214,8 +214,13 @@ private Uri GetApiRequestUri(Uri targetUri, string apiUrl) // If we're here, it's GitHub Enterprise via a configured authority var baseUrl = targetUri.GetLeftPart(UriPartial.Authority); + RegexOptions reOptions = RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; + // Check for 'raw.' in the hostname and remove it to get the correct GHE API URL - baseUrl = Regex.Replace(baseUrl, @"^(https?://)raw\.", "$1", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + baseUrl = Regex.Replace(baseUrl, @"^(https?://)raw\.", "$1", reOptions); + + // Likewise check for `gist.` in the hostname and remove it to get the correct GHE API URL + baseUrl = Regex.Replace(baseUrl, @"^(https?://)gist\.", "$1", reOptions); return new Uri(baseUrl + $"/api/v3/{apiUrl}"); } diff --git a/src/shared/GitHub/UI/Controls/SixDigitInput.axaml.cs b/src/shared/GitHub/UI/Controls/SixDigitInput.axaml.cs index 3be8497b1..524bc959e 100644 --- a/src/shared/GitHub/UI/Controls/SixDigitInput.axaml.cs +++ b/src/shared/GitHub/UI/Controls/SixDigitInput.axaml.cs @@ -5,7 +5,6 @@ using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; -using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using GitCredentialManager; @@ -22,8 +21,6 @@ public partial class SixDigitInput : UserControl, IFocusable (o, v) => o.Text = v, defaultBindingMode: BindingMode.TwoWay); - private PlatformHotkeyConfiguration _keyMap; - private IClipboard _clipboard; private bool _ignoreTextBoxUpdate; private TextBox[] _textBoxes; private string _text; @@ -37,8 +34,6 @@ private void InitializeComponent() { AvaloniaXamlLoader.Load(this); - _keyMap = AvaloniaLocator.Current.GetService(); - _clipboard = AvaloniaLocator.Current.GetService(); _textBoxes = new[] { this.FindControl("one"), @@ -89,7 +84,7 @@ public void SetFocus() { // Workaround: https://github.com/git-ecosystem/git-credential-manager/issues/1293 if (!PlatformUtils.IsMacOS()) - KeyboardDevice.Instance.SetFocusedElement(_textBoxes[0], NavigationMethod.Tab, KeyModifiers.None); + _textBoxes[0].Focus(NavigationMethod.Tab, KeyModifiers.None); } private void SetUpTextBox(TextBox textBox) @@ -99,7 +94,7 @@ private void SetUpTextBox(TextBox textBox) void OnPreviewKeyDown(object sender, KeyEventArgs e) { // Handle paste - if (_keyMap.Paste.Any(x => x.Matches(e))) + if (TopLevel.GetTopLevel(this)?.PlatformSettings?.HotkeyConfiguration.Paste.Any(x => x.Matches(e)) ?? false) { OnPaste(); e.Handled = true; @@ -166,8 +161,7 @@ void OnPreviewKeyDown(object sender, KeyEventArgs e) private void OnPaste() { - string text = _clipboard.GetTextAsync().GetAwaiter().GetResult(); - Text = text; + Text = TopLevel.GetTopLevel(this)?.Clipboard?.GetTextAsync().GetAwaiter().GetResult(); } private bool MoveNext() => MoveFocus(true); @@ -177,7 +171,7 @@ private void OnPaste() private bool MoveFocus(bool next) { // Get currently focused text box - if (FocusManager.Instance.Current is TextBox textBox) + if (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() is TextBox textBox) { int textBoxIndex = Array.IndexOf(_textBoxes, textBox); if (textBoxIndex > -1) @@ -186,7 +180,7 @@ private bool MoveFocus(bool next) ? Math.Min(_textBoxes.Length - 1, textBoxIndex + 1) : Math.Max(0, textBoxIndex - 1); - KeyboardDevice.Instance.SetFocusedElement(_textBoxes[nextIndex], NavigationMethod.Tab, KeyModifiers.None); + _textBoxes[nextIndex].Focus(NavigationMethod.Tab, KeyModifiers.None); return true; } } diff --git a/src/shared/GitHub/UI/Views/SelectAccountView.axaml b/src/shared/GitHub/UI/Views/SelectAccountView.axaml index 2e497283c..417d58387 100644 --- a/src/shared/GitHub/UI/Views/SelectAccountView.axaml +++ b/src/shared/GitHub/UI/Views/SelectAccountView.axaml @@ -43,7 +43,7 @@ - x.Contains("realm=\"GitLab\""))) + { + return true; + } + return false; } diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index d7fc916e1..e65674825 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -170,7 +170,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -219,7 +219,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -268,7 +268,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -315,7 +315,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var azDevOpsMock = new Mock(MockBehavior.Strict); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -363,7 +363,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_ var azDevOpsMock = new Mock(MockBehavior.Strict); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, account, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, account, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -413,7 +413,7 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthorit azDevOpsMock.Setup(x => x.GetAuthorityAsync(expectedOrgUri)).ReturnsAsync(authorityUrl); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -462,7 +462,7 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge .ReturnsAsync(personalAccessToken); var msAuthMock = new Mock(MockBehavior.Strict); - msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) + msAuthMock.Setup(x => x.GetTokenForUserAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null, true)) .ReturnsAsync(authResult); var authorityCacheMock = new Mock(MockBehavior.Strict); @@ -511,6 +511,102 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_ExistingPat_Retu Assert.Equal(personalAccessToken, credential.Password); } + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsManagedIdCredential() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "MANAGED-IDENTITY-TOKEN"; + const string managedIdentity = "MANAGED-IDENTITY"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.ManagedIdentity] = managedIdentity + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenForManagedIdentityAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + ICredential credential = await provider.GetCredentialAsync(input); + + Assert.NotNull(credential); + Assert.Equal(managedIdentity, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenForManagedIdentityAsync(managedIdentity, + AzureDevOpsConstants.AzureDevOpsResourceId), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "SP-TOKEN"; + const string tenantId = "78B1822F-107D-40A3-A29C-AB68D8066074"; + const string clientId = "49B4DC1A-58A8-4EEE-A81B-616A40D0BA64"; + const string servicePrincipal = $"{tenantId}/{clientId}"; + const string servicePrincipalSecret = "CLIENT-SECRET"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalId] = servicePrincipal, + [AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalSecret] = servicePrincipalSecret + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => + x.GetTokenForServicePrincipalAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + ICredential credential = await provider.GetCredentialAsync(input); + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify(x => x.GetTokenForServicePrincipalAsync( + It.Is(sp => sp.TenantId == tenantId && sp.Id == clientId), + It.Is(scopes => scopes.Length == 1 && scopes[0] == AzureDevOpsConstants.AzureDevOpsDefaultScopes[0])), + Times.Once); + } + [Fact] public async Task AzureReposHostProvider_ConfigureAsync_UseHttpPathSetTrue_DoesNothing() { diff --git a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs index 2bd239305..c46f08c33 100644 --- a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs +++ b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs @@ -8,7 +8,8 @@ internal static class AzureDevOpsConstants public const string AadAuthorityBaseUrl = "https://login.microsoftonline.com"; // Azure DevOps's app ID + default scopes - public static readonly string[] AzureDevOpsDefaultScopes = {"499b84ac-1321-427f-aa17-267ca6975798/.default"}; + public const string AzureDevOpsResourceId = "499b84ac-1321-427f-aa17-267ca6975798"; + public static readonly string[] AzureDevOpsDefaultScopes = {$"{AzureDevOpsResourceId}/.default"}; // Visual Studio's client ID // We share this to be able to consume existing access tokens from the VS caches @@ -40,6 +41,10 @@ public static class EnvironmentVariables public const string DevAadRedirectUri = "GCM_DEV_AZREPOS_REDIRECTURI"; public const string DevAadAuthorityBaseUri = "GCM_DEV_AZREPOS_AUTHORITYBASEURI"; public const string CredentialType = "GCM_AZREPOS_CREDENTIALTYPE"; + public const string ServicePrincipalId = "GCM_AZREPOS_SERVICE_PRINCIPAL"; + public const string ServicePrincipalSecret = "GCM_AZREPOS_SP_SECRET"; + public const string ServicePrincipalCertificateThumbprint = "GCM_AZREPOS_SP_CERT_THUMBPRINT"; + public const string ManagedIdentity = "GCM_AZREPOS_MANAGEDIDENTITY"; } public static class GitConfiguration @@ -51,6 +56,10 @@ public static class Credential public const string DevAadAuthorityBaseUri = "azreposDevAuthorityBaseUri"; public const string CredentialType = "azreposCredentialType"; public const string AzureAuthority = "azureAuthority"; + public const string ServicePrincipal = "azreposServicePrincipal"; + public const string ServicePrincipalSecret = "azreposServicePrincipalSecret"; + public const string ServicePrincipalCertificateThumbprint = "azreposServicePrincipalCertificateThumbprint"; + public const string ManagedIdentity = "azreposManagedIdentity"; } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index e696e504d..941b2bd53 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.Linq; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; using System.Threading.Tasks; using GitCredentialManager; @@ -75,6 +76,20 @@ public bool IsSupported(HttpResponseMessage response) public async Task GetCredentialAsync(InputArguments input) { + if (UseManagedIdentity(out string mid)) + { + _context.Trace.WriteLine($"Getting Azure Access Token for managed identity {mid}..."); + var azureResult = await _msAuth.GetTokenForManagedIdentityAsync(mid, AzureDevOpsConstants.AzureDevOpsResourceId); + return new GitCredential(mid, azureResult.AccessToken); + } + + if (UseServicePrincipal(out ServicePrincipalIdentity sp)) + { + _context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}..."); + var azureResult = await _msAuth.GetTokenForServicePrincipalAsync(sp, AzureDevOpsConstants.AzureDevOpsDefaultScopes); + return new GitCredential(sp.Id, azureResult.AccessToken); + } + if (UsePersonalAccessTokens()) { Uri remoteUri = input.GetRemoteUri(); @@ -113,7 +128,15 @@ public Task StoreCredentialAsync(InputArguments input) { Uri remoteUri = input.GetRemoteUri(); - if (UsePersonalAccessTokens()) + if (UseManagedIdentity(out _)) + { + _context.Trace.WriteLine("Nothing to store for managed identity authentication."); + } + else if (UseServicePrincipal(out _)) + { + _context.Trace.WriteLine("Nothing to store for service principal authentication."); + } + else if (UsePersonalAccessTokens()) { string service = GetServiceName(remoteUri); @@ -140,13 +163,22 @@ public Task EraseCredentialAsync(InputArguments input) { Uri remoteUri = input.GetRemoteUri(); - if (UsePersonalAccessTokens()) + if (UseManagedIdentity(out _)) + { + _context.Trace.WriteLine("Nothing to erase for managed identity authentication."); + } + else if (UseServicePrincipal(out _)) + { + _context.Trace.WriteLine("Nothing to erase for service principal authentication."); + } + else if (UsePersonalAccessTokens()) { string service = GetServiceName(remoteUri); string account = GetAccountNameForCredentialQuery(input); // Try to locate an existing credential - _context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={account}..."); + _context.Trace.WriteLine( + $"Erasing stored credential in store with service={service} account={account}..."); if (_context.CredentialStore.Remove(service, account)) { _context.Trace.WriteLine("Credential was successfully erased."); @@ -197,7 +229,7 @@ private async Task GeneratePersonalAccessTokenAsync(InputArguments // Get an AAD access token for the Azure DevOps SPS _context.Trace.WriteLine("Getting Azure AD access token..."); - IMicrosoftAuthenticationResult result = await _msAuth.GetTokenAsync( + IMicrosoftAuthenticationResult result = await _msAuth.GetTokenForUserAsync( authAuthority, GetClientId(), GetRedirectUri(), @@ -289,7 +321,7 @@ private async Task GetAzureAccessTokenAsync(Inpu // Get an AAD access token for the Azure DevOps SPS _context.Trace.WriteLine("Getting Azure AD access token..."); - IMicrosoftAuthenticationResult result = await _msAuth.GetTokenAsync( + IMicrosoftAuthenticationResult result = await _msAuth.GetTokenForUserAsync( authAuthority, GetClientId(), GetRedirectUri(), @@ -461,6 +493,89 @@ private bool UsePersonalAccessTokens() return defaultValue; } + private bool UseServicePrincipal(out ServicePrincipalIdentity sp) + { + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.ServicePrincipal, + out string spStr) || string.IsNullOrWhiteSpace(spStr)) + { + sp = null; + return false; + } + + string[] split = spStr.Split(new[] { '/' }, count: 2); + + if (split.Length < 1 || string.IsNullOrWhiteSpace(split[0])) + { + _context.Streams.Error.WriteLine("error: unable to use configured service principal - missing tenant ID in configuration"); + sp = null; + return false; + } + + if (split.Length < 2 || string.IsNullOrWhiteSpace(split[1])) + { + _context.Streams.Error.WriteLine("error: unable to use configured service principal - missing client ID in configuration"); + sp = null; + return false; + } + + string tenantId = split[0]; + string clientId = split[1]; + + sp = new ServicePrincipalIdentity + { + Id = clientId, + TenantId = tenantId, + }; + + bool hasClientSecret = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalSecret, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.ServicePrincipalSecret, + out string clientSecret); + + bool hasCertThumbprint = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.ServicePrincipalCertificateThumbprint, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.ServicePrincipalCertificateThumbprint, + out string certThumbprint); + + if (hasCertThumbprint && hasClientSecret) + { + _context.Streams.Error.WriteLine("warning: both service principal client secret and certificate thumbprint are configured - using certificate"); + } + + if (hasCertThumbprint) + { + X509Certificate2 cert = X509Utils.GetCertificateByThumbprint(certThumbprint); + if (cert is null) + { + _context.Streams.Error.WriteLine($"error: unable to find certificate with thumbprint '{certThumbprint}' for service principal"); + return false; + } + + sp.Certificate = cert; + } + else if (hasClientSecret) + { + sp.ClientSecret = clientSecret; + } + + return true; + } + + private bool UseManagedIdentity(out string mid) + { + return _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.ManagedIdentity, + KnownGitCfg.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.ManagedIdentity, + out mid) && + !string.IsNullOrWhiteSpace(mid); + } + #endregion #region IConfigurationComponent diff --git a/src/shared/TestInfrastructure/Objects/TestSettings.cs b/src/shared/TestInfrastructure/Objects/TestSettings.cs index 265d411f0..f14bf6cc9 100644 --- a/src/shared/TestInfrastructure/Objects/TestSettings.cs +++ b/src/shared/TestInfrastructure/Objects/TestSettings.cs @@ -187,6 +187,8 @@ ProxyConfiguration ISettings.GetProxyConfiguration() bool ISettings.UseMsAuthDefaultAccount => UseMsAuthDefaultAccount; + bool ISettings.UseSoftwareRendering => false; + #endregion #region IDisposable diff --git a/src/windows/Atlassian.Bitbucket.UI.Windows/Assets/Styles.xaml b/src/windows/Atlassian.Bitbucket.UI.Windows/Assets/Styles.xaml deleted file mode 100644 index 67fe8a512..000000000 --- a/src/windows/Atlassian.Bitbucket.UI.Windows/Assets/Styles.xaml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/src/windows/Atlassian.Bitbucket.UI.Windows/Assets/atlassian-logo.png b/src/windows/Atlassian.Bitbucket.UI.Windows/Assets/atlassian-logo.png deleted file mode 100644 index 6226f936d..000000000 Binary files a/src/windows/Atlassian.Bitbucket.UI.Windows/Assets/atlassian-logo.png and /dev/null differ diff --git a/src/windows/Atlassian.Bitbucket.UI.Windows/Atlassian.Bitbucket.UI.Windows.csproj b/src/windows/Atlassian.Bitbucket.UI.Windows/Atlassian.Bitbucket.UI.Windows.csproj deleted file mode 100644 index f74d8c2b8..000000000 --- a/src/windows/Atlassian.Bitbucket.UI.Windows/Atlassian.Bitbucket.UI.Windows.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - Exe - net472 - true - Atlassian.Bitbucket.UI.Windows - Atlassian.Bitbucket.UI - Atlassian.Bitbucket.UI.Windows.Program - - - - - - - - - - - - - - - - diff --git a/src/windows/Atlassian.Bitbucket.UI.Windows/Commands/CredentialsCommandImpl.cs b/src/windows/Atlassian.Bitbucket.UI.Windows/Commands/CredentialsCommandImpl.cs deleted file mode 100644 index ecfc6b891..000000000 --- a/src/windows/Atlassian.Bitbucket.UI.Windows/Commands/CredentialsCommandImpl.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Atlassian.Bitbucket.UI.Commands; -using Atlassian.Bitbucket.UI.ViewModels; -using Atlassian.Bitbucket.UI.Windows.Views; -using GitCredentialManager; -using GitCredentialManager.UI.Windows; - -namespace Atlassian.Bitbucket.UI.Windows.Commands -{ - public class CredentialsCommandImpl : CredentialsCommand - { - public CredentialsCommandImpl(ICommandContext context) : base(context) { } - - protected override Task ShowAsync(CredentialsViewModel viewModel, CancellationToken ct) - { - return Gui.ShowDialogWindow(viewModel, () => new CredentialsView(), GetParentHandle()); - } - } -} diff --git a/src/windows/Atlassian.Bitbucket.UI.Windows/Controls/TesterWindow.xaml b/src/windows/Atlassian.Bitbucket.UI.Windows/Controls/TesterWindow.xaml deleted file mode 100644 index f221e6f13..000000000 --- a/src/windows/Atlassian.Bitbucket.UI.Windows/Controls/TesterWindow.xaml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/windows/Core.UI.Windows/Controls/WpfDialogWindow.xaml.cs b/src/windows/Core.UI.Windows/Controls/WpfDialogWindow.xaml.cs deleted file mode 100644 index d6fc69f1d..000000000 --- a/src/windows/Core.UI.Windows/Controls/WpfDialogWindow.xaml.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Threading; -using GitCredentialManager.UI.Controls; -using GitCredentialManager.UI.ViewModels; - -namespace GitCredentialManager.UI.Windows.Controls -{ - public partial class WpfDialogWindow : Window - { - private readonly UserControl _view; - - public WpfDialogWindow(UserControl view) - { - InitializeComponent(); - - DataContextChanged += OnDataContextChanged; - - _view = view; - ContentHolder.Content = _view; - } - - private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) - { - if (DataContext is WindowViewModel vm) - { - vm.Accepted += (s, _) => - { - DialogResult = true; - Close(); - }; - - vm.Canceled += (s, _) => - { - DialogResult = false; - Close(); - }; - } - - if (_view is IFocusable focusable) - { - // Send a focus request to the child view on idle - System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, (Action)(() => focusable.SetFocus())); - } - } - - private void CloseButton_Click(object sender, RoutedEventArgs e) - { - if (DataContext is WindowViewModel vm) - { - vm.Cancel(); - } - } - - private void Border_MouseDown(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton == MouseButton.Left) - { - DragMove(); - } - } - } -} diff --git a/src/windows/Core.UI.Windows/Converters/BooleanConverters.cs b/src/windows/Core.UI.Windows/Converters/BooleanConverters.cs deleted file mode 100644 index be195a751..000000000 --- a/src/windows/Core.UI.Windows/Converters/BooleanConverters.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using System.Windows; -using System.Windows.Data; - -namespace GitCredentialManager.UI.Windows.Converters -{ - [ValueConversion(typeof(bool), typeof(bool))] - public class BooleanNotConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return !(bool)value; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - return Binding.DoNothing; - } - } - - [ValueConversion(typeof(bool), typeof(bool))] - public class BooleanOrConverter : IMultiValueConverter - { - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - return values.Cast().Aggregate(false, (x, y) => x || y); - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - { - return new[] { Binding.DoNothing }; - } - } - - [ValueConversion(typeof(bool), typeof(Visibility))] - public class BooleanToVisibilityConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return ConverterHelper.GetConditionalVisibility((bool)value, parameter); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - return Binding.DoNothing; - } - } - - [ValueConversion(typeof(bool), typeof(Visibility))] - public class BooleanOrToVisibilityConverter : IMultiValueConverter - { - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - bool show = values.Cast().Aggregate(false, (x, y) => x || y); - return ConverterHelper.GetConditionalVisibility(show, parameter); - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - { - return new[] { Binding.DoNothing }; - } - } - - [ValueConversion(typeof(bool), typeof(Visibility))] - public class BooleanAndToVisibilityConverter : IMultiValueConverter - { - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - bool show = values.Cast().Aggregate(true, (x, y) => x && y); - return ConverterHelper.GetConditionalVisibility(show, parameter); - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - { - return new[] { Binding.DoNothing }; - } - } -} diff --git a/src/windows/Core.UI.Windows/Converters/ConverterUtils.cs b/src/windows/Core.UI.Windows/Converters/ConverterUtils.cs deleted file mode 100644 index bf91e1e78..000000000 --- a/src/windows/Core.UI.Windows/Converters/ConverterUtils.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using System.Windows; - -namespace GitCredentialManager.UI.Windows.Converters -{ - public class ConverterHelper - { - private static char[] s_commaSeparator = new char[] { ',' }; - - /// - /// Returns true if parameter contains the specified option text. - /// - /// comma-separated options - /// option to search - /// true if parameter contains option, false otherwise - private static bool ParameterContains(object parameter, String option) - { - string arg = parameter as string; - if (!string.IsNullOrEmpty(arg)) - { - string[] optionArgs = arg.Split(s_commaSeparator, StringSplitOptions.RemoveEmptyEntries); - foreach (string optionArg in optionArgs) - { - if (optionArg.Equals(option, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - return false; - } - - /// - /// Returns true if parameter contains "Not", "!", or "Invert". - /// - /// comma-separated options - /// true if parameter has the invert option - public static bool GetInvert(object parameter) - { - string arg = parameter as String; - if (!string.IsNullOrEmpty(arg)) - { - string[] options = arg.Split(s_commaSeparator, StringSplitOptions.RemoveEmptyEntries); - foreach (string option in options) - { - if (option.Equals("Not", StringComparison.OrdinalIgnoreCase) || - option.Equals("!", StringComparison.OrdinalIgnoreCase) || - option.Equals("Invert", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - return false; - } - - /// - /// Returns the appropriate Visibility based on the show condition - /// and the preferred Visibility.Collaped or Visibility.Hidden option - /// in parameter. - /// - /// true to get Visibility.Visible, - /// false for Visibility.Collapsed or Visibility.Hidden depending on parameter. - /// comma-separated options. "Not", "!", or "Invert" to invert - /// the bool evaluation before converting to Visibility. "Hidden" to get - /// Visibility.Hidden (where the default is Visibility.Collapsed). - /// Visibility.Collapsed or Visibility.Hidden - public static Visibility GetConditionalVisibility(bool show, object parameter) - { - return GetConditionalVisibility(show, parameter, false); - } - - /// - /// Returns the appropriate Visibility based on the show condition - /// and the preferred Visibility.Collaped or Visibility.Hidden option - /// in parameter. - /// - /// true to get Visibility.Visible, - /// false for Visibility.Collapsed or Visibility.Hidden depending on parameter. - /// comma-separated options. "Not", "!", or "Invert" to invert - /// the bool evaluation before converting to Visibility. "Hidden" to get - /// Visibility.Hidden (where the default is Visibility.Collapsed). - /// true to ignore the Invert option in parameter. - /// This is used to avoid double-inverting. - /// Visibility.Collapsed or Visibility.Hidden - public static Visibility GetConditionalVisibility(bool show, object parameter, bool ignoreInvert) - { - bool result = show; - if (!ignoreInvert && GetInvert(parameter)) - { - result = !show; - } - return result ? Visibility.Visible : GetCollapsedOrHidden(parameter); - } - - /// - /// Returns Visibility.Hidden if parameter contains "Hidden". Default is - /// Visibility.Collapsed. - /// - /// comma-separated options. "Hidden" to get - /// Visibility.Hidden. Default is Visibility.Collapsed. - /// Visibility.Collapsed or Visibility.Hidden - internal static Visibility GetCollapsedOrHidden(object parameter) - { - return ParameterContains(parameter, "Hidden") ? Visibility.Hidden : Visibility.Collapsed; - } - - /// - /// Returns Visibility.Hidden if parameter[argIndex] contains "Hidden". Default is - /// Visibility.Collapsed. - /// - /// parameter[argIndex] as comma-separated options. - /// "Hidden" to get Visibility.Hidden. Default is Visibility.Collapsed. - /// index to the actual argument to use in the parameter String[] - /// Visibility - public static Visibility GetCollapsedOrHiddenFromArray(object parameter, int argIndex) - { - object[] args = parameter as object[]; - if (args != null && args.Length >= argIndex + 1) - { - return GetCollapsedOrHidden(args[argIndex] as string); - } - return Visibility.Collapsed; - } - } -} diff --git a/src/windows/Core.UI.Windows/Converters/NonEmptyStringToVisibleConverter.cs b/src/windows/Core.UI.Windows/Converters/NonEmptyStringToVisibleConverter.cs deleted file mode 100644 index 6fda85bd2..000000000 --- a/src/windows/Core.UI.Windows/Converters/NonEmptyStringToVisibleConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace GitCredentialManager.UI.Windows.Converters -{ - [ValueConversion(typeof(string), typeof(Visibility))] - public class NonEmptyStringToVisibleConverter : IValueConverter - { - public virtual object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return ConverterHelper.GetConditionalVisibility(!string.IsNullOrEmpty(value as string), parameter); - } - - public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - return Binding.DoNothing; - } - } - - [ValueConversion(typeof(string), typeof(Visibility))] - public class NonNullToVisibleConverter : IValueConverter - { - public virtual object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return ConverterHelper.GetConditionalVisibility(value != null, parameter); - } - - public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - return Binding.DoNothing; - } - } -} diff --git a/src/windows/Core.UI.Windows/Core.UI.Windows.csproj b/src/windows/Core.UI.Windows/Core.UI.Windows.csproj deleted file mode 100644 index ba14edca4..000000000 --- a/src/windows/Core.UI.Windows/Core.UI.Windows.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net472 - true - GitCredentialManager.UI.Windows - gcmcoreuiwpf - - - - - - - - - - - diff --git a/src/windows/Core.UI.Windows/Gui.cs b/src/windows/Core.UI.Windows/Gui.cs deleted file mode 100644 index 81508bf9f..000000000 --- a/src/windows/Core.UI.Windows/Gui.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using GitCredentialManager.UI.ViewModels; -using GitCredentialManager.UI.Windows.Controls; - -namespace GitCredentialManager.UI.Windows -{ - public static class Gui - { - /// - /// Present the user with a . - /// - /// factory. - /// Parent window handle. - /// - /// Returns `` if the user completed the dialog; otherwise `` - /// if the user canceled or abandoned the dialog. - /// - public static Task ShowWindow(Func windowCreator, IntPtr parentHwnd) - { - return StartSTATask(() => ShowDialog(windowCreator(), parentHwnd)); - } - - /// - /// Present the user with a . - /// - /// - /// Returns `` if the user completed the dialog and the view model is valid; - /// otherwise `` if the user canceled or abandoned the dialog, or the view - /// model is invalid. - /// - /// Window view model. - /// Window content factory. - /// Parent window handle. - public static Task ShowDialogWindow(WindowViewModel viewModel, Func contentCreator, IntPtr parentHwnd) - { - return ShowWindow(() => new WpfDialogWindow(contentCreator()) { DataContext = viewModel }, parentHwnd); - } - - private static Task StartSTATask(Action action) - { - var completionSource = new TaskCompletionSource(); - var thread = new Thread(() => - { - try - { - action(); - completionSource.SetResult(null); - } - catch (Exception e) - { - completionSource.SetException(e); - } - }); - - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - - return completionSource.Task; - } - - public static bool? ShowDialog(Window window, IntPtr parentHwnd) - { - // Zero is not a valid window handle - if (parentHwnd == IntPtr.Zero) - { - return window.ShowDialog(); - } - - // Set the parent window handle and ensure the dialog starts in the correct location - new System.Windows.Interop.WindowInteropHelper(window).Owner = parentHwnd; - window.WindowStartupLocation = WindowStartupLocation.CenterOwner; - - const int ERROR_INVALID_WINDOW_HANDLE = 1400; - - try - { - return window.ShowDialog(); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == ERROR_INVALID_WINDOW_HANDLE) - { - // The window handle given was invalid - clear the owner and show the dialog centered on the screen - window.Owner = null; - window.WindowStartupLocation = WindowStartupLocation.CenterScreen; - return window.ShowDialog(); - } - } - } -} diff --git a/src/windows/Git-Credential-Manager.UI.Windows/Assets/Styles.xaml b/src/windows/Git-Credential-Manager.UI.Windows/Assets/Styles.xaml deleted file mode 100644 index 67fe8a512..000000000 --- a/src/windows/Git-Credential-Manager.UI.Windows/Assets/Styles.xaml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/src/windows/Git-Credential-Manager.UI.Windows/Commands/CredentialsCommandImpl.cs b/src/windows/Git-Credential-Manager.UI.Windows/Commands/CredentialsCommandImpl.cs deleted file mode 100644 index ca49de16b..000000000 --- a/src/windows/Git-Credential-Manager.UI.Windows/Commands/CredentialsCommandImpl.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using GitCredentialManager.UI.Commands; -using GitCredentialManager.UI.ViewModels; -using GitCredentialManager.UI.Windows.Views; - -namespace GitCredentialManager.UI.Windows.Commands -{ - public class CredentialsCommandImpl : CredentialsCommand - { - public CredentialsCommandImpl(ICommandContext context) : base(context) { } - - protected override Task ShowAsync(CredentialsViewModel viewModel, CancellationToken ct) - { - return Gui.ShowDialogWindow(viewModel, () => new CredentialsView(), GetParentHandle()); - } - } -} diff --git a/src/windows/Git-Credential-Manager.UI.Windows/Commands/DefaultAccountCommandImpl.cs b/src/windows/Git-Credential-Manager.UI.Windows/Commands/DefaultAccountCommandImpl.cs deleted file mode 100644 index fc03e420f..000000000 --- a/src/windows/Git-Credential-Manager.UI.Windows/Commands/DefaultAccountCommandImpl.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using GitCredentialManager.UI.Commands; -using GitCredentialManager.UI.ViewModels; -using GitCredentialManager.UI.Windows.Views; - -namespace GitCredentialManager.UI.Windows.Commands -{ - public class DefaultAccountCommandImpl : DefaultAccountCommand - { - public DefaultAccountCommandImpl(ICommandContext context) : base(context) { } - - protected override Task ShowAsync(DefaultAccountViewModel viewModel, CancellationToken ct) - { - return Gui.ShowDialogWindow(viewModel, () => new DefaultAccountView(), GetParentHandle()); - } - } -} diff --git a/src/windows/Git-Credential-Manager.UI.Windows/Commands/DeviceCodeCommandImpl.cs b/src/windows/Git-Credential-Manager.UI.Windows/Commands/DeviceCodeCommandImpl.cs deleted file mode 100644 index 729614f7c..000000000 --- a/src/windows/Git-Credential-Manager.UI.Windows/Commands/DeviceCodeCommandImpl.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using GitCredentialManager.UI.Commands; -using GitCredentialManager.UI.ViewModels; -using GitCredentialManager.UI.Windows.Views; - -namespace GitCredentialManager.UI.Windows.Commands -{ - public class DeviceCodeCommandImpl : DeviceCodeCommand - { - public DeviceCodeCommandImpl(ICommandContext context) : base(context) { } - - protected override Task ShowAsync(DeviceCodeViewModel viewModel, CancellationToken ct) - { - return Gui.ShowDialogWindow(viewModel, () => new DeviceCodeView(), GetParentHandle()); - } - } -} diff --git a/src/windows/Git-Credential-Manager.UI.Windows/Commands/OAuthCommandImpl.cs b/src/windows/Git-Credential-Manager.UI.Windows/Commands/OAuthCommandImpl.cs deleted file mode 100644 index ab396a02d..000000000 --- a/src/windows/Git-Credential-Manager.UI.Windows/Commands/OAuthCommandImpl.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using GitCredentialManager.UI.Commands; -using GitCredentialManager.UI.ViewModels; -using GitCredentialManager.UI.Windows.Views; - -namespace GitCredentialManager.UI.Windows.Commands -{ - public class OAuthCommandImpl : OAuthCommand - { - public OAuthCommandImpl(ICommandContext context) : base(context) { } - - protected override Task ShowAsync(OAuthViewModel viewModel, CancellationToken ct) - { - return Gui.ShowDialogWindow(viewModel, () => new OAuthView(), GetParentHandle()); - } - } -} diff --git a/src/windows/Git-Credential-Manager.UI.Windows/Controls/TesterWindow.xaml b/src/windows/Git-Credential-Manager.UI.Windows/Controls/TesterWindow.xaml deleted file mode 100644 index d2ab5dd1e..000000000 --- a/src/windows/Git-Credential-Manager.UI.Windows/Controls/TesterWindow.xaml +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - - - - - - - - -