diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 797a42d73..f020c676f 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -35,18 +35,21 @@ parameters: jobName: 'Windows (x86)' runtime: win-x86 pool: GitClientPME-1ESHostedPool-intel-pc + poolArch: amd64 image: win-x86_64-ado1es os: windows - id: windows_x64 jobName: 'Windows (x64)' runtime: win-x64 pool: GitClientPME-1ESHostedPool-intel-pc + poolArch: amd64 image: win-x86_64-ado1es os: windows - id: windows_arm64 jobName: 'Windows (ARM64)' runtime: win-arm64 pool: GitClientPME-1ESHostedPool-arm64-pc + poolArch: arm64 image: win-arm64-ado1es os: windows @@ -73,12 +76,14 @@ parameters: jobName: 'Linux (x64)' runtime: linux-x64 pool: GitClientPME-1ESHostedPool-intel-pc + poolArch: amd64 image: ubuntu-x86_64-ado1es os: linux - id: linux_arm64 jobName: 'Linux (ARM64)' runtime: linux-arm64 pool: GitClientPME-1ESHostedPool-arm64-pc + poolArch: arm64 image: ubuntu-arm64-ado1es os: linux @@ -121,6 +126,7 @@ extends: name: ${{ dim.pool }} image: ${{ dim.image }} os: ${{ dim.os }} + hostArchitecture: ${{ dim.poolArch }} templateContext: outputs: - output: pipelineArtifact @@ -136,10 +142,10 @@ extends: $version = (Get-Content .\VERSION) -replace '\.\d+$', '' Write-Host "##vso[task.setvariable variable=version;isReadOnly=true]$version" - task: UseDotNet@2 - displayName: 'Use .NET 8 SDK' + displayName: 'Use .NET 10 SDK' inputs: packageType: sdk - version: '8.x' + version: '10.x' - task: PowerShell@2 displayName: 'Build payload' inputs: @@ -296,10 +302,10 @@ extends: script: | echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" - task: UseDotNet@2 - displayName: 'Use .NET 8 SDK' + displayName: 'Use .NET 10 SDK' inputs: packageType: sdk - version: '8.x' + version: '10.x' - task: Bash@3 displayName: 'Build payload' inputs: @@ -551,6 +557,7 @@ extends: name: ${{ dim.pool }} image: ${{ dim.image }} os: ${{ dim.os }} + hostArchitecture: ${{ dim.poolArch }} templateContext: outputs: - output: pipelineArtifact @@ -565,10 +572,10 @@ extends: script: | echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" - task: UseDotNet@2 - displayName: 'Use .NET 8 SDK' + displayName: 'Use .NET 10 SDK' inputs: packageType: sdk - version: '8.x' + version: '10.x' - task: Bash@3 displayName: 'Build payload' inputs: @@ -664,10 +671,10 @@ extends: $version = (Get-Content .\VERSION) -replace '\.\d+$', '' Write-Host "##vso[task.setvariable variable=version;isReadOnly=true]$version" - task: UseDotNet@2 - displayName: 'Use .NET 8 SDK' + displayName: 'Use .NET 10 SDK' inputs: packageType: sdk - version: '8.x' + version: '10.x' - task: NuGetToolInstaller@1 displayName: 'Install NuGet CLI' inputs: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b89613684..943e7c465 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,9 +25,9 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.2.0 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 94f275915..1d4488405 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -28,9 +28,18 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.2.0 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x + + # The x86 test host requires an x86 .NET runtime, which isn't pre-installed + # on the runner, nor can the actions/setup-dotnet action install it. + # Install it manually so tests can run. + - name: Setup .NET (x86) + if: matrix.runtime == 'win-x86' + run: | + Invoke-WebRequest 'https://dot.net/v1/dotnet-install.ps1' -OutFile dotnet-install.ps1 + ./dotnet-install.ps1 -Channel 10.0 -Architecture x86 -InstallDir 'C:\Program Files (x86)\dotnet' - name: Install dependencies run: dotnet restore @@ -56,7 +65,7 @@ jobs: cp out/windows/Installer.Windows/bin/Release/net472/${{ matrix.runtime }}.sym/* artifacts/bin/${{ matrix.runtime }}/ - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.runtime }} path: | @@ -76,9 +85,9 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.2.0 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Install dependencies run: dotnet restore @@ -100,7 +109,7 @@ jobs: mv out/linux/Packaging.Linux/Release/tar/*.tar.gz artifacts/ - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.runtime }} path: | @@ -120,9 +129,9 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.2.0 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Install dependencies run: dotnet restore @@ -145,7 +154,7 @@ jobs: mv out/osx/Installer.Mac/pkg/Release/gcm*.pkg artifacts/ - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.runtime }} path: | diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 9fecfd863..bfbd2bbfa 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: DavidAnson/markdownlint-cli2-action@07035fd053f7be764496c0f8d8f9f41f98305101 + - uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 with: globs: | "**/*.md" @@ -35,7 +35,7 @@ jobs: - name: Run link checker # For any troubleshooting, see: # https://github.com/lycheeverse/lychee/blob/master/docs/TROUBLESHOOTING.md - uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 + uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 with: # user-agent: if a user agent is not specified, some websites (e.g. # GitHub Docs) return HTTP errors which Lychee will interpret as diff --git a/.github/workflows/maintainer-absence.yml b/.github/workflows/maintainer-absence.yml index 20e6694e7..3de79e6a1 100644 --- a/.github/workflows/maintainer-absence.yml +++ b/.github/workflows/maintainer-absence.yml @@ -18,7 +18,7 @@ jobs: name: create-issue runs-on: ubuntu-latest steps: - - uses: actions/github-script@v8 + - uses: actions/github-script@v9 with: script: | const startDate = new Date('${{ github.event.inputs.startDate }}'); diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index d1aea471a..85c821eea 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -5,6 +5,9 @@ on: push: branches: - main + pull_request: + branches: + - main jobs: docker: @@ -15,7 +18,7 @@ jobs: matrix: vector: - image: ubuntu - - image: debian:bullseye + - image: debian:bookworm - image: fedora # Centos no longer officially maintains images on Docker Hub. However, # tgagor is a contributor who pushes updated images weekly, which should @@ -29,6 +32,7 @@ jobs: - image: registry.suse.com/suse/sle15:15.4.27.11.31 - image: archlinux - image: mcr.microsoft.com/cbl-mariner/base/core:2.0 + - image: mcr.microsoft.com/azurelinux/base/core:3.0 container: ${{matrix.vector.image}} steps: - run: | @@ -36,7 +40,7 @@ jobs: zypper -n install tar gzip elif [[ ${{matrix.vector.image}} == *"centos"* ]]; then dnf install which -y - elif [[ ${{matrix.vector.image}} == *"mariner"* ]]; then + elif [[ ${{matrix.vector.image}} == *"mariner"* || ${{matrix.vector.image}} == *"azurelinux"* ]]; then GNUPGHOME=/root/.gnupg tdnf update -y && GNUPGHOME=/root/.gnupg tdnf install tar -y # needed for `actions/checkout` fi diff --git a/.vscode/launch.json b/.vscode/launch.json index d9b004b86..152dd35b3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/out/shared/Git-Credential-Manager/bin/Debug/net8.0/git-credential-manager.dll", + "program": "${workspaceFolder}/out/shared/Git-Credential-Manager/bin/Debug/net10.0/git-credential-manager.dll", "args": ["get"], "cwd": "${workspaceFolder}/out/shared/Git-Credential-Manager", "console": "integratedTerminal", @@ -22,7 +22,7 @@ "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/out/shared/Git-Credential-Manager/bin/Debug/net8.0/git-credential-manager.dll", + "program": "${workspaceFolder}/out/shared/Git-Credential-Manager/bin/Debug/net10.0/git-credential-manager.dll", "args": ["store"], "cwd": "${workspaceFolder}/out/shared/Git-Credential-Manager", "console": "integratedTerminal", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c559b17b8..8dd1d3737 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -56,7 +56,7 @@ "type": "shell", "group": "test", "args": [ - "~/.nuget/packages/reportgenerator/*/*/net8.0/ReportGenerator.dll", + "~/.nuget/packages/reportgenerator/*/*/net10.0/ReportGenerator.dll", "-reports:${workspaceFolder}/**/TestResults/**/coverage.cobertura.xml", "-targetdir:${workspaceFolder}/out/code-coverage" ], @@ -71,7 +71,7 @@ "type": "shell", "group": "test", "args": [ - "${env:USERROFILE}/.nuget/packages/reportgenerator/*/*/net8.0/ReportGenerator.dll", + "${env:USERROFILE}/.nuget/packages/reportgenerator/*/*/net10.0/ReportGenerator.dll", "-reports:${workspaceFolder}/**/TestResults/**/coverage.cobertura.xml", "-targetdir:${workspaceFolder}/out/code-coverage" ], diff --git a/Directory.Build.props b/Directory.Build.props index e7ed76eb9..58e8f8770 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -27,9 +27,7 @@ - - 8.0.5 - + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 000000000..d1e002d85 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,41 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VERSION b/VERSION index 364608644..0ab902011 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.3.0 +2.8.0.0 diff --git a/build/GCM.MSBuild.csproj b/build/GCM.MSBuild.csproj index 3df4909e6..bd25f8345 100644 --- a/build/GCM.MSBuild.csproj +++ b/build/GCM.MSBuild.csproj @@ -1,13 +1,13 @@ - net8.0 + net10.0 false - - + + diff --git a/docs/azrepos-wif.md b/docs/azrepos-wif.md new file mode 100644 index 000000000..0e8f0147e --- /dev/null +++ b/docs/azrepos-wif.md @@ -0,0 +1,185 @@ +# Azure Workload Identity Federation + +Git Credential Manager supports [Workload Identity Federation][wif] for +authentication with Azure Repos. This document provides an overview of Workload +Identity Federation and how to use it with GCM. + +## Overview + +Workload Identity Federation allows a workload (such as a CI/CD pipeline, VM, or +container) to exchange a token from an external identity provider for a Microsoft +Entra ID access token — without needing to manage secrets like client secrets or +certificates. + +This is especially useful in scenarios where: + +- You want to avoid storing long-lived secrets. +- Your workload already has an identity token from another provider (e.g., GitHub + Actions OIDC, a Managed Identity, or a custom identity provider). +- You want to follow the principle of least privilege with short-lived, + automatically rotated credentials. + +You can read more about Workload Identity Federation in the +[Microsoft Entra documentation][wif]. + +## How it works + +When configured, GCM obtains a client assertion (a token from the external +identity provider) and exchanges it with Microsoft Entra ID for an access token +scoped to Azure DevOps. The exact mechanism for obtaining the client assertion +depends on the federation scenario you choose. + +## Scenarios + +GCM supports three federation scenarios: + +### Generic + +Use this scenario when you have a pre-obtained client assertion token from any +external identity provider. You provide the assertion directly and GCM exchanges +it for an access token. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] +Assertion|[`credential.azreposWorkloadFederationAssertion`][gcm-wif-assertion-config]|[`GCM_AZREPOS_WIF_ASSERTION`][gcm-wif-assertion-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation generic +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +git config --global credential.azreposWorkloadFederationAssertion "eyJhbGci..." +``` + +### Managed Identity + +Use this scenario when your workload runs on an Azure resource that has a +[Managed Identity][az-mi] assigned. GCM will first request a token from the +Managed Identity for the configured audience, then exchange that token for an +Azure DevOps access token. + +This is useful for Azure VMs, App Services, or other Azure resources that have a +Managed Identity but need to authenticate as a specific app registration with +a federated credential trust. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] +Managed Identity|[`credential.azreposWorkloadFederationManagedIdentity`][gcm-wif-mi-config]|[`GCM_AZREPOS_WIF_MANAGEDIDENTITY`][gcm-wif-mi-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +The Managed Identity value accepts the same formats as +[`credential.azreposManagedIdentity`][gcm-mi-config]: + +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 + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation managedidentity +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +git config --global credential.azreposWorkloadFederationManagedIdentity system +``` + +### GitHub Actions + +Use this scenario when your workload runs in a GitHub Actions workflow. GCM will +automatically obtain an OIDC token from the GitHub Actions runtime and exchange +it for an Azure DevOps access token. + +This scenario uses the `ACTIONS_ID_TOKEN_REQUEST_URL` and +`ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variables that GitHub Actions +automatically provides when a workflow has the `id-token: write` permission. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +No additional GCM settings are required — the GitHub Actions OIDC environment +variables are read automatically. + +#### Prerequisites + +1. An app registration in Microsoft Entra ID with a federated credential + configured to trust your GitHub repository. +2. The app registration must have the necessary permissions to access Azure + DevOps. +3. Your GitHub Actions workflow must have the `id-token: write` permission. + +#### Example workflow + +```yaml +permissions: + id-token: write + contents: read + +steps: + - uses: actions/checkout@v4 + env: + GCM_AZREPOS_WIF: githubactions + GCM_AZREPOS_WIF_CLIENTID: "11111111-1111-1111-1111-111111111111" + GCM_AZREPOS_WIF_TENANTID: "22222222-2222-2222-2222-222222222222" +``` + +## Audience + +All scenarios accept an optional audience setting that controls the audience +claim in the federated token request. The default value is +`api://AzureADTokenExchange`, which is the standard audience for Microsoft Entra +ID workload identity federation. + +You only need to change this if your federated credential trust is configured +with a custom audience. + +[az-mi]: https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation +[gcm-mi-config]: https://gh.io/gcm/config#credentialazreposmanagedidentity +[gcm-wif-config]: https://gh.io/gcm/config#credentialazreposworkloadfederation +[gcm-wif-clientid-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationclientid +[gcm-wif-tenantid-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationtenantid +[gcm-wif-audience-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationaudience +[gcm-wif-assertion-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationassertion +[gcm-wif-mi-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationmanagedidentity +[gcm-wif-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF +[gcm-wif-clientid-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_CLIENTID +[gcm-wif-tenantid-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_TENANTID +[gcm-wif-audience-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_AUDIENCE +[gcm-wif-assertion-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_ASSERTION +[gcm-wif-mi-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_MANAGEDIDENTITY diff --git a/docs/configuration.md b/docs/configuration.md index ba978ef30..af5d410f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -884,6 +884,138 @@ git config --global credential.azreposManagedIdentity "id://11111111-1111-1111-1 --- +### credential.azreposWorkloadFederation + +Use [Workload Identity Federation][wif] to authenticate with Azure Repos. + +The value specifies the federation scenario to use for obtaining a client +assertion to exchange for an access token. + +You must also set the following companion settings: + +- [credential.azreposWorkloadFederationClientId][credential-azrepos-wif-clientid] +- [credential.azreposWorkloadFederationTenantId][credential-azrepos-wif-tenantid] + +Depending on the scenario, additional settings may be required. + +Value|Description +-|- +`generic`|Use a user-supplied client assertion ([credential.azreposWorkloadFederationAssertion][credential-azrepos-wif-assertion]) +`managedidentity`|Use a [Managed Identity][managed-identity] to obtain the federated token ([credential.azreposWorkloadFederationManagedIdentity][credential-azrepos-wif-managedidentity]) +`githubactions`|Automatically obtain an OIDC token from GitHub Actions + +For more information about workload identity federation, see the +[conceptual documentation][azrepos-wif-doc] and the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation githubactions +``` + +**Also see: [GCM_AZREPOS_WIF][gcm-azrepos-wif]** + +--- + +### credential.azreposWorkloadFederationClientId + +The client ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +``` + +**Also see: [GCM_AZREPOS_WIF_CLIENTID][gcm-azrepos-wif-clientid]** + +--- + +### credential.azreposWorkloadFederationTenantId + +The tenant ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +``` + +**Also see: [GCM_AZREPOS_WIF_TENANTID][gcm-azrepos-wif-tenantid]** + +--- + +### credential.azreposWorkloadFederationAudience + +The audience to use when requesting the federated token for +[Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +Defaults to `api://AzureADTokenExchange`. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationAudience "api://AzureADTokenExchange" +``` + +**Also see: [GCM_AZREPOS_WIF_AUDIENCE][gcm-azrepos-wif-audience]** + +--- + +### credential.azreposWorkloadFederationAssertion + +Specifies the client assertion token to use with the `generic` +[Workload Identity Federation][wif] scenario +([credential.azreposWorkloadFederation][credential-azrepos-wif]). + +This setting is required when `credential.azreposWorkloadFederation` is set to +`generic`. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationAssertion "eyJhbGci..." +``` + +**Also see: [GCM_AZREPOS_WIF_ASSERTION][gcm-azrepos-wif-assertion]** + +--- + +### credential.azreposWorkloadFederationManagedIdentity + +Specifies the [Managed Identity][managed-identity] to use to obtain a federated +token for the `managedidentity` [Workload Identity Federation][wif] scenario +([credential.azreposWorkloadFederation][credential-azrepos-wif]). + +This setting is required when `credential.azreposWorkloadFederation` is set to +`managedidentity`. + +The value accepts the same formats as +[credential.azreposManagedIdentity](#credentialazreposmanagedidentity). + +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 + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationManagedIdentity system +``` + +**Also see: [GCM_AZREPOS_WIF_MANAGEDIDENTITY][gcm-azrepos-wif-managedidentity]** + +--- + ### credential.azreposServicePrincipal Specify the client and tenant IDs of a [service principal][service-principal] @@ -1048,6 +1180,12 @@ Defaults to disabled. [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-azrepos-wif]: environment.md#GCM_AZREPOS_WIF +[gcm-azrepos-wif-clientid]: environment.md#GCM_AZREPOS_WIF_CLIENTID +[gcm-azrepos-wif-tenantid]: environment.md#GCM_AZREPOS_WIF_TENANTID +[gcm-azrepos-wif-audience]: environment.md#GCM_AZREPOS_WIF_AUDIENCE +[gcm-azrepos-wif-assertion]: environment.md#GCM_AZREPOS_WIF_ASSERTION +[gcm-azrepos-wif-managedidentity]: environment.md#GCM_AZREPOS_WIF_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 @@ -1077,6 +1215,7 @@ Defaults to disabled. [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 +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation [provider-migrate]: migration.md#gcm_authority [cache-options]: https://git-scm.com/docs/git-credential-cache#_options [pass]: https://www.passwordstore.org/ @@ -1090,6 +1229,13 @@ Defaults to disabled. [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 +[azrepos-wif-doc]: azrepos-wif.md +[credential-azrepos-wif]: #credentialazreposworkloadfederation +[credential-azrepos-wif-clientid]: #credentialazreposworkloadfederationclientid +[credential-azrepos-wif-tenantid]: #credentialazreposworkloadfederationtenantid +[credential-azrepos-wif-audience]: #credentialazreposworkloadfederationaudience +[credential-azrepos-wif-assertion]: #credentialazreposworkloadfederationassertion +[credential-azrepos-wif-managedidentity]: #credentialazreposworkloadfederationmanagedidentity [credential-azrepos-sp]: #credentialazreposserviceprincipal [credential-azrepos-sp-secret]: #credentialazreposserviceprincipalsecret [credential-azrepos-sp-cert-thumbprint]: #credentialazreposserviceprincipalcertificatethumbprint diff --git a/docs/development.md b/docs/development.md index 0242d68b8..9eedda44a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -215,13 +215,13 @@ HTML reports can be generated using ReportGenerator, this should be installed during the build process, from the command line: ```shell -dotnet ~/.nuget/packages/reportgenerator/*/*/net8.0/ReportGenerator.dll -reports:./**/TestResults/**/coverage.cobertura.xml -targetdir:./out/code-coverage +dotnet ~/.nuget/packages/reportgenerator/*/*/net10.0/ReportGenerator.dll -reports:./**/TestResults/**/coverage.cobertura.xml -targetdir:./out/code-coverage ``` or ```shell -dotnet {$env:USERPROFILE}/.nuget/packages/reportgenerator/*/*/net8.0/ReportGenerator.dll -reports:./**/TestResults/**/coverage.cobertura.xml -targetdir:./out/code-coverage +dotnet {$env:USERPROFILE}/.nuget/packages/reportgenerator/*/*/net10.0/ReportGenerator.dll -reports:./**/TestResults/**/coverage.cobertura.xml -targetdir:./out/code-coverage ``` Or via VSCode Terminal/Run Task: diff --git a/docs/enterprise-config.md b/docs/enterprise-config.md index d0cf30aba..0e8591f60 100644 --- a/docs/enterprise-config.md +++ b/docs/enterprise-config.md @@ -30,22 +30,10 @@ that can always be overridden by the user in the usual ways. Default setting values come from the Windows Registry, specifically the following keys: -### 32-bit Windows - ```text HKEY_LOCAL_MACHINE\SOFTWARE\GitCredentialManager\Configuration ``` -### 64-bit Windows - -```text -HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GitCredentialManager\Configuration -``` - -> GCM is a 32-bit executable on Windows. When running on a 64-bit -installation of Windows registry access is transparently redirected to the -`WOW6432Node` node. - By using the Windows Registry, system administrators can use Group Policy to easily set defaults for GCM's settings. @@ -55,6 +43,16 @@ those of the [Git configuration][config] settings. The type of each registry key can be either `REG_SZ` (string) or `REG_DWORD` (integer). +### 32-bit / x86 + +When running the 32-bit (x86) version of GCM on a 64-bit (x64 or ARM64) +installation of Windows, the registry access is transparently redirected to +the `WOW6432Node` node. + +```text +HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GitCredentialManager\Configuration +``` + ## macOS Default settings values come from macOS's preferences system. Configuration diff --git a/docs/environment.md b/docs/environment.md index f321caa6c..44a50e4ee 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -991,6 +991,172 @@ export GCM_AZREPOS_MANAGEDIDENTITY="id://11111111-1111-1111-1111-111111111111" --- +### GCM_AZREPOS_WIF + +Use [Workload Identity Federation][wif] to authenticate with Azure Repos. + +The value specifies the federation scenario to use for obtaining a client +assertion to exchange for an access token. + +You must also set the following companion settings: + +- [GCM_AZREPOS_WIF_CLIENTID][gcm-azrepos-wif-clientid] +- [GCM_AZREPOS_WIF_TENANTID][gcm-azrepos-wif-tenantid] + +Depending on the scenario, additional settings may be required. + +Value|Description +-|- +`generic`|Use a user-supplied client assertion ([GCM_AZREPOS_WIF_ASSERTION][gcm-azrepos-wif-assertion]) +`managedidentity`|Use a [Managed Identity][managed-identity] to obtain the federated token ([GCM_AZREPOS_WIF_MANAGEDIDENTITY][gcm-azrepos-wif-managedidentity]) +`githubactions`|Automatically obtain an OIDC token from GitHub Actions + +For more information about workload identity federation, see the +[conceptual documentation][azrepos-wif-doc] and the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF="githubactions" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF="githubactions" +``` + +**Also see: [credential.azreposWorkloadFederation][credential-azrepos-wif]** + +--- + +### GCM_AZREPOS_WIF_CLIENTID + +The client ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_CLIENTID="11111111-1111-1111-1111-111111111111" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_CLIENTID="11111111-1111-1111-1111-111111111111" +``` + +**Also see: [credential.azreposWorkloadFederationClientId][credential-azrepos-wif-clientid]** + +--- + +### GCM_AZREPOS_WIF_TENANTID + +The tenant ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_TENANTID="22222222-2222-2222-2222-222222222222" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_TENANTID="22222222-2222-2222-2222-222222222222" +``` + +**Also see: [credential.azreposWorkloadFederationTenantId][credential-azrepos-wif-tenantid]** + +--- + +### GCM_AZREPOS_WIF_AUDIENCE + +The audience to use when requesting the federated token for +[Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +Defaults to `api://AzureADTokenExchange`. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_AUDIENCE="api://AzureADTokenExchange" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_AUDIENCE="api://AzureADTokenExchange" +``` + +**Also see: [credential.azreposWorkloadFederationAudience][credential-azrepos-wif-audience]** + +--- + +### GCM_AZREPOS_WIF_ASSERTION + +Specifies the client assertion token to use with the `generic` +[Workload Identity Federation][wif] scenario +([GCM_AZREPOS_WIF][gcm-azrepos-wif]). + +This setting is required when `GCM_AZREPOS_WIF` is set to `generic`. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_ASSERTION="eyJhbGci..." +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_ASSERTION="eyJhbGci..." +``` + +**Also see: [credential.azreposWorkloadFederationAssertion][credential-azrepos-wif-assertion]** + +--- + +### GCM_AZREPOS_WIF_MANAGEDIDENTITY + +Specifies the [Managed Identity][managed-identity] to use to obtain a federated +token for the `managedidentity` [Workload Identity Federation][wif] scenario +([GCM_AZREPOS_WIF][gcm-azrepos-wif]). + +This setting is required when `GCM_AZREPOS_WIF` is set to `managedidentity`. + +The value accepts the same formats as +[GCM_AZREPOS_MANAGEDIDENTITY](#gcm_azrepos_managedidentity). + +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_WIF_MANAGEDIDENTITY="system" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_MANAGEDIDENTITY="system" +``` + +**Also see: [credential.azreposWorkloadFederationManagedIdentity][credential-azrepos-wif-managedidentity]** + +--- + ### GCM_AZREPOS_SERVICE_PRINCIPAL Specify the client and tenant IDs of a [service principal][service-principal] @@ -1186,6 +1352,12 @@ Defaults to disabled. [credential-autodetecttimeout]: configuration.md#credentialautodetecttimeout [credential-azrepos-credential-type]: configuration.md#credentialazreposcredentialtype [credential-azrepos-managedidentity]: configuration.md#credentialazreposmanagedidentity +[credential-azrepos-wif]: configuration.md#credentialazreposworkloadfederation +[credential-azrepos-wif-clientid]: configuration.md#credentialazreposworkloadfederationclientid +[credential-azrepos-wif-tenantid]: configuration.md#credentialazreposworkloadfederationtenantid +[credential-azrepos-wif-audience]: configuration.md#credentialazreposworkloadfederationaudience +[credential-azrepos-wif-assertion]: configuration.md#credentialazreposworkloadfederationassertion +[credential-azrepos-wif-managedidentity]: configuration.md#credentialazreposworkloadfederationmanagedidentity [credential-bitbucketauthmodes]: configuration.md#credentialbitbucketAuthModes [credential-cacheoptions]: configuration.md#credentialcacheoptions [credential-credentialstore]: configuration.md#credentialcredentialstore @@ -1224,6 +1396,7 @@ Defaults to disabled. [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 +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation [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 @@ -1235,6 +1408,13 @@ Defaults to disabled. [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 +[azrepos-wif-doc]: azrepos-wif.md +[gcm-azrepos-wif]: #gcm_azrepos_wif +[gcm-azrepos-wif-clientid]: #gcm_azrepos_wif_clientid +[gcm-azrepos-wif-tenantid]: #gcm_azrepos_wif_tenantid +[gcm-azrepos-wif-audience]: #gcm_azrepos_wif_audience +[gcm-azrepos-wif-assertion]: #gcm_azrepos_wif_assertion +[gcm-azrepos-wif-managedidentity]: #gcm_azrepos_wif_managedidentity [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 diff --git a/docs/install.md b/docs/install.md index 9fa7da4ac..86ead9557 100644 --- a/docs/install.md +++ b/docs/install.md @@ -210,7 +210,7 @@ the preferred install method for Linux because you can use it to install on any distribution][dotnet-supported-distributions]. You can also use this method on macOS if you so choose. -**Note:** Make sure you have installed [version 8.0 of the .NET +**Note:** Make sure you have installed [version 10.0 of the .NET SDK][dotnet-install] before attempting to run the following `dotnet tool` commands. After installing, you will also need to follow the output instructions to add the tools directory to your `PATH`. diff --git a/docs/ntlm-kerberos.md b/docs/ntlm-kerberos.md index 23a055ba4..46f9cf01f 100644 --- a/docs/ntlm-kerberos.md +++ b/docs/ntlm-kerberos.md @@ -49,6 +49,30 @@ server to agree on which authentication protocol to use (Kerberos or NTLM) based on their capabilities. Typically Kerberos is preferred if both the client and server support it, with NTLM acting as a fallback. +#### NTLM-over-SPNEGO + +> [!CAUTION] +> When using SPNEGO negotiation if either the client or server does not support +> Kerberos, or if there is an issue with Kerberos authentication, _NTLM may be +> selected as a fallback authentication protocol_. +> +> **This can expose you to all the security risks associated with NTLM.** + +Currently the only way to prevent NTLM from being used as a fallback when SPNEGO +negotiation is in use on Windows is to set the following registry key on your +client system to the value `2` (type `DWORD`) to disable NTLM support +system-wide: + +```text +HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0\RestrictSendingNTLMTraffic +``` + +> [!WARNING] +> Disabling NTLM support system-wide can have unintended consequences. +> +> NTLM is still often used in various legacy applications and services, and +> disabling it may cause authentication failures in those applications. + ## Built-in Support in Git Git provides built-in support for NTLM and Kerberos authentication through the @@ -61,8 +85,22 @@ On Windows, Git can use the native Windows [SSPI][sspi-wiki] (Security Support Provider Interface) to perform NTLM and Kerberos authentication. This allows Git to integrate seamlessly with the Windows authentication infrastructure. +> [!IMPORTANT] +> Kerberos authentication is not enabled by default in Git due to a known bug. +> +> To workaround this bug and enable Kerberos authentication, you must set the +> `http..emptyAuth` configuration option to `true` for your remote URL. +> +> For example: +> +> ```shell +> git config --global http.https://example.com.emptyAuth true +> ``` + > [!NOTE] -> As of Git for Windows version 2.XX.X, **NTLM support is disabled by default**. +> As of Git for Windows version 2.53.0.2, **NTLM support is disabled by +> default**. +> > Kerberos support _remains enabled_. ### Re-enabling NTLM Support diff --git a/src/linux/Packaging.Linux/Packaging.Linux.csproj b/src/linux/Packaging.Linux/Packaging.Linux.csproj index ddfb31500..b1ec25a99 100644 --- a/src/linux/Packaging.Linux/Packaging.Linux.csproj +++ b/src/linux/Packaging.Linux/Packaging.Linux.csproj @@ -3,7 +3,7 @@ - net8.0 + net10.0 false diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index 1a8ede938..5a0981d6e 100755 --- a/src/linux/Packaging.Linux/install-from-source.sh +++ b/src/linux/Packaging.Linux/install-from-source.sh @@ -91,7 +91,7 @@ ensure_dotnet_installed() { if [ -z "$(verify_existing_dotnet_installation)" ]; then curl -LO https://dot.net/v1/dotnet-install.sh chmod +x ./dotnet-install.sh - bash -c "./dotnet-install.sh --channel 8.0" + bash -c "./dotnet-install.sh --channel 10.0" # Since we have to run the dotnet install script with bash, dotnet isn't # added to the process PATH, so we manually add it here. @@ -103,10 +103,10 @@ ensure_dotnet_installed() { verify_existing_dotnet_installation() { # Get initial pieces of installed sdk version(s). - sdks=$(dotnet --list-sdks | cut -c 1-3) + sdks=$(dotnet --list-sdks | cut -d' ' -f1 | cut -d. -f1,2) # If we have a supported version installed, return. - supported_dotnet_versions="8.0" + supported_dotnet_versions="10.0" for v in $supported_dotnet_versions; do if [ $(echo $sdks | grep "$v") ]; then echo $sdks @@ -169,7 +169,7 @@ case "$distribution" in # Install dotnet packages and dependencies if needed. if [ -z "$(verify_existing_dotnet_installation)" ]; then # First try to use native feeds (Ubuntu 22.04 and later). - if ! apt_install dotnet8; then + if ! apt_install dotnet10; then # If the native feeds fail, we fall back to # packages.microsoft.com. We begin by adding the dotnet package # repository/signing key. @@ -185,7 +185,7 @@ case "$distribution" in $sudo_cmd apt update $sudo_cmd apt install apt-transport-https -y $sudo_cmd apt update - $sudo_cmd apt install dotnet-sdk-8.0 dpkg-dev -y + $sudo_cmd apt install dotnet-sdk-10.0 dpkg-dev -y fi fi ;; @@ -231,7 +231,7 @@ case "$distribution" in ensure_dotnet_installed ;; - mariner) + mariner | azurelinux*) print_unsupported_distro "WARNING" "$distribution" $sudo_cmd tdnf update -y diff --git a/src/linux/Packaging.Linux/layout.sh b/src/linux/Packaging.Linux/layout.sh index fe3a0f2b8..7d21a9dff 100755 --- a/src/linux/Packaging.Linux/layout.sh +++ b/src/linux/Packaging.Linux/layout.sh @@ -49,7 +49,7 @@ GCM_SRC="$SRC/shared/Git-Credential-Manager" PROJ_OUT="$OUT/linux/Packaging.Linux" # Build parameters -FRAMEWORK=net8.0 +FRAMEWORK=net10.0 # Perform pre-execution checks CONFIGURATION="${CONFIGURATION:=Debug}" diff --git a/src/osx/Installer.Mac/Installer.Mac.csproj b/src/osx/Installer.Mac/Installer.Mac.csproj index daabd20d4..ca872f911 100644 --- a/src/osx/Installer.Mac/Installer.Mac.csproj +++ b/src/osx/Installer.Mac/Installer.Mac.csproj @@ -3,7 +3,7 @@ - net8.0 + net10.0 false diff --git a/src/osx/Installer.Mac/layout.sh b/src/osx/Installer.Mac/layout.sh index ad8e2cfc2..9af3555b8 100755 --- a/src/osx/Installer.Mac/layout.sh +++ b/src/osx/Installer.Mac/layout.sh @@ -24,7 +24,7 @@ GCM_SRC="$SRC/shared/Git-Credential-Manager" GCM_UI_SRC="$SRC/shared/Git-Credential-Manager.UI.Avalonia" # Build parameters -FRAMEWORK=net8.0 +FRAMEWORK=net10.0 # Parse script arguments for i in "$@" diff --git a/src/osx/Installer.Mac/notarize.sh b/src/osx/Installer.Mac/notarize.sh index 9315d688a..f3aa55d00 100755 --- a/src/osx/Installer.Mac/notarize.sh +++ b/src/osx/Installer.Mac/notarize.sh @@ -1,4 +1,8 @@ #!/bin/bash +die () { + echo "$*" >&2 + exit 1 +} for i in "$@" do diff --git a/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj b/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj index 9e768b91c..b3072b96d 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj +++ b/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj @@ -1,20 +1,20 @@  - net8.0 + net10.0 false true latest - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj b/src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj index 6aab348f8..a2d5c69bb 100644 --- a/src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj +++ b/src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 Atlassian.Bitbucket Atlassian.Bitbucket false diff --git a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx index d7e6058e8..165edd0c8 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx +++ b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx @@ -22,10 +22,20 @@ + Bitbucket Authentication +
@@ -53,11 +63,20 @@ + Bitbucket Authentication - +
diff --git a/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs b/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs index 0118e9d85..42f5cedc7 100644 --- a/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs +++ b/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Security.AccessControl; using System.Text; +using System.Threading.Tasks; using GitCredentialManager.Diagnostics; using GitCredentialManager.Tests.Objects; using Xunit; @@ -11,7 +12,7 @@ namespace Core.Tests.Commands; public class DiagnoseCommandTests { [Fact] - public void NetworkingDiagnostic_SendHttpRequest_Primary_OK() + public async Task NetworkingDiagnostic_SendHttpRequest_Primary_OK() { var primaryUriString = "http://example.com"; var sb = new StringBuilder(); @@ -24,14 +25,14 @@ public void NetworkingDiagnostic_SendHttpRequest_Primary_OK() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); Assert.Contains(expected, sb.ToString()); } [Fact] - public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() + public async Task NetworkingDiagnostic_SendHttpRequest_Backup_OK() { var primaryUriString = "http://example.com"; var backupUriString = "http://httpforever.com"; @@ -48,7 +49,7 @@ public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); httpHandler.Setup(HttpMethod.Head, backupUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); httpHandler.AssertRequest(HttpMethod.Head, backupUri, expectedNumberOfCalls: 1); @@ -56,7 +57,7 @@ public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() } [Fact] - public void NetworkingDiagnostic_SendHttpRequest_No_Network() + public async Task NetworkingDiagnostic_SendHttpRequest_No_Network() { var primaryUriString = "http://example.com"; var backupUriString = "http://httpforever.com"; @@ -73,7 +74,7 @@ public void NetworkingDiagnostic_SendHttpRequest_No_Network() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); httpHandler.Setup(HttpMethod.Head, backupUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); httpHandler.AssertRequest(HttpMethod.Head, backupUri, expectedNumberOfCalls: 1); diff --git a/src/shared/Core.Tests/Core.Tests.csproj b/src/shared/Core.Tests/Core.Tests.csproj index e3ae7e6b0..64a1e2437 100644 --- a/src/shared/Core.Tests/Core.Tests.csproj +++ b/src/shared/Core.Tests/Core.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 false true latest @@ -9,13 +9,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/Core.Tests/EnvironmentTests.cs b/src/shared/Core.Tests/EnvironmentTests.cs index d9b7cb67c..40c685c2a 100644 --- a/src/shared/Core.Tests/EnvironmentTests.cs +++ b/src/shared/Core.Tests/EnvironmentTests.cs @@ -94,6 +94,7 @@ public void PosixEnvironment_TryLocateExecutable_Exists_ReturnTrueAndPath() [expectedPath] = Array.Empty(), } }; + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); @@ -116,6 +117,32 @@ public void PosixEnvironment_TryLocateExecutable_ExistsMultiple_ReturnTrueAndFir ["/bin/foo"] = Array.Empty(), } }; + fs.SetExecutable(expectedPath); + fs.SetExecutable("/usr/local/bin/foo"); + fs.SetExecutable("/bin/foo"); + var envars = new Dictionary {["PATH"] = PosixPathVar}; + var env = new PosixEnvironment(fs, envars); + + bool actualResult = env.TryLocateExecutable(PosixExecName, out string actualPath); + + Assert.True(actualResult); + Assert.Equal(expectedPath, actualPath); + } + + [PosixFact] + public void PosixEnvironment_TryLocateExecutable_NotExecutable_SkipsToNextMatch() + { + string nonExecPath = "/home/john.doe/bin/foo"; + string expectedPath = "/usr/local/bin/foo"; + var fs = new TestFileSystem + { + Files = new Dictionary + { + [nonExecPath] = Array.Empty(), + [expectedPath] = Array.Empty(), + } + }; + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); @@ -142,6 +169,8 @@ public void MacOSEnvironment_TryLocateExecutable_Paths_Are_Ignored() [expectedPath] = Array.Empty(), } }; + fs.SetExecutable(pathsToIgnore.FirstOrDefault()); + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); diff --git a/src/shared/Core.Tests/GitConfigurationTests.cs b/src/shared/Core.Tests/GitConfigurationTests.cs index 498651f73..5005cf431 100644 --- a/src/shared/Core.Tests/GitConfigurationTests.cs +++ b/src/shared/Core.Tests/GitConfigurationTests.cs @@ -436,5 +436,225 @@ public void GitConfiguration_UnsetAll_All_ThrowsException() Assert.Throws(() => config.UnsetAll(GitConfigurationLevel.All, "core.foobar", Constants.RegexPatterns.Any)); } + + [Fact] + public void GitConfiguration_CacheTryGet_ReturnsValueFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local user.email john@example.com").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // First access loads cache + bool result1 = config.TryGet("user.name", false, out string value1); + Assert.True(result1); + Assert.Equal("john.doe", value1); + + // Second access should use cache + bool result2 = config.TryGet("user.email", false, out string value2); + Assert.True(result2); + Assert.Equal("john@example.com", value2); + } + + [Fact] + public void GitConfiguration_CacheGetAll_ReturnsAllValuesFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value1").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value2").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value3").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + var values = new List(config.GetAll("test.multi")); + + Assert.Equal(3, values.Count); + Assert.Equal("value1", values[0]); + Assert.Equal("value2", values[1]); + Assert.Equal("value3", values[2]); + } + + [Fact] + public void GitConfiguration_CacheEnumerate_EnumeratesFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local cache.name test-value").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local cache.enabled true").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + var cacheEntries = new List<(string key, string value)>(); + config.Enumerate(entry => + { + if (entry.Key.StartsWith("cache.")) + { + cacheEntries.Add((entry.Key, entry.Value)); + } + return true; + }); + + Assert.Equal(2, cacheEntries.Count); + Assert.Contains(("cache.name", "test-value"), cacheEntries); + Assert.Contains(("cache.enabled", "true"), cacheEntries); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_SetInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.value initial").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache with initial value + bool result1 = config.TryGet("test.value", false, out string value1); + Assert.True(result1); + Assert.Equal("initial", value1); + + // Set new value (should invalidate cache) + config.Set(GitConfigurationLevel.Local, "test.value", "updated"); + + // Next read should get updated value + bool result2 = config.TryGet("test.value", false, out string value2); + Assert.True(result2); + Assert.Equal("updated", value2); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_AddInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.multi first").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache + var values1 = new List(config.GetAll("test.multi")); + Assert.Single(values1); + Assert.Equal("first", values1[0]); + + // Add new value (should invalidate cache) + config.Add(GitConfigurationLevel.Local, "test.multi", "second"); + + // Next read should include new value + var values2 = new List(config.GetAll("test.multi")); + Assert.Equal(2, values2.Count); + Assert.Equal("first", values2[0]); + Assert.Equal("second", values2[1]); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_UnsetInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.value exists").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache + bool result1 = config.TryGet("test.value", false, out string value1); + Assert.True(result1); + Assert.Equal("exists", value1); + + // Unset value (should invalidate cache) + config.Unset(GitConfigurationLevel.Local, "test.value"); + + // Next read should not find value + bool result2 = config.TryGet("test.value", false, out string value2); + Assert.False(result2); + Assert.Null(value2); + } + + [Fact] + public void GitConfiguration_CacheLevelFilter_ReturnsOnlyLocalValues() + { + string repoPath = CreateRepository(out string workDirPath); + + try + { + ExecGit(repoPath, workDirPath, "config --global test.level global-value").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local test.level local-value").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Get local value only + bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Raw, + "test.level", out string value); + Assert.True(result); + Assert.Equal("local-value", value); + } + finally + { + // Cleanup global config + ExecGit(repoPath, workDirPath, "config --global --unset test.level"); + } + } + + [Fact] + public void GitConfiguration_TypedQuery_CanonicalizesValues() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.path ~/example").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Path type queries use a separate cache loaded with --type=path, + // so Git canonicalizes the values during cache load. + bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Path, + "test.path", out string value); + Assert.True(result); + Assert.NotNull(value); + // Value should be canonicalized path, not raw "~/example" + Assert.NotEqual("~/example", value); + } } } diff --git a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs index 7ff80f03d..eb87e8c57 100644 --- a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs +++ b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs @@ -86,6 +86,102 @@ public void GnuPassCredentialStore_Remove_NotFound_ReturnsFalse() Assert.False(result); } + [PosixFact] + public void GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory() + { + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + + string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string storePath = Path.Combine(homePath, ".password-store"); + const string userId = "gcm-test@example.com"; + + // Place .gpg-id only in the namespace subdirectory (not the store root), + // simulating a pass store where the root has no .gpg-id but submodules do. + string subDirPath = Path.Combine(storePath, TestNamespace); + string gpgIdPath = Path.Combine(subDirPath, ".gpg-id"); + + gpg.GenerateKeys(userId); + + fs.Directories.Add(storePath); + fs.Directories.Add(subDirPath); + fs.Files[gpgIdPath] = Encoding.UTF8.GetBytes(userId); + + var collection = new GpgPassCredentialStore(fs, gpg, storePath, TestNamespace); + + string service = $"https://example.com/{Guid.NewGuid():N}"; + const string userName = "john.doe"; + string password = Guid.NewGuid().ToString("N"); + + try + { + // Write + collection.AddOrUpdate(service, userName, password); + + // Read + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, outCredential.Account); + Assert.Equal(password, outCredential.Password); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + collection.Remove(service, userName); + } + } + + [PosixFact] + public void GnuPassCredentialStore_WriteCredential_MultipleGpgIds_UsesNearestGpgId() + { + // Verify that when two subdirectories each have their own .gpg-id, encrypting a credential + // under one subdirectory uses that subdirectory's GPG identity, not the other one. + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + + string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string storePath = Path.Combine(homePath, ".password-store"); + + const string personalUserId = "personal@example.com"; + const string workUserId = "work@example.com"; + + // Only register the personal key; if the wrong (work) key is picked, EncryptFile will throw. + gpg.GenerateKeys(personalUserId); + + string personalSubDir = Path.Combine(storePath, "personal"); + string workSubDir = Path.Combine(storePath, "work"); + + fs.Directories.Add(storePath); + fs.Directories.Add(personalSubDir); + fs.Directories.Add(workSubDir); + fs.Files[Path.Combine(personalSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(personalUserId); + fs.Files[Path.Combine(workSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(workUserId); + + // Use "personal" namespace so credentials are stored under storePath/personal/... + var collection = new GpgPassCredentialStore(fs, gpg, storePath, "personal"); + + string service = $"https://example.com/{Guid.NewGuid():N}"; + const string userName = "john.doe"; + string password = Guid.NewGuid().ToString("N"); + + try + { + // Write - should pick personal/.gpg-id (personalUserId), not work/.gpg-id (workUserId) + collection.AddOrUpdate(service, userName, password); + + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, outCredential.Account); + Assert.Equal(password, outCredential.Password); + } + finally + { + collection.Remove(service, userName); + } + } + private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg) { string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); diff --git a/src/shared/Core.Tests/StreamExtensionsTests.cs b/src/shared/Core.Tests/StreamExtensionsTests.cs index 09153ad26..b72874ba9 100644 --- a/src/shared/Core.Tests/StreamExtensionsTests.cs +++ b/src/shared/Core.Tests/StreamExtensionsTests.cs @@ -381,12 +381,13 @@ public void StreamExtensions_WriteDictionary_MultiEntriesWithEmpty_WritesKVPList { ["a"] = new[] {"1", "2", "", "3", "4"}, ["b"] = new[] {"5"}, - ["c"] = new[] {"6", "7", ""} + ["c"] = new[] {"6", "7", ""}, + ["d"] = new[] {"8", "", "9"} }; string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: LF); - Assert.Equal("a[]=3\na[]=4\nb=5\n\n", output); + Assert.Equal("a[]=3\na[]=4\nb=5\nd=9\n\n", output); } #endregion diff --git a/src/shared/Core.Tests/Trace2MessageTests.cs b/src/shared/Core.Tests/Trace2MessageTests.cs index 7e29a641f..82c1249ca 100644 --- a/src/shared/Core.Tests/Trace2MessageTests.cs +++ b/src/shared/Core.Tests/Trace2MessageTests.cs @@ -12,6 +12,8 @@ public class Trace2MessageTests [InlineData(26.316083, " 26.316083 ")] [InlineData(100.316083, "100.316083 ")] [InlineData(1000.316083, "1000.316083")] + [InlineData(10000.316083, "10000.316083")] + [InlineData(100000.31608, "100000.316080")] public void BuildTimeSpan_Match_Returns_Expected_String(double input, string expected) { var actual = Trace2Message.BuildTimeSpan(input); diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 5e2eca63c..5d65fa982 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -9,6 +9,7 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; using System.Text; +using System.Text.Json; using System.Threading; using GitCredentialManager.UI; using GitCredentialManager.UI.Controls; @@ -63,6 +64,14 @@ Task GetTokenForUserAsync(string authority, stri /// - "resource://{guid}" - Use the user-assigned managed identity with resource ID {guid}. /// Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource); + + /// + /// Acquire a token using workload federation. + /// + /// An object containing configuration workload federation. + /// Scopes to request. + /// Authentication result including access token. + Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes); } public class ServicePrincipalIdentity @@ -287,7 +296,8 @@ public async Task GetTokenForServicePrincipalAsy } } - public async Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource) + public async Task GetTokenForManagedIdentityAsync( + string managedIdentity, string resource) { var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); @@ -306,8 +316,88 @@ public async Task GetTokenForManagedIdentityAsyn { Context.Trace.WriteLine(mid == ManagedIdentityId.SystemAssigned ? "Failed to acquire token for system managed identity." - : $"Failed to acquire token for user managed identity '{managedIdentity:D}'."); + : $"Failed to acquire token for user managed identity '{managedIdentity}'."); + Context.Trace.WriteException(ex); + throw; + } + } + + public async Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes) + { + IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(fedOpts); + + AuthenticationResult result = await app.AcquireTokenForClient(scopes) + .ExecuteAsync() + .ConfigureAwait(false); + + return new MsalResult(result); + } + + private async Task GetClientAssertion(MicrosoftWorkloadFederationOptions fedOpts, AssertionRequestOptions _) + { + switch (fedOpts.Scenario) + { + case MicrosoftWorkloadFederationScenario.Generic: + Context.Trace.WriteLine("Getting client assertion for generic workload federation scenario..."); + if (string.IsNullOrWhiteSpace(fedOpts.GenericClientAssertion)) + throw new InvalidOperationException( + "Client assertion must be provided for generic workload federation scenario."); + return fedOpts.GenericClientAssertion; + + case MicrosoftWorkloadFederationScenario.ManagedIdentity: + Context.Trace.WriteLine("Getting client assertion for managed identity workload federation scenario..."); + var miResult = await GetTokenForManagedIdentityAsync(fedOpts.ManagedIdentityId, fedOpts.Audience); + return miResult.AccessToken; + + case MicrosoftWorkloadFederationScenario.GitHubActions: + Context.Trace.WriteLine("Getting client assertion for GitHub Actions workload federation scenario..."); + return await GetGitHubOidcToken(fedOpts.GitHubTokenRequestUrl, fedOpts.Audience, fedOpts.GitHubTokenRequestToken); + + default: + throw new ArgumentOutOfRangeException(nameof(fedOpts.Scenario), fedOpts.Scenario, "Unsupported workload federation scenario."); + } + } + + private async Task GetGitHubOidcToken(Uri requestUri, string audience, string requestToken) + { + using HttpClient http = Context.HttpClientFactory.CreateClient(); + + UriBuilder ub = new UriBuilder(requestUri); + if (ub.Query.Length > 0) ub.Query += "&"; + ub.Query += $"audience={Uri.EscapeDataString(audience)}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, ub.Uri); + request.AddBearerAuthenticationHeader(requestToken); + + Context.Trace.WriteLine($"Requesting GitHub OIDC token from '{request.RequestUri}'..."); + Context.Trace.WriteLineSecrets("OIDC request token: {0}", new[] { requestToken }); + using HttpResponseMessage response = await http.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + string error = await response.Content.ReadAsStringAsync(); + Context.Trace.WriteLine($"Failed to acquire GitHub OIDC token [{response.StatusCode:D} {response.StatusCode}]: {error}"); + response.EnsureSuccessStatusCode(); + } + + string json = await response.Content.ReadAsStringAsync(); + + try + { + using JsonDocument jsonDoc = JsonDocument.Parse(json); + if (!jsonDoc.RootElement.TryGetProperty("value", out JsonElement tokenElement)) + { + throw new InvalidOperationException( + "Invalid response from GitHub OIDC token endpoint: 'value' property not found."); + } + + return tokenElement.GetString() ?? + throw new InvalidOperationException( + "Invalid response from GitHub OIDC token endpoint: 'value' property is null."); + } + catch (Exception ex) + { Context.Trace.WriteException(ex); + Context.Trace.WriteLine($"OIDC token response: {json}"); throw; } } @@ -558,6 +648,24 @@ private async Task CreateConfidentialClientAppli return app; } + private async Task CreateConfidentialClientApplicationAsync( + MicrosoftWorkloadFederationOptions fedOpts) + { + var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); + + Context.Trace.WriteLine($"Creating federated confidential client application for {fedOpts.TenantId}/{fedOpts.ClientId}..."); + var appBuilder = ConfidentialClientApplicationBuilder.Create(fedOpts.ClientId) + .WithTenantId(fedOpts.TenantId) + .WithHttpClientFactory(httpFactoryAdaptor) + .WithClientAssertion(reqOpts => GetClientAssertion(fedOpts, reqOpts)); + + IConfidentialClientApplication app = appBuilder.Build(); + + await RegisterTokenCacheAsync(app.AppTokenCache, CreateAppTokenCacheProps, Context.Trace2); + + return app; + } + #endregion #region Helpers diff --git a/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs b/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs new file mode 100644 index 000000000..5511c0dee --- /dev/null +++ b/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs @@ -0,0 +1,77 @@ +using System; + +namespace GitCredentialManager.Authentication; + +public enum MicrosoftWorkloadFederationScenario +{ + /// + /// Federate via pre-computed client assertion. + /// + Generic, + + /// + /// Federate via an access token for an Entra ID Managed Identity. + /// + ManagedIdentity, + + /// + /// Federate via a GitHub Actions OIDC token. + /// + GitHubActions, +} + +public class MicrosoftWorkloadFederationOptions +{ + public const string DefaultAudience = Constants.DefaultWorkloadFederationAudience; + + private string _audience = DefaultAudience; + + /// + /// The workload federation scenario to use. + /// + public MicrosoftWorkloadFederationScenario Scenario { get; set; } + + /// + /// Tenant ID of the identity to request an access token for. + /// + public string TenantId { get; set; } + + /// + /// Client ID of the identity to request an access token for. + /// + public string ClientId { get; set; } + + /// + /// The audience to use when requesting a token. + /// + /// If this is null, the default audience will be used. + public string Audience + { + get => _audience; + set => _audience = value ?? DefaultAudience; + } + + /// + /// Generic assertion. + /// + /// Used with the federation scenario. + public string GenericClientAssertion { get; set; } + + /// + /// The managed identity to request a federated token for, to exchange for an access token. + /// + /// Used with the federation scenario. + public string ManagedIdentityId { get; set; } + + /// + /// GitHub Actions OIDC token request URI. + /// + /// Used with the federation scenario. + public Uri GitHubTokenRequestUrl { get; set; } + + /// + /// GitHub Actions OIDC token request token. + /// + /// Used with the federation scenario. + public string GitHubTokenRequestToken { get; set; } +} diff --git a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs index 34d6cfbe7..05843f9df 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs @@ -10,12 +10,16 @@ namespace GitCredentialManager.Authentication.OAuth public class OAuth2WebBrowserOptions { internal const string DefaultSuccessHtml = @" - + + Authentication successful

Authentication successful

You can now close this page.

"; internal const string DefaultFailureHtmlFormat = @" - + + Authentication failed

Authentication failed

Error:
{0}
diff --git a/src/shared/Core/Authentication/OAuthAuthentication.cs b/src/shared/Core/Authentication/OAuthAuthentication.cs index a8de4ecb6..792ba40ec 100644 --- a/src/shared/Core/Authentication/OAuthAuthentication.cs +++ b/src/shared/Core/Authentication/OAuthAuthentication.cs @@ -247,7 +247,7 @@ private Task ShowDeviceCodeViaUiAsync(OAuth2DeviceCodeResult dcr, CancellationTo VerificationUrl = dcr.VerificationUri.ToString(), }; - return AvaloniaUi.ShowViewAsync(viewModel, GetParentWindowHandle(), CancellationToken.None); + return AvaloniaUi.ShowViewAsync(viewModel, GetParentWindowHandle(), ct); } private Task ShowDeviceCodeViaHelperAsync( diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index e6625a6ea..6fecc2b38 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -31,6 +31,8 @@ public static class Constants /// public static readonly Guid MsaTransferTenantId = new("f8cdef31-a31e-4b4a-93e4-5f571e91255a"); + public const string DefaultWorkloadFederationAudience = "api://AzureADTokenExchange"; + public static class CredentialProtocol { public const string NtlmKey = "ntlm"; @@ -130,6 +132,9 @@ public static class EnvironmentVariables public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS"; public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING"; public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES"; + + public const string GitHubActionsTokenRequestUrl = "ACTIONS_ID_TOKEN_REQUEST_URL"; + public const string GitHubActionsTokenRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; } public static class Http diff --git a/src/shared/Core/Core.csproj b/src/shared/Core/Core.csproj index f2804177b..d316df992 100644 --- a/src/shared/Core/Core.csproj +++ b/src/shared/Core/Core.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 gcmcore GitCredentialManager false @@ -13,25 +13,26 @@ - - + + - + - - - - - - + + + + + + - + + diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs index 11dc83818..95d26df32 100644 --- a/src/shared/Core/CredentialStore.cs +++ b/src/shared/Core/CredentialStore.cs @@ -291,18 +291,6 @@ private void ValidateGpgPass(out string storeRoot, out string execPath) storeRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".password-store"); } - // Check we have a GPG ID to sign credential files with - string gpgIdFile = Path.Combine(storeRoot, ".gpg-id"); - if (!_context.FileSystem.FileExists(gpgIdFile)) - { - var format = - "Password store has not been initialized at '{0}'; run `pass init ` to initialize the store."; - var message = string.Format(format, storeRoot); - _context.Trace2.WriteError(message); - throw new Exception(message + Environment.NewLine + - $"See {Constants.HelpUrls.GcmCredentialStores} for more information." - ); - } } private void ValidateCredentialCache(out string options) diff --git a/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs b/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs index 50ab5b4da..c49104ea8 100644 --- a/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs +++ b/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs @@ -29,7 +29,7 @@ protected override async Task RunInternalAsync(StringBuilder log, IList RunInternalAsync(StringBuilder log, IList { TestHttpUri, TestHttpUriFallback }) { diff --git a/src/shared/Core/EnvironmentBase.cs b/src/shared/Core/EnvironmentBase.cs index 6a3967193..39ed9dd03 100644 --- a/src/shared/Core/EnvironmentBase.cs +++ b/src/shared/Core/EnvironmentBase.cs @@ -138,7 +138,8 @@ internal virtual bool TryLocateExecutable(string program, ICollection pa { string candidatePath = Path.Combine(basePath, program); if (FileSystem.FileExists(candidatePath) && (pathsToIgnore is null || - !pathsToIgnore.Contains(candidatePath, StringComparer.OrdinalIgnoreCase))) + !pathsToIgnore.Contains(candidatePath, StringComparer.OrdinalIgnoreCase)) + && FileSystem.FileIsExecutable(candidatePath)) { path = candidatePath; return true; diff --git a/src/shared/Core/FileSystem.cs b/src/shared/Core/FileSystem.cs index aeacfd51d..c23f0faa1 100644 --- a/src/shared/Core/FileSystem.cs +++ b/src/shared/Core/FileSystem.cs @@ -34,6 +34,14 @@ public interface IFileSystem /// True if a file exists, false otherwise. bool FileExists(string path); + /// + /// Check if a file has execute permissions. + /// On Windows this always returns true. On POSIX it checks for any execute bit. + /// + /// Full path to file to test. + /// True if the file is executable, false otherwise. + bool FileIsExecutable(string path); + /// /// Check if a directory exists at the specified path. /// @@ -122,6 +130,23 @@ public abstract class FileSystem : IFileSystem public bool FileExists(string path) => File.Exists(path); +#if NETFRAMEWORK + public bool FileIsExecutable(string path) => true; +#else + public bool FileIsExecutable(string path) + { + if (!PlatformUtils.IsPosix()) + return true; + +#pragma warning disable CA1416 // Platform guard via PlatformUtils.IsPosix() + var mode = File.GetUnixFileMode(path); + return (mode & (UnixFileMode.UserExecute | + UnixFileMode.GroupExecute | + UnixFileMode.OtherExecute)) != 0; +#pragma warning restore CA1416 + } +#endif + public bool DirectoryExists(string path) => Directory.Exists(path); public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); diff --git a/src/shared/Core/Git.cs b/src/shared/Core/Git.cs index 0c58e0159..82588357c 100644 --- a/src/shared/Core/Git.cs +++ b/src/shared/Core/Git.cs @@ -146,6 +146,15 @@ private string GetCurrentRepositoryInternal(bool suppressStreams) } git.Start(Trace2ProcessClass.Git); + + // Drain and throw away stderr asynchronously to avoid a deadlock + // if the child process fills the stderr pipe buffer. + if (suppressStreams) + { + git.Process.ErrorDataReceived += (_, _) => { }; + git.Process.BeginErrorReadLine(); + } + string data = git.StandardOutput.ReadToEnd(); git.WaitForExit(); @@ -167,6 +176,8 @@ public IEnumerable GetRemotes() { using (var git = CreateProcess("remote -v show")) { + // Redirect stderr so we can check for 'not a git repository' errors + git.StartInfo.RedirectStandardError = true; git.Start(Trace2ProcessClass.Git); // To avoid deadlocks, always read the output stream first and then wait // TODO: don't read in all the data at once; stream it @@ -267,7 +278,9 @@ public async Task> InvokeHelperAsync(string args, ID public static GitException CreateGitException(ChildProcess git, string message, ITrace2 trace2 = null) { - var gitMessage = git.StandardError.ReadToEnd(); + var gitMessage = git.StartInfo.RedirectStandardError + ? git.StandardError.ReadToEnd() + : null; if (trace2 != null) throw new Trace2GitException(trace2, message, git.ExitCode, gitMessage); diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index 9603b2db5..83a10d591 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Text; namespace GitCredentialManager @@ -108,24 +109,359 @@ public interface IGitConfiguration void UnsetAll(GitConfigurationLevel level, string name, string valueRegex); } + /// + /// Represents a single configuration entry with its origin and level. + /// + internal class ConfigCacheEntry + { + public string Value { get; set; } + public GitConfigurationLevel Level { get; set; } + + public ConfigCacheEntry(string scope, string value) + { + Value = value; + Level = ParseScope(scope); + } + + private static GitConfigurationLevel ParseScope(string scope) + { + switch (scope) + { + case "system": + return GitConfigurationLevel.System; + case "global": + return GitConfigurationLevel.Global; + case "local": + case "worktree": + case "command": + return GitConfigurationLevel.Local; + default: + return GitConfigurationLevel.Unknown; + } + } + } + + /// + /// Cache for Git configuration entries loaded from 'git config list --show-scope -z'. + /// + internal class ConfigCache + { + private Dictionary> _entries; + private readonly object _lock = new object(); + + public bool IsLoaded => _entries != null; + + public void Load(string data, ITrace trace) + { + lock (_lock) + { + var entries = new Dictionary>(GitConfigurationKeyComparer.Instance); + + var scope = new StringBuilder(); + var key = new StringBuilder(); + var value = new StringBuilder(); + + int i = 0; + while (i < data.Length) + { + scope.Clear(); + key.Clear(); + value.Clear(); + + // Read scope (NUL terminated) + while (i < data.Length && data[i] != '\0') + { + scope.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after scope."); + break; + } + + // Skip the NUL terminator + i++; + + // Read key (newline terminated) + while (i < data.Length && data[i] != '\n') + { + key.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected newline terminator (\\n) after key."); + break; + } + + // Skip the newline terminator + i++; + + // Read value (NUL terminated) + while (i < data.Length && data[i] != '\0') + { + value.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after value."); + break; + } + + // Skip the NUL terminator + i++; + + string keyStr = key.ToString(); + var entry = new ConfigCacheEntry(scope.ToString(), value.ToString()); + + if (!entries.ContainsKey(keyStr)) + { + entries[keyStr] = new List(); + } + entries[keyStr].Add(entry); + } + + _entries = entries; + } + } + + public bool TryGet(string name, GitConfigurationLevel level, out string value) + { + lock (_lock) + { + if (_entries == null) + { + value = null; + return false; + } + + if (!_entries.TryGetValue(name, out var entryList)) + { + value = null; + return false; + } + + // Find the last entry matching the level filter (respects Git's precedence) + // Git config precedence: system < global < local, so last match wins + ConfigCacheEntry lastMatch = null; + foreach (var entry in entryList) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + lastMatch = entry; + } + } + + if (lastMatch != null) + { + value = lastMatch.Value; + return true; + } + + value = null; + return false; + } + } + + public IEnumerable GetAll(string name, GitConfigurationLevel level) + { + lock (_lock) + { + if (_entries == null || !_entries.TryGetValue(name, out var entryList)) + { + return Array.Empty(); + } + + var results = new List(); + foreach (var entry in entryList) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + results.Add(entry.Value); + } + } + + return results; + } + } + + public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) + { + lock (_lock) + { + if (_entries == null) + return; + + foreach (var kvp in _entries) + { + foreach (var entry in kvp.Value) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + var configEntry = new GitConfigurationEntry(kvp.Key, entry.Value); + if (!cb(configEntry)) + { + return; + } + } + } + } + } + } + + public void Clear() + { + lock (_lock) + { + _entries = null; + } + } + } + public class GitProcessConfiguration : IGitConfiguration { private static readonly GitVersion TypeConfigMinVersion = new GitVersion(2, 18, 0); + private static readonly GitVersion ConfigListTypeMinVersion = new GitVersion(2, 54, 0); + private static readonly GitVersion ConfigListTypeMinVfsBase = new GitVersion(2, 53, 0); + private static readonly GitVersion ConfigListTypeMinVfsSuffix = new GitVersion(0, 1); private readonly ITrace _trace; private readonly GitProcess _git; + private readonly Dictionary _cache; + private readonly bool _useCache; - internal GitProcessConfiguration(ITrace trace, GitProcess git) + internal GitProcessConfiguration(ITrace trace, GitProcess git) : this(trace, git, useCache: true) + { + } + + internal GitProcessConfiguration(ITrace trace, GitProcess git, bool useCache) { EnsureArgument.NotNull(trace, nameof(trace)); EnsureArgument.NotNull(git, nameof(git)); _trace = trace; _git = git; + + // 'git config list --type=' requires Git 2.54.0+, + // or microsoft/git fork 2.53.0.vfs.0.1+ + if (useCache && !SupportsConfigListType(git)) + { + trace.WriteLine($"Git version {git.Version.OriginalString} does not support 'git config list --type'; config cache disabled"); + useCache = false; + } + + _useCache = useCache; + _cache = useCache ? new Dictionary() : null; + } + + private static bool SupportsConfigListType(GitProcess git) + { + if (git.Version >= ConfigListTypeMinVersion) + return true; + + // The microsoft/git fork fast-tracked the fix into 2.53.0.vfs.0.1. + // Version strings like "2.53.0.vfs.0.1" parse as [2,53,0] because + // GitVersion stops at the non-integer "vfs" component, so we check + // the original string for the ".vfs." marker and parse the suffix. + string versionStr = git.Version.OriginalString; + if (versionStr != null) + { + int vfsIdx = versionStr.IndexOf(".vfs."); + if (vfsIdx >= 0) + { + var baseVersion = new GitVersion(versionStr.Substring(0, vfsIdx)); + var vfsSuffix = new GitVersion(versionStr.Substring(vfsIdx + 5)); + return baseVersion >= ConfigListTypeMinVfsBase + && vfsSuffix >= ConfigListTypeMinVfsSuffix; + } + } + + return false; + } + + private void EnsureCacheLoaded(GitConfigurationType type) + { + ConfigCache cache; + if (!_useCache || (_cache.TryGetValue(type, out cache) && cache.IsLoaded)) + { + return; + } + + if (cache == null) + { + cache = new ConfigCache(); + _cache[type] = cache; + } + + string typeArg; + + switch (type) + { + case GitConfigurationType.Raw: + typeArg = "--no-type"; + break; + + case GitConfigurationType.Path: + typeArg = "--type=path"; + break; + + case GitConfigurationType.Bool: + typeArg = "--type=bool"; + break; + + default: + return; + } + + using (ChildProcess git = _git.CreateProcess($"config list --show-scope -z {typeArg}")) + { + git.Start(Trace2ProcessClass.Git); + // To avoid deadlocks, always read the output stream first and then wait + string data = git.StandardOutput.ReadToEnd(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + cache.Load(data, _trace); + break; + default: + _trace.WriteLine($"Failed to load config cache (exit={git.ExitCode})"); + // Don't throw - fall back to individual commands + break; + } + } + } + + private void InvalidateCache() + { + if (_useCache) + { + foreach (ConfigCache cache in _cache.Values) + { + cache.Clear(); + } + } } public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) { + if (_useCache) + { + EnsureCacheLoaded(GitConfigurationType.Raw); + + ConfigCache cache = _cache[GitConfigurationType.Raw]; + + if (cache.IsLoaded) + { + cache.Enumerate(level, cb); + return; + } + } + + // Fall back to original implementation string levelArg = GetLevelFilterArg(level); using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} --list")) { @@ -194,6 +530,19 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa public bool TryGet(GitConfigurationLevel level, GitConfigurationType type, string name, out string value) { + if (_useCache) + { + EnsureCacheLoaded(type); + + ConfigCache cache = _cache[type]; + if (cache.IsLoaded) + { + // Cache is loaded, use it for the result (whether found or not) + return cache.TryGet(name, level, out value); + } + } + + // Fall back to individual git config command if cache not available string levelArg = GetLevelFilterArg(level); string typeArg = GetCanonicalizeTypeArg(type); using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} {typeArg} {QuoteCmdArg(name)}")) @@ -242,6 +591,7 @@ public void Set(GitConfigurationLevel level, string name, string value) switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to set config entry '{name}' to value '{value}' (exit={git.ExitCode}, level={level})"); @@ -263,6 +613,7 @@ public void Add(GitConfigurationLevel level, string name, string value) switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to add config entry '{name}' with value '{value}' (exit={git.ExitCode}, level={level})"); @@ -285,6 +636,7 @@ public void Unset(GitConfigurationLevel level, string name) { case 0: // OK case 5: // Trying to unset a value that does not exist + InvalidateCache(); break; default: _trace.WriteLine($"Failed to unset config entry '{name}' (exit={git.ExitCode}, level={level})"); @@ -295,6 +647,23 @@ public void Unset(GitConfigurationLevel level, string name) public IEnumerable GetAll(GitConfigurationLevel level, GitConfigurationType type, string name) { + if (_useCache) + { + EnsureCacheLoaded(type); + + ConfigCache cache = _cache[type]; + if (cache.IsLoaded) + { + var cachedValues = cache.GetAll(name, level); + foreach (var val in cachedValues) + { + yield return val; + } + yield break; + } + } + + // Fall back to individual git config command string levelArg = GetLevelFilterArg(level); string typeArg = GetCanonicalizeTypeArg(type); @@ -392,6 +761,7 @@ public void ReplaceAll(GitConfigurationLevel level, string name, string valueReg switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to replace all multivar '{name}' and value regex '{valueRegex}' with new value '{value}' (exit={git.ExitCode}, level={level})"); @@ -420,6 +790,7 @@ public void UnsetAll(GitConfigurationLevel level, string name, string valueRegex { case 0: // OK case 5: // Trying to unset a value that does not exist + InvalidateCache(); break; default: _trace.WriteLine($"Failed to unset all multivar '{name}' with value regex '{valueRegex}' (exit={git.ExitCode}, level={level})"); diff --git a/src/shared/Core/HttpClientFactory.cs b/src/shared/Core/HttpClientFactory.cs index c48e277e5..d66fad39f 100644 --- a/src/shared/Core/HttpClientFactory.cs +++ b/src/shared/Core/HttpClientFactory.cs @@ -130,7 +130,11 @@ public HttpClient CreateClient() // Import the custom certs X509Certificate2Collection certBundle = new X509Certificate2Collection(); +#if NETFRAMEWORK certBundle.Import(certBundlePath); +#else + certBundle.ImportFromPemFile(certBundlePath); +#endif try { diff --git a/src/shared/Core/Interop/Linux/LinuxConfigParser.cs b/src/shared/Core/Interop/Linux/LinuxConfigParser.cs index 1caa918fc..b9b759977 100644 --- a/src/shared/Core/Interop/Linux/LinuxConfigParser.cs +++ b/src/shared/Core/Interop/Linux/LinuxConfigParser.cs @@ -31,7 +31,7 @@ public IDictionary Parse(string content) { var result = new Dictionary(GitConfigurationKeyComparer.Instance); - IEnumerable lines = content.Split(['\n'], StringSplitOptions.RemoveEmptyEntries); + IEnumerable lines = content.Split(new char[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (string line in lines) { diff --git a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs index 6ed56c693..debc9c815 100644 --- a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs +++ b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs @@ -21,19 +21,33 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot protected override string CredentialFileExtension => ".gpg"; - private string GetGpgId() + private string GetGpgId(string credentialFullPath) { - string gpgIdPath = Path.Combine(StoreRoot, ".gpg-id"); - if (!FileSystem.FileExists(gpgIdPath)) + // Walk up from the credential's directory to the store root, looking for a .gpg-id file. + // This mimics the behaviour of GNU Pass, which uses the nearest .gpg-id in the directory hierarchy. + string dir = Path.GetDirectoryName(credentialFullPath); + while (dir != null) { - throw new Exception($"Cannot find GPG ID in '{gpgIdPath}'; password store has not been initialized"); - } + string gpgIdPath = Path.Combine(dir, ".gpg-id"); + if (FileSystem.FileExists(gpgIdPath)) + { + using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream)) + { + return reader.ReadLine(); + } + } - using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var reader = new StreamReader(stream)) - { - return reader.ReadLine(); + // Stop after checking the store root + if (FileSystem.IsSamePath(dir, StoreRoot)) + { + break; + } + + dir = Path.GetDirectoryName(dir); } + + throw new Exception($"Cannot find GPG ID in password store at '{StoreRoot}'; run `pass init ` to initialize the store."); } protected override bool TryDeserializeCredential(string path, out FileCredential credential) @@ -68,7 +82,7 @@ protected override bool TryDeserializeCredential(string path, out FileCredential protected override void SerializeCredential(FileCredential credential) { - string gpgId = GetGpgId(); + string gpgId = GetGpgId(credential.FullPath); var sb = new StringBuilder(credential.Password); sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine); diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs index af3dcf99c..480db7ea5 100644 --- a/src/shared/Core/Settings.cs +++ b/src/shared/Core/Settings.cs @@ -659,7 +659,7 @@ public bool IsCertificateVerificationEnabled } public bool AutomaticallyUseClientCertificates => - TryGetSetting(null, KnownGitCfg.Credential.SectionName, KnownGitCfg.Http.SslAutoClientCert, out string value) && value.ToBooleanyOrDefault(false); + TryGetSetting(null, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslAutoClientCert, out string value) && value.ToBooleanyOrDefault(false); public string CustomCertificateBundlePath => TryGetPathSetting(KnownEnvars.GitSslCaInfo, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslCaInfo, out string value) ? value : null; diff --git a/src/shared/Core/StreamExtensions.cs b/src/shared/Core/StreamExtensions.cs index 7ff338f5a..beb85699b 100644 --- a/src/shared/Core/StreamExtensions.cs +++ b/src/shared/Core/StreamExtensions.cs @@ -179,7 +179,7 @@ public static void WriteDictionary(this TextWriter writer, IDictionary= 0; i--) { - using (var writer = _writers[i]) + using (_writers[i]) { - _writers.Remove(writer); + _writers.RemoveAt(i); } } } @@ -640,7 +640,7 @@ private void WriteMessage(Trace2Message message) private static string BuildThreadName() { // If this is the entry thread, call it "main", per Trace2 convention - if (Thread.CurrentThread.ManagedThreadId == 0) + if (Thread.CurrentThread.ManagedThreadId == 1) { return "main"; } diff --git a/src/shared/Core/Trace2Message.cs b/src/shared/Core/Trace2Message.cs index 14327031f..78eb05a20 100644 --- a/src/shared/Core/Trace2Message.cs +++ b/src/shared/Core/Trace2Message.cs @@ -151,7 +151,7 @@ private static string BuildSpan(PerformanceFormatSpan component, string data) if (double.TryParse(data, out _)) { // Remove all padding for values that take up the entire span - if (Math.Abs(sizeDifference) == paddingTotal) + if (Math.Abs(sizeDifference) >= paddingTotal) { component.BeginPadding = 0; component.EndPadding = 0; diff --git a/src/shared/DotnetTool/DotnetTool.csproj b/src/shared/DotnetTool/DotnetTool.csproj index a1107a4b6..720f75693 100644 --- a/src/shared/DotnetTool/DotnetTool.csproj +++ b/src/shared/DotnetTool/DotnetTool.csproj @@ -1,6 +1,6 @@ - net8.0 + net10.0 true dotnet-tool.nuspec diff --git a/src/shared/DotnetTool/dotnet-tool.nuspec b/src/shared/DotnetTool/dotnet-tool.nuspec index 35f81ebc9..9eb9a7020 100644 --- a/src/shared/DotnetTool/dotnet-tool.nuspec +++ b/src/shared/DotnetTool/dotnet-tool.nuspec @@ -11,7 +11,7 @@ - + diff --git a/src/shared/DotnetTool/layout.ps1 b/src/shared/DotnetTool/layout.ps1 index ca9b13011..f2bd87285 100644 --- a/src/shared/DotnetTool/layout.ps1 +++ b/src/shared/DotnetTool/layout.ps1 @@ -44,7 +44,7 @@ $DotnetToolRel = "shared/DotnetTool" $GcmSrc = Join-Path $Src "shared\Git-Credential-Manager" $ProjOut = Join-Path $Out $DotnetToolRel -$Framework = "net8.0" +$Framework = "net10.0" if (-not $Output -or $Output.Trim() -eq "") { $Output = Join-Path $ProjOut "nupkg\$Configuration" diff --git a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj index 8c469897e..b367bb48a 100644 --- a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj +++ b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj @@ -2,8 +2,8 @@ Exe - net8.0 - net472;net8.0 + net10.0 + net472;net10.0 win-x86;win-x64;win-arm64;osx-x64;linux-x64;osx-arm64;linux-arm64;linux-arm git-credential-manager GitCredentialManager diff --git a/src/shared/GitHub.Tests/GitHub.Tests.csproj b/src/shared/GitHub.Tests/GitHub.Tests.csproj index 0574e00d1..cf7f8be69 100644 --- a/src/shared/GitHub.Tests/GitHub.Tests.csproj +++ b/src/shared/GitHub.Tests/GitHub.Tests.csproj @@ -1,20 +1,20 @@  - net8.0 + net10.0 false true latest - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/GitHub/GitHub.csproj b/src/shared/GitHub/GitHub.csproj index 66a4afd79..53ac5cf94 100644 --- a/src/shared/GitHub/GitHub.csproj +++ b/src/shared/GitHub/GitHub.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 GitHub GitHub false diff --git a/src/shared/GitHub/GitHubAuthChallenge.cs b/src/shared/GitHub/GitHubAuthChallenge.cs index de3afbdbd..1b33330c9 100644 --- a/src/shared/GitHub/GitHubAuthChallenge.cs +++ b/src/shared/GitHub/GitHubAuthChallenge.cs @@ -107,7 +107,15 @@ public override bool Equals(object obj) public override int GetHashCode() { - return Domain.GetHashCode() * 1019 ^ - Enterprise.GetHashCode() * 337; + int domainHash = Domain is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(Domain); + + int enterpriseHash = Enterprise is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(Enterprise); + + return (domainHash * 1019) ^ + (enterpriseHash * 337); } } diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index 06afd9592..07607dd4e 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -198,6 +198,7 @@ private bool FilterAccounts(Uri remoteUri, IEnumerable wwwAuth, ref ILis if (!IsGitHubDotCom(remoteUri)) { _context.Trace.WriteLine("No account filtering outside of GitHub.com."); + return false; } // Allow the user to disable account filtering until this feature stabilises. diff --git a/src/shared/GitHub/GitHubResources.resx b/src/shared/GitHub/GitHubResources.resx index a5348d617..3972d4779 100644 --- a/src/shared/GitHub/GitHubResources.resx +++ b/src/shared/GitHub/GitHubResources.resx @@ -20,6 +20,7 @@ + Git Credential Manager - Authentication Succeeded
@@ -75,6 +89,7 @@ p { + Git Credential Manager - Authentication Failed
diff --git a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs index f14b3cb3e..45c6cfd7f 100644 --- a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs +++ b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs @@ -38,7 +38,7 @@ protected CredentialsCommand(ICommandContext context) this.SetHandler(ExecuteAsync, url, userName, basic, browser, device, pat, all); } - private async Task ExecuteAsync(string userName, string enterpriseUrl, + private async Task ExecuteAsync(string enterpriseUrl, string userName, bool basic, bool browser, bool device, bool pat, bool all) { var viewModel = new CredentialsViewModel(Context.SessionManager, Context.ProcessManager) diff --git a/src/shared/GitLab.Tests/GitLab.Tests.csproj b/src/shared/GitLab.Tests/GitLab.Tests.csproj index 098878aec..aef8483a1 100644 --- a/src/shared/GitLab.Tests/GitLab.Tests.csproj +++ b/src/shared/GitLab.Tests/GitLab.Tests.csproj @@ -1,20 +1,20 @@  - net8.0 + net10.0 false true latest - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/GitLab/GitLab.csproj b/src/shared/GitLab/GitLab.csproj index 25c37f2fe..19aa356f2 100644 --- a/src/shared/GitLab/GitLab.csproj +++ b/src/shared/GitLab/GitLab.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 GitLab GitLab false diff --git a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs index 1c1995a8d..02a0f7818 100644 --- a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs +++ b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs @@ -35,7 +35,7 @@ protected CredentialsCommand(ICommandContext context) this.SetHandler(ExecuteAsync, url, userName, basic, browser, pat, all); } - private async Task ExecuteAsync(string userName, string url, bool basic, bool browser, bool pat, bool all) + private async Task ExecuteAsync(string url, string userName, bool basic, bool browser, bool pat, bool all) { var viewModel = new CredentialsViewModel(Context.SessionManager) { diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs index 2c506ae62..ec60d8f70 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs @@ -628,6 +628,93 @@ public void AzureReposBindingManager_SignIn_OtherGlobalOtherLocal_BindsLocal() Assert.Equal(user2, actualGlobalUser); } + // Idempotency: SignIn when state is already correct should not write to git config + + [Fact] + public void AzureReposBindingManager_SignIn_SameGlobalNoLocal_NoConfigWrites() + { + // Steady-state: global already bound to signing-in user, no local override. + // This is the common case on every 'git fetch' after the first sign-in. + const string orgName = "org"; + const string user1 = "user1"; + + var git = new TestGit(); + var trace = new NullTrace(); + var manager = new AzureReposBindingManager(trace, git); + + git.Configuration.Global[CreateKey(orgName)] = new[] {user1}; + + manager.SignIn(orgName, user1); + + Assert.Equal(0, git.Configuration.SetCallCount); + Assert.Equal(0, git.Configuration.UnsetCallCount); + } + + [Fact] + public void AzureReposBindingManager_SignIn_OtherGlobalSameLocal_NoConfigWrites() + { + // Steady-state: a different user holds the global binding, and local is already + // bound to the signing-in user. No change needed. + const string orgName = "org"; + const string user1 = "user1"; + const string user2 = "user2"; + + var git = new TestGit(); + var trace = new NullTrace(); + var manager = new AzureReposBindingManager(trace, git); + + git.Configuration.Global[CreateKey(orgName)] = new[] {user2}; + git.Configuration.Local[CreateKey(orgName)] = new[] {user1}; + + manager.SignIn(orgName, user1); + + Assert.Equal(0, git.Configuration.SetCallCount); + Assert.Equal(0, git.Configuration.UnsetCallCount); + } + + [Fact] + public void AzureReposBindingManager_SignIn_SameGlobalSameLocal_OnlyUnbindsLocal() + { + // Global already matches, local redundantly mirrors it. + // Only the local unset is needed; re-writing the global value is wasteful. + const string orgName = "org"; + const string user1 = "user1"; + + var git = new TestGit(); + var trace = new NullTrace(); + var manager = new AzureReposBindingManager(trace, git); + + git.Configuration.Global[CreateKey(orgName)] = new[] {user1}; + git.Configuration.Local[CreateKey(orgName)] = new[] {user1}; + + manager.SignIn(orgName, user1); + + Assert.Equal(0, git.Configuration.SetCallCount); + Assert.Equal(1, git.Configuration.UnsetCallCount); + } + + [Fact] + public void AzureReposBindingManager_SignIn_SameGlobalOtherLocal_OnlyUnbindsLocal() + { + // Global already matches, local has a different user that needs removing. + // Only the local unset is needed; re-writing the global value is wasteful. + const string orgName = "org"; + const string user1 = "user1"; + const string user2 = "user2"; + + var git = new TestGit(); + var trace = new NullTrace(); + var manager = new AzureReposBindingManager(trace, git); + + git.Configuration.Global[CreateKey(orgName)] = new[] {user1}; + git.Configuration.Local[CreateKey(orgName)] = new[] {user2}; + + manager.SignIn(orgName, user1); + + Assert.Equal(0, git.Configuration.SetCallCount); + Assert.Equal(1, git.Configuration.UnsetCallCount); + } + #endregion #region SignOut diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index bfd14d14f..e05db1646 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -605,6 +605,248 @@ public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsM AzureDevOpsConstants.AzureDevOpsResourceId), Times.Once); } + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Generic_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "generic"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string assertion = "CLIENT-ASSERTION"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion] = assertion, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.Generic && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.GenericClientAssertion == assertion), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GenericFileAssertion_ReadsFromFile() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "generic"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string assertion = "CLIENT-ASSERTION-FROM-FILE"; + const string filePath = "/tmp/assertion-token.txt"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion] = $"file://{filePath}", + } + } + }; + + context.FileSystem.Files[filePath] = System.Text.Encoding.UTF8.GetBytes(assertion); + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.Generic && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.GenericClientAssertion == assertion), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_MI_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "managedidentity"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string managedIdentity = "22222222-2222-2222-2222-222222222222"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity] = managedIdentity, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.ManagedIdentity && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.ManagedIdentityId == managedIdentity), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GitHubActions_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "githubactions"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string ghRequestUrl = "https://token.actions.example.com/oidc/example?param=value"; + const string ghRequestToken = "OIDC-TOKEN"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [Constants.EnvironmentVariables.GitHubActionsTokenRequestUrl] = ghRequestUrl, + [Constants.EnvironmentVariables.GitHubActionsTokenRequestToken] = ghRequestToken, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.GitHubActions && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.GitHubTokenRequestUrl == new Uri(ghRequestUrl) && + fed.GitHubTokenRequestToken == ghRequestToken && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + [Fact] public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential() { diff --git a/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj b/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj index 1c673bcc9..e9cb3f16d 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj +++ b/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj @@ -1,20 +1,20 @@  - net8.0 + net10.0 false true latest - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs index a282d4eff..f9ec8ce0f 100644 --- a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs +++ b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs @@ -46,6 +46,12 @@ public static class EnvironmentVariables public const string ServicePrincipalCertificateThumbprint = "GCM_AZREPOS_SP_CERT_THUMBPRINT"; public const string ServicePrincipalCertificateSendX5C = "GCM_AZREPOS_SP_CERT_SEND_X5C"; public const string ManagedIdentity = "GCM_AZREPOS_MANAGEDIDENTITY"; + public const string WorkloadFederation = "GCM_AZREPOS_WIF"; + public const string WorkloadFederationClientId = "GCM_AZREPOS_WIF_CLIENTID"; + public const string WorkloadFederationTenantId = "GCM_AZREPOS_WIF_TENANTID"; + public const string WorkloadFederationAudience = "GCM_AZREPOS_WIF_AUDIENCE"; + public const string WorkloadFederationAssertion = "GCM_AZREPOS_WIF_ASSERTION"; + public const string WorkloadFederationManagedIdentity = "GCM_AZREPOS_WIF_MANAGEDIDENTITY"; } public static class GitConfiguration @@ -62,6 +68,12 @@ public static class Credential public const string ServicePrincipalCertificateThumbprint = "azreposServicePrincipalCertificateThumbprint"; public const string ServicePrincipalCertificateSendX5C = "azreposServicePrincipalCertificateSendX5C"; public const string ManagedIdentity = "azreposManagedIdentity"; + public const string WorkloadFederation = "azreposWorkloadFederation"; + public const string WorkloadFederationClientId = "azreposWorkloadFederationClientId"; + public const string WorkloadFederationTenantId = "azreposWorkloadFederationTenantId"; + public const string WorkloadFederationAudience = "azreposWorkloadFederationAudience"; + public const string WorkloadFederationAssertion = "azreposWorkloadFederationAssertion"; + public const string WorkloadFederationManagedIdentity = "azreposWorkloadFederationManagedIdentity"; } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs index 7e26590bd..2ae7fd11a 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs @@ -283,12 +283,24 @@ public static void SignIn(this IAzureReposBindingManager bindingManager, string if (existingBinding?.GlobalUserName != null && !StringComparer.OrdinalIgnoreCase.Equals(existingBinding.GlobalUserName, userName)) { - bindingManager.Bind(orgName, userName, local: true); + // Global is bound to a different user (B); bind this user locally (-> B | A). + // Skip the write if local is already correct. + if (!StringComparer.OrdinalIgnoreCase.Equals(existingBinding.LocalUserName, userName)) + { + bindingManager.Bind(orgName, userName, local: true); + } } else { - bindingManager.Bind(orgName, userName, local: false); - bindingManager.Unbind(orgName, local: true); + // Global is absent or already matches; ensure global is set and local is clear. + if (existingBinding?.GlobalUserName is null) + { + bindingManager.Bind(orgName, userName, local: false); + } + if (existingBinding?.LocalUserName is not null) + { + bindingManager.Unbind(orgName, local: true); + } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index 72eb378eb..9a916a236 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -85,6 +85,15 @@ public async Task GetCredentialAsync(InputArguments input) ); } + if (UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts)) + { + _context.Trace.WriteLine($"Getting Azure Access Token using WIF (scenario: {fedOpts.Scenario})..."); + var azureResult = await _msAuth.GetTokenUsingWorkloadFederationAsync(fedOpts, AzureDevOpsConstants.AzureDevOpsDefaultScopes); + return new GetCredentialResult( + new GitCredential(fedOpts.ClientId, azureResult.AccessToken) + ); + } + if (UseServicePrincipal(out ServicePrincipalIdentity sp)) { _context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}..."); @@ -137,6 +146,10 @@ public Task StoreCredentialAsync(InputArguments input) { _context.Trace.WriteLine("Nothing to store for managed identity authentication."); } + else if (UseWorkloadFederation(out _)) + { + _context.Trace.WriteLine("Nothing to store for federated identity authentication."); + } else if (UseServicePrincipal(out _)) { _context.Trace.WriteLine("Nothing to store for service principal authentication."); @@ -172,6 +185,10 @@ public Task EraseCredentialAsync(InputArguments input) { _context.Trace.WriteLine("Nothing to erase for managed identity authentication."); } + else if (UseWorkloadFederation(out _)) + { + _context.Trace.WriteLine("Nothing to erase for federated identity authentication."); + } else if (UseServicePrincipal(out _)) { _context.Trace.WriteLine("Nothing to erase for service principal authentication."); @@ -588,6 +605,160 @@ private bool UseManagedIdentity(out string mid) !string.IsNullOrWhiteSpace(mid); } + private bool UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts) + { + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederation, + out string wifStr)) + { + fedOpts = null; + return false; + } + + MicrosoftWorkloadFederationScenario scenario; + switch (wifStr.ToLowerInvariant()) + { + case "generic": + scenario = MicrosoftWorkloadFederationScenario.Generic; + break; + + case "mi": + case "managedidentity": + scenario = MicrosoftWorkloadFederationScenario.ManagedIdentity; + break; + + case "github": + case "githubactions": + scenario = MicrosoftWorkloadFederationScenario.GitHubActions; + break; + + default: // Unknown scenario value + fedOpts = null; + return false; + } + + bool hasClientId = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationClientId, + out string clientId); + + bool hasTenantId = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationTenantId, + out string tenantId); + + if (!hasClientId || !hasTenantId) + { + _context.Streams.Error.WriteLine("error: both client ID and tenant ID are required for workload federation"); + fedOpts = null; + return false; + } + + // Audience is optional - the default is "api://AzureADTokenExchange" + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAudience, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationAudience, + out string audience) || string.IsNullOrWhiteSpace(audience)) + { + audience = MicrosoftWorkloadFederationOptions.DefaultAudience; + } + + fedOpts = new MicrosoftWorkloadFederationOptions + { + Scenario = scenario, + ClientId = clientId, + TenantId = tenantId, + Audience = audience + }; + + switch (scenario) + { + case MicrosoftWorkloadFederationScenario.Generic: + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationAssertion, + out string assertion) || string.IsNullOrWhiteSpace(assertion)) + { + _context.Streams.Error.WriteLine("error: assertion is required for the generic workload federation scenario"); + fedOpts = null; + return false; + } + + // Check if this value points to a file containing the actual assertion (file://) + if (Uri.TryCreate(assertion, UriKind.Absolute, out Uri assertionUri) + && StringComparer.OrdinalIgnoreCase.Equals(assertionUri.Scheme, "file")) + { + string filePath = assertionUri.LocalPath; + if (!_context.FileSystem.FileExists(filePath)) + { + _context.Streams.Error.WriteLine($"error: assertion file not found: {filePath}"); + fedOpts = null; + return false; + } + + _context.Trace.WriteLine($"Reading workload federation assertion from file '{filePath}'..."); + assertion = _context.FileSystem.ReadAllText(filePath).Trim(); + if (string.IsNullOrWhiteSpace(assertion)) + { + _context.Streams.Error.WriteLine($"error: assertion file is empty: {filePath}"); + fedOpts = null; + return false; + } + } + + fedOpts.GenericClientAssertion = assertion; + break; + + case MicrosoftWorkloadFederationScenario.ManagedIdentity: + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationManagedIdentity, + out string managedIdentity) || string.IsNullOrWhiteSpace(managedIdentity)) + { + _context.Streams.Error.WriteLine("error: managed identity is required for the managed identity workload federation scenario"); + fedOpts = null; + return false; + } + + fedOpts.ManagedIdentityId = managedIdentity; + break; + + case MicrosoftWorkloadFederationScenario.GitHubActions: + if (!_context.Environment.Variables.TryGetValue( + Constants.EnvironmentVariables.GitHubActionsTokenRequestUrl, out string tokenRequestUrl) + || !Uri.TryCreate(tokenRequestUrl, UriKind.Absolute, out Uri tokenRequestUri)) + { + _context.Streams.Error.WriteLine( + "error: unable to get valid token request URL from environment variable for the GitHub Actions workload federation scenario"); + fedOpts = null; + return false; + } + + if (!_context.Environment.Variables.TryGetValue( + Constants.EnvironmentVariables.GitHubActionsTokenRequestToken, out string tokenRequestToken) + || string.IsNullOrWhiteSpace(tokenRequestToken)) + { + _context.Streams.Error.WriteLine( + "error: unable to get valid token request token from environment variable for the GitHub Actions workload federation scenario"); + fedOpts = null; + return false; + } + + fedOpts.GitHubTokenRequestUrl = tokenRequestUri; + fedOpts.GitHubTokenRequestToken = tokenRequestToken; + break; + } + + return true; + } + #endregion #region IConfigurationComponent diff --git a/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj b/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj index eaf866bfa..73b7dedff 100644 --- a/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj +++ b/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 Microsoft.AzureRepos Microsoft.AzureRepos false diff --git a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs index 11dff8f1f..57a75f2b8 100644 --- a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs +++ b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs @@ -11,6 +11,7 @@ public class TestFileSystem : IFileSystem public string UserHomePath { get; set; } public string UserDataDirectoryPath { get; set; } public IDictionary Files { get; set; } = new Dictionary(); + public ISet ExecutableFiles { get; } = new HashSet(); public ISet Directories { get; set; } = new HashSet(); public string CurrentDirectory { get; set; } = Path.GetTempPath(); public bool IsCaseSensitive { get; set; } = false; @@ -36,6 +37,18 @@ bool IFileSystem.FileExists(string path) return Files.ContainsKey(path); } + bool IFileSystem.FileIsExecutable(string path) + { + if (!Files.ContainsKey(path)) + throw new FileNotFoundException("File not found", path); + + // On Windows, all files are considered executable. + if (!PlatformUtils.IsPosix()) + return true; + + return ExecutableFiles.Contains(path); + } + bool IFileSystem.DirectoryExists(string path) { return Directories.Contains(TrimSlash(path)); @@ -130,6 +143,20 @@ string[] IFileSystem.ReadAllLines(string path) #endregion + /// + /// Mark a test file as executable. File must exist in already. + /// + public void SetExecutable(string path, bool isExecutable = true) + { + if (!Files.ContainsKey(path)) + throw new FileNotFoundException("File not found", path); + + if (isExecutable) + ExecutableFiles.Add(path); + else + ExecutableFiles.Remove(path); + } + /// /// Trim trailing slashes from a path. /// diff --git a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs index 517a8c7b8..6a887a97c 100644 --- a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs +++ b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs @@ -16,6 +16,9 @@ public class TestGitConfiguration : IGitConfiguration public IDictionary> Local { get; set; } = new Dictionary>(GitConfigurationKeyComparer.Instance); + public int SetCallCount { get; private set; } + public int UnsetCallCount { get; private set; } + #region IGitConfiguration public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) @@ -68,6 +71,7 @@ public bool TryGet(GitConfigurationLevel level, GitConfigurationType type, strin public void Set(GitConfigurationLevel level, string name, string value) { + SetCallCount++; IDictionary> dict = GetDictionary(level); if (!dict.TryGetValue(name, out var values)) @@ -107,6 +111,7 @@ public void Add(GitConfigurationLevel level, string name, string value) public void Unset(GitConfigurationLevel level, string name) { + UnsetCallCount++; IDictionary> dict = GetDictionary(level); // Simulate git diff --git a/src/shared/TestInfrastructure/TestInfrastructure.csproj b/src/shared/TestInfrastructure/TestInfrastructure.csproj index 63f6fee89..9c3e96e5e 100644 --- a/src/shared/TestInfrastructure/TestInfrastructure.csproj +++ b/src/shared/TestInfrastructure/TestInfrastructure.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 GitCredentialManager.Tests false false @@ -9,10 +9,10 @@ - - - - + + + + diff --git a/src/windows/Installer.Windows/Installer.Windows.csproj b/src/windows/Installer.Windows/Installer.Windows.csproj index ec678fe5f..b625be44d 100644 --- a/src/windows/Installer.Windows/Installer.Windows.csproj +++ b/src/windows/Installer.Windows/Installer.Windows.csproj @@ -14,7 +14,6 @@ false false $(PlatformOutPath)Installer.Windows\bin\$(Configuration)\net472\$(RuntimeIdentifier)\ - 6.3.1 false @@ -29,7 +28,7 @@ - + @@ -37,14 +36,14 @@ - "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=system /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" - "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=user /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" + "$(PkgTools_InnoSetup)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=system /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" + "$(PkgTools_InnoSetup)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=user /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" diff --git a/src/windows/Installer.Windows/layout.ps1 b/src/windows/Installer.Windows/layout.ps1 index 3b1624896..53646764a 100644 --- a/src/windows/Installer.Windows/layout.ps1 +++ b/src/windows/Installer.Windows/layout.ps1 @@ -3,7 +3,10 @@ param ([Parameter(Mandatory)] $Configuration, [Parameter(Mandatory)] $Output, $R # Trim trailing slashes from output paths $Output = $Output.TrimEnd('\','/') -$SymbolOutput = $SymbolOutput.TrimEnd('\','/') + +if ($SymbolOutput) { + $SymbolOutput = $SymbolOutput.TrimEnd('\','/') +} Write-Output "Output: $Output"