diff --git a/.azure-pipelines/nuget.config b/.azure-pipelines/nuget.config new file mode 100644 index 0000000000..0cdfa50d8e --- /dev/null +++ b/.azure-pipelines/nuget.config @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index a957609d89..7b93478284 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -134,6 +134,15 @@ extends: artifactName: '${{ dim.runtime }}' steps: - checkout: self + - task: CopyFiles@2 + displayName: 'Use Central Feed Services (CFS)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)\.azure-pipelines' + Contents: 'nuget.config' + TargetFolder: '$(Build.SourcesDirectory)' + Overwrite: true + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to NuGet feeds' - task: PowerShell@2 displayName: 'Read version file' inputs: @@ -295,6 +304,15 @@ extends: artifactName: '${{ dim.runtime }}' steps: - checkout: self + - task: CopyFiles@2 + displayName: 'Use Central Feed Services (CFS)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines' + Contents: 'nuget.config' + TargetFolder: '$(Build.SourcesDirectory)' + Overwrite: true + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to NuGet feeds' - task: Bash@3 displayName: 'Read version file' inputs: @@ -570,6 +588,15 @@ extends: artifactName: '${{ dim.runtime }}' steps: - checkout: self + - task: CopyFiles@2 + displayName: 'Use Central Feed Services (CFS)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines' + Contents: 'nuget.config' + TargetFolder: '$(Build.SourcesDirectory)' + Overwrite: true + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to NuGet feeds' - task: Bash@3 displayName: 'Read version file' inputs: @@ -639,7 +666,7 @@ extends: inlineOperation: | [ { - "KeyCode": "CP-453387-Pgp", + "KeyCode": "CP-500207-Pgp", "OperationCode": "LinuxSign", "ToolName": "sign", "ToolVersion": "1.0", @@ -673,6 +700,15 @@ extends: artifactName: 'dotnet-tool' steps: - checkout: self + - task: CopyFiles@2 + displayName: 'Use Central Feed Services (CFS)' + inputs: + SourceFolder: '$(Build.SourcesDirectory)\.azure-pipelines' + Contents: 'nuget.config' + TargetFolder: '$(Build.SourcesDirectory)' + Overwrite: true + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to NuGet feeds' - task: PowerShell@2 displayName: 'Read version file' inputs: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 943e7c465b..72d5418879 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,10 +22,10 @@ jobs: language: [ 'csharp' ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 1d4488405e..9997aa42ae 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -25,10 +25,10 @@ jobs: os: windows-11-arm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x @@ -82,10 +82,10 @@ jobs: runtime: [ linux-x64, linux-arm64, linux-arm ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x @@ -126,10 +126,10 @@ jobs: runtime: [ osx-x64, osx-arm64 ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index bfbd2bbfaf..ff64b9bcd2 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -18,7 +18,7 @@ jobs: name: Lint markdown files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 with: @@ -30,7 +30,7 @@ jobs: name: Check for broken links runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run link checker # For any troubleshooting, see: diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index 85c821eea4..dca6f56b46 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -45,7 +45,7 @@ jobs: GNUPGHOME=/root/.gnupg tdnf install tar -y # needed for `actions/checkout` fi - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - run: | sh "${GITHUB_WORKSPACE}/src/linux/Packaging.Linux/install-from-source.sh" -y diff --git a/Directory.Packages.props b/Directory.Packages.props index d1e002d856..3e71e110a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,9 +14,9 @@ - - - + + + diff --git a/Git-Credential-Manager.sln b/Git-Credential-Manager.sln index a883e760ed..a1c80d9e7b 100644 --- a/Git-Credential-Manager.sln +++ b/Git-Credential-Manager.sln @@ -15,6 +15,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AzureRepos", "src EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AzureRepos.Tests", "src\shared\Microsoft.AzureRepos.Tests\Microsoft.AzureRepos.Tests.csproj", "{97DC6241-1240-4A85-8035-F8404A983A82}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.ManagedApps", "src\shared\Microsoft.ManagedApps\Microsoft.ManagedApps.csproj", "{AD948E97-7A7D-4364-AF28-3E4341D72026}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.ManagedApps.Tests", "src\shared\Microsoft.ManagedApps.Tests\Microsoft.ManagedApps.Tests.csproj", "{9EE65F46-3FFA-4886-ACED-998C1597271E}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "windows", "windows", "{66722747-1B61-40E4-A89B-1AC8E6D62EA9}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestInfrastructure", "src\shared\TestInfrastructure\TestInfrastructure.csproj", "{5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}" @@ -135,6 +139,38 @@ Global {97DC6241-1240-4A85-8035-F8404A983A82}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU {97DC6241-1240-4A85-8035-F8404A983A82}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU {97DC6241-1240-4A85-8035-F8404A983A82}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.MacDebug|Any CPU.Build.0 = Debug|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.MacRelease|Any CPU.Build.0 = Release|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.Release|Any CPU.Build.0 = Release|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {AD948E97-7A7D-4364-AF28-3E4341D72026}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.MacDebug|Any CPU.Build.0 = Debug|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.MacRelease|Any CPU.Build.0 = Release|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.Release|Any CPU.Build.0 = Release|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {9EE65F46-3FFA-4886-ACED-998C1597271E}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.Debug|Any CPU.Build.0 = Debug|Any CPU {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU @@ -287,6 +323,8 @@ Global {AD41FA1E-51F5-4E4F-B7DA-32F921491313} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {714AF9EB-44E6-4058-BD3E-9039F29F4D7A} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {97DC6241-1240-4A85-8035-F8404A983A82} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} + {AD948E97-7A7D-4364-AF28-3E4341D72026} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} + {9EE65F46-3FFA-4886-ACED-998C1597271E} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {66722747-1B61-40E4-A89B-1AC8E6D62EA9} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E} {5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {3C840B06-A595-4FD9-9A76-56CD45B14780} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} diff --git a/VERSION b/VERSION index 0ab902011a..111f6e3a6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.0.0 +2.9.1.0 diff --git a/docs/credstores.md b/docs/credstores.md index ca76f56926..4d54059e60 100644 --- a/docs/credstores.md +++ b/docs/credstores.md @@ -277,7 +277,7 @@ Note that you'll want to ensure that another credential helper is placed before GCM in the `credential.helper` Git configuration or else you will be prompted to enter your credentials every time you interact with a remote repository. -[access-windows-credential-manager]: https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0 +[access-windows-credential-manager]: https://support.microsoft.com/en-US/Windows/Security/credential-manager-in-windows [aws-cloudshell]: https://aws.amazon.com/cloudshell/ [azure-cloudshell]: https://docs.microsoft.com/azure/cloud-shell/overview [cmdkey]: https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/cmdkey diff --git a/docs/generic-oauth.md b/docs/generic-oauth.md index 92ad6dc5cc..dbf5c06fbb 100644 --- a/docs/generic-oauth.md +++ b/docs/generic-oauth.md @@ -42,6 +42,7 @@ following values in your Git configuration: - Client Secret (optional) - Redirect URL (optional, defaults to `http://127.0.0.1`) - Scopes (optional) +- Response Mode (optional, defaults to `query`) - OAuth Endpoints - Authorization Endpoint - Token Endpoint @@ -62,6 +63,7 @@ git config --global credential..oauthAuthorizeEndpoint git config --global credential..oauthTokenEndpoint git config --global credential..oauthScopes git config --global credential..oauthDeviceEndpoint +git config --global credential..oauthResponseMode ``` **Example commands:** @@ -83,6 +85,7 @@ git config --global credential..oauthDeviceEndpoint oauthScopes = "code:write profile:read" oauthDefaultUserName = "OAUTH" oauthUseClientAuthHeader = false + oauthResponseMode = "query" ``` ### Additional configuration @@ -90,6 +93,27 @@ git config --global credential..oauthDeviceEndpoint Depending on the specific implementation of OAuth with your Git host you may also need to specify additional behavior. +#### Response mode + +The response mode controls how the authorization server returns the response to +the loopback redirect URI once the user has authenticated. GCM supports the +following values: + +- `query` (default) - parameters are returned in the redirect URI query string. +- `fragment` - parameters are returned in the redirect URI fragment. +- `form_post` - parameters are returned as an auto-submitting HTML form that is + POSTed to the redirect URI, as described by the + [OAuth 2.0 Form Post Response Mode][form-post-spec] specification. + +Most hosts use the default `query` mode. Only set this if your host requires a +specific response mode: + +```shell +git config --global credential..oauthResponseMode +``` + +[form-post-spec]: https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html + #### Token user name If your Git host requires that you specify a username to use with OAuth tokens diff --git a/docs/github-apideprecation.md b/docs/github-apideprecation.md index 6a54a7a401..7075085d29 100644 --- a/docs/github-apideprecation.md +++ b/docs/github-apideprecation.md @@ -143,6 +143,6 @@ the new token-based authentication requirements **DO NOT** apply to GHES: [windows-cli-save-pat-image]: img/windows-cli-save-pat.png [vs-2019]: https://docs.microsoft.com/en-us/visualstudio/install/update-visual-studio?view=vs-2019 [vs-2017]: https://docs.microsoft.com/en-us/visualstudio/install/update-visual-studio?view=vs-2017 -[windows-credential-manager]: https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0 +[windows-credential-manager]: https://support.microsoft.com/en-US/Windows/Security/credential-manager-in-windows [windows-gui-add-pat-image]: img/windows-gui-add-pat.png [windows-gui-credentials-image]: img/windows-gui-credentials.png diff --git a/docs/linux-validate-gpg.md b/docs/linux-validate-gpg.md index 49150c1e59..d19caae96e 100644 --- a/docs/linux-validate-gpg.md +++ b/docs/linux-validate-gpg.md @@ -10,46 +10,42 @@ the latest Debian package and/or tarball signature. apt-get install -y curl debsig-verify # Download public key signature file -curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ -| grep -E 'browser_download_url.*gcm-public.asc' \ -| cut -d : -f 2,3 \ -| tr -d \" \ -| xargs -I 'url' curl -L -o gcm-public.asc 'url' +curl -Os https://packages.microsoft.com/keys/microsoft-2025.asc # De-armor public key signature file -gpg --output gcm-public.gpg --dearmor gcm-public.asc +gpg --output microsoft-2025.gpg --dearmor microsoft-2025.asc -# Note that the fingerprint of this key is "3C853823978B07FA", which you can +# Note that the fingerprint of this key is "EE4D7792F748182B", which you can # determine by running: -gpg --show-keys gcm-public.asc | head -n 2 | tail -n 1 | tail -c 17 +gpg --show-keys microsoft-2025.asc | head -n 2 | tail -n 1 | tail -c 17 # Copy de-armored public key to debsig keyring folder -mkdir /usr/share/debsig/keyrings/3C853823978B07FA -mv gcm-public.gpg /usr/share/debsig/keyrings/3C853823978B07FA/ +mkdir /usr/share/debsig/keyrings/EE4D7792F748182B +mv microsoft-2025.gpg /usr/share/debsig/keyrings/EE4D7792F748182B/ # Create an appropriate policy file -mkdir /etc/debsig/policies/3C853823978B07FA -cat > /etc/debsig/policies/3C853823978B07FA/generic.pol << EOL +mkdir /etc/debsig/policies/EE4D7792F748182B +cat > /etc/debsig/policies/EE4D7792F748182B/generic.pol << EOL - + - + - + EOL -# Download Debian package +# Download Debian package (substitute `x64` with `arm64` on ARM machines) curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ -| grep "browser_download_url.*deb" \ +| grep "browser_download_url.*-x64-.*deb" \ | cut -d : -f 2,3 \ | tr -d \" \ | xargs -I 'url' curl -L -o gcm.deb 'url' @@ -61,24 +57,20 @@ debsig-verify gcm.deb ## Tarball ```shell # Download the public key signature file -curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ -| grep -E 'browser_download_url.*gcm-public.asc' \ -| cut -d : -f 2,3 \ -| tr -d \" \ -| xargs -I 'url' curl -L -o gcm-public.asc 'url' +curl -Os https://packages.microsoft.com/keys/microsoft-2025.asc # Import the public key -gpg --import gcm-public.asc +gpg --import microsoft-2025.asc # Download the tarball and its signature file -curl -s https://api.github.com/repos/ldennington/git-credential-manager/releases/latest \ +curl -s https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest \ | grep -E 'browser_download_url.*gcm-linux.*[0-9].[0-9].[0-9].tar.gz' \ | cut -d : -f 2,3 \ | tr -d \" \ | xargs -I 'url' curl -LO 'url' # Trust the public key -echo -e "5\ny\n" | gpg --command-fd 0 --expert --edit-key 3C853823978B07FA trust +echo -e "5\ny\n" | gpg --command-fd 0 --expert --edit-key EE4D7792F748182B trust # Verify the signature gpg --verify gcm-linux_amd64*.tar.gz.asc gcm-linux*.tar.gz diff --git a/global.json b/global.json index 5cc6b13a63..d9483139eb 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "rollForward": "latestMajor", - "version": "8.0" + "version": "8.0.100" } } diff --git a/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs b/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs index 1a6866fb63..e57caf6931 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/Cloud/BitbucketOAuth2ClientTest.cs @@ -36,7 +36,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode() Bitbucket.Cloud.BitbucketOAuth2Client client = GetBitbucketOAuth2Client(); - MockGetAuthenticationCodeAsync(finalCallbackUri, null, client.Scopes); + MockGetAuthenticationResponseAsync(finalCallbackUri, null, client.Scopes); MockCodeGenerator(); @@ -56,7 +56,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_RespectsClient Bitbucket.Cloud.BitbucketOAuth2Client client = GetBitbucketOAuth2Client(); - MockGetAuthenticationCodeAsync(finalCallbackUri, clientId, client.Scopes); + MockGetAuthenticationResponseAsync(finalCallbackUri, clientId, client.Scopes); MockCodeGenerator(); @@ -115,7 +115,7 @@ private void MockCodeGenerator() codeGenerator.Setup(c => c.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, pkceCodeVerifier)).Returns(pkceCodeChallenge); } - private void MockGetAuthenticationCodeAsync(Uri finalCallbackUri, string overrideClientId, IEnumerable scopes) + private void MockGetAuthenticationResponseAsync(Uri finalCallbackUri, string overrideClientId, IEnumerable scopes) { var authorizationUri = new UriBuilder(CloudConstants.OAuth2AuthorizationEndpoint) { @@ -128,7 +128,8 @@ private void MockGetAuthenticationCodeAsync(Uri finalCallbackUri, string overrid + "&scope=" + WebUtility.UrlEncode(string.Join(" ", scopes)).ToLower() }.Uri; - browser.Setup(b => b.GetAuthenticationCodeAsync(authorizationUri, rootCallbackUri, ct)).Returns(Task.FromResult(finalCallbackUri)); + browser.Setup(b => b.GetAuthenticationResponseAsync(authorizationUri, rootCallbackUri, OAuth2ResponseMode.Default, ct)) + .Returns(Task.FromResult(finalCallbackUri.GetQueryParameters())); } private Uri MockFinalCallbackUri() diff --git a/src/shared/Atlassian.Bitbucket.Tests/DataCenter/BitbucketOAuth2ClientTest.cs b/src/shared/Atlassian.Bitbucket.Tests/DataCenter/BitbucketOAuth2ClientTest.cs index e2e7225db3..5931a6a0c7 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/DataCenter/BitbucketOAuth2ClientTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/DataCenter/BitbucketOAuth2ClientTest.cs @@ -37,7 +37,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode() var client = GetBitbucketOAuth2Client(); - MockGetAuthenticationCodeAsync(remoteUrl, rootCallbackUri, finalCallbackUri, clientId, client.Scopes); + MockGetAuthenticationResponseAsync(remoteUrl, rootCallbackUri, finalCallbackUri, clientId, client.Scopes); MockCodeGenerator(); @@ -58,7 +58,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode_Wh var client = GetBitbucketOAuth2Client(); - MockGetAuthenticationCodeAsync(remoteUrl, new Uri(rootCallbackUrl), finalCallbackUri, clientId, client.Scopes); + MockGetAuthenticationResponseAsync(remoteUrl, new Uri(rootCallbackUrl), finalCallbackUri, clientId, client.Scopes); MockCodeGenerator(); @@ -90,7 +90,7 @@ private void MockCodeGenerator() codeGenerator.Setup(c => c.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, pkceCodeVerifier)).Returns(pkceCodeChallenge); } - private void MockGetAuthenticationCodeAsync(string url, Uri redirectUri, Uri finalCallbackUri, string overrideClientId, IEnumerable scopes) + private void MockGetAuthenticationResponseAsync(string url, Uri redirectUri, Uri finalCallbackUri, string overrideClientId, IEnumerable scopes) { var authorizationUri = new UriBuilder(url + "/rest/oauth2/latest/authorize") { @@ -103,7 +103,8 @@ private void MockGetAuthenticationCodeAsync(string url, Uri redirectUri, Uri fin + "&scope=" + WebUtility.UrlEncode(string.Join(" ", scopes)).ToUpper() }.Uri; - browser.Setup(b => b.GetAuthenticationCodeAsync(authorizationUri, redirectUri, ct)).Returns(Task.FromResult(finalCallbackUri)); + browser.Setup(b => b.GetAuthenticationResponseAsync(authorizationUri, redirectUri, OAuth2ResponseMode.Default, ct)) + .Returns(Task.FromResult(finalCallbackUri.GetQueryParameters())); } private Uri MockFinalCallbackUri(Uri redirectUri) diff --git a/src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs b/src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs index be660b99bb..1ec3eae251 100644 --- a/src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs +++ b/src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs @@ -174,6 +174,89 @@ await Assert.ThrowsAsync(() => client.GetAuthorizationCodeAsync(expectedScopes, browser, extraParams, CancellationToken.None)); } + [Theory] + [InlineData(OAuth2ResponseMode.Query, "query")] + [InlineData(OAuth2ResponseMode.Fragment, "fragment")] + [InlineData(OAuth2ResponseMode.FormPost, "form_post")] + public async Task OAuth2Client_GetAuthorizationCodeAsync_NonDefaultResponseMode_SendsResponseModeParameter( + OAuth2ResponseMode responseMode, string expectedValue) + { + const string expectedAuthCode = "68c39cbd8d"; + + var baseUri = new Uri("https://example.com"); + OAuth2ServerEndpoints endpoints = CreateEndpoints(baseUri); + + var httpHandler = new TestHttpMessageHandler {ThrowOnUnexpectedRequest = true}; + + string[] expectedScopes = {"read", "write", "delete"}; + + OAuth2Application app = CreateTestApplication(); + + var server = new TestOAuth2Server(endpoints); + server.RegisterApplication(app); + server.Bind(httpHandler); + server.TokenGenerator.AuthCodes.Add(expectedAuthCode); + + server.AuthorizationEndpointInvoked += (_, request) => + { + IDictionary actualParams = request.RequestUri.GetQueryParameters(); + Assert.True(actualParams.TryGetValue( + OAuth2Constants.AuthorizationEndpoint.ResponseModeParameter, out string actualMode)); + Assert.Equal(expectedValue, actualMode); + }; + + IOAuth2WebBrowser browser = new TestOAuth2WebBrowser(httpHandler); + + var trace2 = new NullTrace2(); + OAuth2Client client = new OAuth2Client( + new HttpClient(httpHandler), endpoints, TestClientId, trace2, + TestRedirectUri, TestClientSecret, responseMode: responseMode); + + OAuth2AuthorizationCodeResult result = await client.GetAuthorizationCodeAsync( + expectedScopes, browser, null, CancellationToken.None); + + Assert.Equal(expectedAuthCode, result.Code); + } + + [Fact] + public async Task OAuth2Client_GetAuthorizationCodeAsync_DefaultResponseMode_OmitsResponseModeParameter() + { + const string expectedAuthCode = "68c39cbd8d"; + + var baseUri = new Uri("https://example.com"); + OAuth2ServerEndpoints endpoints = CreateEndpoints(baseUri); + + var httpHandler = new TestHttpMessageHandler {ThrowOnUnexpectedRequest = true}; + + string[] expectedScopes = {"read", "write", "delete"}; + + OAuth2Application app = CreateTestApplication(); + + var server = new TestOAuth2Server(endpoints); + server.RegisterApplication(app); + server.Bind(httpHandler); + server.TokenGenerator.AuthCodes.Add(expectedAuthCode); + + server.AuthorizationEndpointInvoked += (_, request) => + { + IDictionary actualParams = request.RequestUri.GetQueryParameters(); + Assert.False(actualParams.ContainsKey( + OAuth2Constants.AuthorizationEndpoint.ResponseModeParameter)); + }; + + IOAuth2WebBrowser browser = new TestOAuth2WebBrowser(httpHandler); + + var trace2 = new NullTrace2(); + OAuth2Client client = new OAuth2Client( + new HttpClient(httpHandler), endpoints, TestClientId, trace2, + TestRedirectUri, TestClientSecret, responseMode: OAuth2ResponseMode.Default); + + OAuth2AuthorizationCodeResult result = await client.GetAuthorizationCodeAsync( + expectedScopes, browser, null, CancellationToken.None); + + Assert.Equal(expectedAuthCode, result.Code); + } + [Fact] public async Task OAuth2Client_GetDeviceCodeAsync() { diff --git a/src/shared/Core.Tests/Authentication/OAuth2ResponseModeTests.cs b/src/shared/Core.Tests/Authentication/OAuth2ResponseModeTests.cs new file mode 100644 index 0000000000..a52bf23508 --- /dev/null +++ b/src/shared/Core.Tests/Authentication/OAuth2ResponseModeTests.cs @@ -0,0 +1,42 @@ +using GitCredentialManager.Authentication.OAuth; +using Xunit; + +namespace GitCredentialManager.Tests.Authentication; + +public class OAuth2ResponseModeTests +{ + [Theory] + [InlineData(OAuth2ResponseMode.Default, null)] + [InlineData(OAuth2ResponseMode.Query, "query")] + [InlineData(OAuth2ResponseMode.Fragment, "fragment")] + [InlineData(OAuth2ResponseMode.FormPost, "form_post")] + public void OAuth2ResponseMode_GetParameterValue(OAuth2ResponseMode mode, string expected) + { + Assert.Equal(expected, mode.GetParameterValue()); + } + + [Theory] + [InlineData("query", OAuth2ResponseMode.Query)] + [InlineData("Query", OAuth2ResponseMode.Query)] + [InlineData("fragment", OAuth2ResponseMode.Fragment)] + [InlineData("FRAGMENT", OAuth2ResponseMode.Fragment)] + [InlineData("form_post", OAuth2ResponseMode.FormPost)] + [InlineData("FORM_POST", OAuth2ResponseMode.FormPost)] + [InlineData("formpost", OAuth2ResponseMode.FormPost)] + public void OAuth2ResponseMode_TryParse_Valid(string value, OAuth2ResponseMode expected) + { + Assert.True(OAuth2ResponseModeExtensions.TryParse(value, out OAuth2ResponseMode actual)); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("web_message")] + [InlineData("unknown")] + public void OAuth2ResponseMode_TryParse_Invalid_ReturnsFalse(string value) + { + Assert.False(OAuth2ResponseModeExtensions.TryParse(value, out _)); + } +} diff --git a/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs b/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs index cea6abe17f..9274845a2f 100644 --- a/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs +++ b/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs @@ -63,4 +63,20 @@ public void OAuth2SystemWebBrowser_UpdateRedirectUri_AnyPort(string input) ); Assert.False(actualUri.IsDefaultPort); } + + [Theory] + [InlineData("application/x-www-form-urlencoded", true)] + [InlineData("application/x-www-form-urlencoded; charset=utf-8", true)] + [InlineData("application/x-www-form-urlencoded;charset=UTF-8", true)] + [InlineData("APPLICATION/X-WWW-FORM-URLENCODED", true)] + [InlineData(" application/x-www-form-urlencoded ; charset=utf-8 ", true)] + [InlineData("application/json", false)] + [InlineData("text/plain; charset=utf-8", false)] + [InlineData("multipart/form-data; boundary=----abc", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void OAuth2SystemWebBrowser_IsFormUrlEncoded(string contentType, bool expected) + { + Assert.Equal(expected, OAuth2SystemWebBrowser.IsFormUrlEncoded(contentType)); + } } diff --git a/src/shared/Core.Tests/GenericOAuthConfigTests.cs b/src/shared/Core.Tests/GenericOAuthConfigTests.cs index b05ae2e8b3..cd1f1573fe 100644 --- a/src/shared/Core.Tests/GenericOAuthConfigTests.cs +++ b/src/shared/Core.Tests/GenericOAuthConfigTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using GitCredentialManager.Authentication.OAuth; using GitCredentialManager.Tests.Objects; using Xunit; @@ -99,5 +100,75 @@ public void GenericOAuthConfig_TryGet_Gitea() Assert.Equal(expectedAuthzEndpoint, config.Endpoints.AuthorizationEndpoint); Assert.Equal(expectedTokenEndpoint, config.Endpoints.TokenEndpoint); } + + [Theory] + [InlineData("query", OAuth2ResponseMode.Query)] + [InlineData("fragment", OAuth2ResponseMode.Fragment)] + [InlineData("form_post", OAuth2ResponseMode.FormPost)] + [InlineData("FORM_POST", OAuth2ResponseMode.FormPost)] + public void GenericOAuthConfig_TryGet_ParsesResponseMode(string value, OAuth2ResponseMode expected) + { + bool result = TryGetWithResponseMode(value, out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(expected, config.ResponseMode); + } + + [Fact] + public void GenericOAuthConfig_TryGet_InvalidResponseMode_FallsBackToDefault() + { + bool result = TryGetWithResponseMode("bogus", out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(OAuth2ResponseMode.Default, config.ResponseMode); + } + + [Fact] + public void GenericOAuthConfig_TryGet_ResponseModeUnset_UsesDefault() + { + bool result = TryGetWithResponseMode(null, out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(OAuth2ResponseMode.Default, config.ResponseMode); + } + + private static bool TryGetWithResponseMode(string responseMode, out GenericOAuthConfig config) + { + const string protocol = "https"; + const string host = "example.com"; + var remoteUri = new Uri($"{protocol}://{host}"); + + string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}"; + + var trace = new NullTrace(); + var gitConfig = new TestGitConfiguration + { + Global = + { + [GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { "client-id" }, + [GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { "/oauth/authorize" }, + [GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { "/oauth/token" }, + } + }; + + if (responseMode != null) + { + gitConfig.Global[GetKey(Constants.GitConfiguration.Credential.OAuthResponseMode)] = new[] { responseMode }; + } + + var settings = new TestSettings + { + GitConfiguration = gitConfig, + RemoteUri = remoteUri + }; + + var input = new InputArguments(new Dictionary + { + {"protocol", protocol}, + {"host", host}, + }); + + return GenericOAuthConfig.TryGet(trace, settings, input, out config); + } } } diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 5d65fa9823..86b0feff08 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -30,7 +30,7 @@ public interface IMicrosoftAuthentication /// /// Azure authority. /// Client ID. - /// Redirect URI for the client. + /// Redirect URI for the client. Use null for the default redirect URI. /// Set of scopes to request. /// Optional user name for an existing account. /// Use MSA-Passthrough behavior when authenticating. @@ -116,10 +116,9 @@ public interface IMicrosoftAuthenticationResult public enum MicrosoftAuthenticationFlowType { - Auto = 0, - EmbeddedWebView = 1, - SystemWebView = 2, - DeviceCode = 3 + EmbeddedWebView, + SystemWebView, + DeviceCode } public class MicrosoftAuthentication : AuthenticationBase, IMicrosoftAuthentication @@ -152,6 +151,27 @@ public async Task GetTokenForUserAsync( Context.Trace.WriteLine("MSA passthrough is enabled."); } + // Check if the user has specified a particular type of authentication flow + MicrosoftAuthenticationFlowType flowType = GetFlowType(redirectUri); + Context.Trace.WriteLine($"Flow type is: '{flowType}'."); + + // If we are going to use anything *other than* the system webview, we ignore + // the provided redirect URI and set it to the default for a native client. + // The broker is used above all else, if enabled, but that has a fallback to + // the system browser if there is a problem. + // We must continue to pass through the provided redirect URI if we're going to + // try the system webview, as the system webview requires a real loopback redirect + // URI that is registered with the application. + if (!useBroker && flowType != MicrosoftAuthenticationFlowType.SystemWebView) + { + Context.Trace.WriteLine("Using default redirect URI."); + redirectUri = null; // null to signal the default redirect URI + } + else + { + Context.Trace.WriteLine($"Redirect URI is '{redirectUri}'."); + } + try { // Create the public client application for authentication @@ -214,27 +234,17 @@ public async Task GetTokenForUserAsync( Context.Trace.WriteLine("Performing interactive auth with broker..."); result = await app.AcquireTokenInteractive(scopes) .WithPrompt(Prompt.SelectAccount) - // We must configure the system webview as a fallback + // We must configure the system webview as a fallback in case + // the broker is not available on this system. .WithSystemWebViewOptions(GetSystemWebViewOptions()) .ExecuteAsync(); } } else { - // Check for a user flow preference if they've specified one - MicrosoftAuthenticationFlowType flowType = GetFlowType(); + // Respect the user's flow preference switch (flowType) { - case MicrosoftAuthenticationFlowType.Auto: - if (CanUseEmbeddedWebView()) - goto case MicrosoftAuthenticationFlowType.EmbeddedWebView; - - if (CanUseSystemWebView(app, redirectUri)) - goto case MicrosoftAuthenticationFlowType.SystemWebView; - - // Fall back to device code flow - goto case MicrosoftAuthenticationFlowType.DeviceCode; - case MicrosoftAuthenticationFlowType.EmbeddedWebView: Context.Trace.WriteLine("Performing interactive auth with embedded web view..."); EnsureCanUseEmbeddedWebView(); @@ -247,7 +257,7 @@ public async Task GetTokenForUserAsync( case MicrosoftAuthenticationFlowType.SystemWebView: Context.Trace.WriteLine("Performing interactive auth with system web view..."); - EnsureCanUseSystemWebView(app, redirectUri); + EnsureCanUseSystemWebView(redirectUri); result = await app.AcquireTokenInteractive(scopes) .WithPrompt(Prompt.SelectAccount) .WithSystemWebViewOptions(GetSystemWebViewOptions()) @@ -263,7 +273,7 @@ public async Task GetTokenForUserAsync( break; default: - goto case MicrosoftAuthenticationFlowType.Auto; + goto case MicrosoftAuthenticationFlowType.DeviceCode; // safe default } } } @@ -457,7 +467,7 @@ await AvaloniaUi.ShowViewAsync( } } - internal MicrosoftAuthenticationFlowType GetFlowType() + internal MicrosoftAuthenticationFlowType GetFlowType(Uri redirectUri) { if (Context.Settings.TryGetSetting( Constants.EnvironmentVariables.MsAuthFlow, @@ -469,7 +479,7 @@ internal MicrosoftAuthenticationFlowType GetFlowType() switch (valueStr.ToLowerInvariant()) { case "auto": - return MicrosoftAuthenticationFlowType.Auto; + return Auto(); case "embedded": return MicrosoftAuthenticationFlowType.EmbeddedWebView; case "system": @@ -483,7 +493,21 @@ internal MicrosoftAuthenticationFlowType GetFlowType() Context.Streams.Error.WriteLine($"warning: unknown Microsoft Authentication flow type '{valueStr}'; using 'auto'"); } - return MicrosoftAuthenticationFlowType.Auto; + return Auto(); + + // Resolve the 'auto' flow type based on the redirect URI and platform capabilities + MicrosoftAuthenticationFlowType Auto() + { + // Prefer embedded webview + if (CanUseEmbeddedWebView()) + return MicrosoftAuthenticationFlowType.EmbeddedWebView; + + if (CanUseSystemWebView(redirectUri)) + return MicrosoftAuthenticationFlowType.SystemWebView; + + // Fall back to device code flow + return MicrosoftAuthenticationFlowType.DeviceCode; + } } /// @@ -550,9 +574,21 @@ private async Task CreatePublicClientApplicationAsync( var appBuilder = PublicClientApplicationBuilder.Create(clientId) .WithAuthority(authority) - .WithRedirectUri(redirectUri.ToString()) .WithHttpClientFactory(httpFactoryAdaptor); + // Use the default redirect URI if one is not provided + if (redirectUri is null) + { + // Uses "https://login.microsoftonline.com/common/oauth2/nativeclient" on .NET Framework + // but "http://localhost" on .NET Core. This is because there is no embedded webview support + // in .NET Core and thus the system webview is the only option. + appBuilder.WithDefaultRedirectUri(); + } + else + { + appBuilder.WithRedirectUri(redirectUri.ToString()); + } + // Listen to MSAL logs if GCM_TRACE_MSAUTH is set if (Context.Settings.IsMsalTracingEnabled) { @@ -988,7 +1024,7 @@ private void EnsureCanUseEmbeddedWebView() #endif } - private bool CanUseSystemWebView(IPublicClientApplication app, Uri redirectUri) + private bool CanUseSystemWebView(Uri redirectUri) { // // MSAL requires the application redirect URI is a loopback address to use the System WebView @@ -1000,7 +1036,7 @@ private bool CanUseSystemWebView(IPublicClientApplication app, Uri redirectUri) return Context.SessionManager.IsWebBrowserAvailable && redirectUri.IsLoopback; } - private void EnsureCanUseSystemWebView(IPublicClientApplication app, Uri redirectUri) + private void EnsureCanUseSystemWebView(Uri redirectUri) { if (!Context.SessionManager.IsWebBrowserAvailable) { diff --git a/src/shared/Core/Authentication/OAuth/IOAuth2WebBrowser.cs b/src/shared/Core/Authentication/OAuth/IOAuth2WebBrowser.cs index a9dbdf519a..72a30de613 100644 --- a/src/shared/Core/Authentication/OAuth/IOAuth2WebBrowser.cs +++ b/src/shared/Core/Authentication/OAuth/IOAuth2WebBrowser.cs @@ -1,5 +1,5 @@ using System; -using System.Net; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -9,6 +9,16 @@ public interface IOAuth2WebBrowser { Uri UpdateRedirectUri(Uri uri); - Task GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct); + /// + /// Drive the user agent through the authorization request and intercept the + /// authorization response delivered to the redirect URI. + /// + /// Authorization request URI to open in the user agent. + /// Redirect URI to intercept the response on. + /// Mechanism the authorization server uses to deliver the response. + /// Token to cancel the operation. + /// The authorization response parameters. + Task> GetAuthenticationResponseAsync( + Uri authorizationUri, Uri redirectUri, OAuth2ResponseMode responseMode, CancellationToken ct); } } diff --git a/src/shared/Core/Authentication/OAuth/OAuth2Client.cs b/src/shared/Core/Authentication/OAuth/OAuth2Client.cs index 27834d2aaf..75120522cc 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2Client.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2Client.cs @@ -73,6 +73,7 @@ public class OAuth2Client : IOAuth2Client private readonly ITrace2 _trace2; private readonly string _clientSecret; private readonly bool _addAuthHeader; + private readonly OAuth2ResponseMode _responseMode; private IOAuth2CodeGenerator _codeGenerator; @@ -82,7 +83,8 @@ public OAuth2Client(HttpClient httpClient, ITrace2 trace2, Uri redirectUri = null, string clientSecret = null, - bool addAuthHeader = true) + bool addAuthHeader = true, + OAuth2ResponseMode responseMode = OAuth2ResponseMode.Default) { _httpClient = httpClient; _endpoints = endpoints; @@ -91,6 +93,7 @@ public OAuth2Client(HttpClient httpClient, _redirectUri = redirectUri; _clientSecret = clientSecret; _addAuthHeader = addAuthHeader; + _responseMode = responseMode; } public IOAuth2CodeGenerator CodeGenerator @@ -119,6 +122,13 @@ public async Task GetAuthorizationCodeAsync(IEnum [OAuth2Constants.AuthorizationEndpoint.PkceChallengeParameter] = codeChallenge }; + // Only send the parameter when requesting a non-default mode to keep the request unchanged otherwise. + if (_responseMode != OAuth2ResponseMode.Default) + { + queryParams[OAuth2Constants.AuthorizationEndpoint.ResponseModeParameter] = + _responseMode.GetParameterValue(); + } + if (extraQueryParams?.Count > 0) { foreach (var kvp in extraQueryParams) @@ -157,25 +167,27 @@ public async Task GetAuthorizationCodeAsync(IEnum Uri authorizationUri = authorizationUriBuilder.Uri; - // Open the browser at the request URI to start the authorization code grant flow. - Uri finalUri = await browser.GetAuthenticationCodeAsync(authorizationUri, redirectUri, ct); + // Open the browser at the request URI to start the authorization code grant flow, and + // intercept the response parameters delivered to the redirect URI. + IDictionary responseParams = + await browser.GetAuthenticationResponseAsync(authorizationUri, redirectUri, _responseMode, ct); // Check for errors serious enough we should terminate the flow, such as if the state value returned does // not match the one we passed. This indicates a badly implemented Authorization Server, or worse, some // form of failed MITM or replay attack. - IDictionary redirectQueryParams = finalUri.GetQueryParameters(); - if (!redirectQueryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.StateParameter, out string replyState)) + if (!responseParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.StateParameter, out string replyState)) { - throw new Trace2OAuth2Exception(_trace2, $"Missing '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response."); + throw new Trace2OAuth2Exception(_trace2, + $"Missing '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response."); } if (!StringComparer.Ordinal.Equals(state, replyState)) { throw new Trace2OAuth2Exception(_trace2, - $"Missing '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response."); + $"Invalid '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response; does not match the request."); } // We expect to have the auth code in the response otherwise terminate the flow (we failed authentication for some reason) - if (!redirectQueryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.AuthorizationCodeParameter, out string authCode)) + if (!responseParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.AuthorizationCodeParameter, out string authCode)) { throw new Trace2OAuth2Exception(_trace2, $"Missing '{OAuth2Constants.AuthorizationGrantResponse.AuthorizationCodeParameter}' in response."); diff --git a/src/shared/Core/Authentication/OAuth/OAuth2Constants.cs b/src/shared/Core/Authentication/OAuth/OAuth2Constants.cs index 0b96a60476..a1c0ca90a8 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2Constants.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2Constants.cs @@ -14,6 +14,10 @@ public static class AuthorizationEndpoint public const string StateParameter = "state"; public const string AuthorizationCodeResponseType = "code"; public const string ResponseTypeParameter = "response_type"; + public const string ResponseModeParameter = "response_mode"; + public const string QueryResponseMode = "query"; + public const string FragmentResponseMode = "fragment"; + public const string FormPostResponseMode = "form_post"; public const string PkceChallengeParameter = "code_challenge"; public const string PkceChallengeMethodParameter = "code_challenge_method"; public const string PkceChallengeMethodPlain = "plain"; diff --git a/src/shared/Core/Authentication/OAuth/OAuth2ResponseMode.cs b/src/shared/Core/Authentication/OAuth/OAuth2ResponseMode.cs new file mode 100644 index 0000000000..2f5ef0f13f --- /dev/null +++ b/src/shared/Core/Authentication/OAuth/OAuth2ResponseMode.cs @@ -0,0 +1,90 @@ +using System; + +namespace GitCredentialManager.Authentication.OAuth; + +/// +/// The mechanism the authorization server uses to return authorization response +/// parameters to the redirect URI. +/// +public enum OAuth2ResponseMode +{ + /// + /// Use the default response mode as determined by the authorization server. + /// + Default = 0, + + /// + /// Parameters are encoded in the query component of the redirect URI. + /// + Query, + + /// + /// Parameters are encoded in the fragment component of the redirect URI. + /// + Fragment, + + /// + /// Parameters are returned as an HTML form that is auto-submitted as an + /// application/x-www-form-urlencoded POST to the redirect URI, as + /// described by the OAuth 2.0 Form Post Response Mode specification. + /// + FormPost, +} + +public static class OAuth2ResponseModeExtensions +{ + /// + /// Get the wire value for the response_mode authorization request parameter. + /// + public static string GetParameterValue(this OAuth2ResponseMode mode) + { + switch (mode) + { + case OAuth2ResponseMode.Default: + return null; + case OAuth2ResponseMode.Query: + return OAuth2Constants.AuthorizationEndpoint.QueryResponseMode; + case OAuth2ResponseMode.Fragment: + return OAuth2Constants.AuthorizationEndpoint.FragmentResponseMode; + case OAuth2ResponseMode.FormPost: + return OAuth2Constants.AuthorizationEndpoint.FormPostResponseMode; + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown OAuth2 response mode."); + } + } + + /// + /// Try to parse a response_mode wire value into an . + /// + public static bool TryParse(string value, out OAuth2ResponseMode mode) + { + mode = OAuth2ResponseMode.Default; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (StringComparer.OrdinalIgnoreCase.Equals(value, OAuth2Constants.AuthorizationEndpoint.QueryResponseMode)) + { + mode = OAuth2ResponseMode.Query; + return true; + } + + if (StringComparer.OrdinalIgnoreCase.Equals(value, OAuth2Constants.AuthorizationEndpoint.FragmentResponseMode)) + { + mode = OAuth2ResponseMode.Fragment; + return true; + } + + // Accept both "form_post" (wire value) and "formpost" for convenience. + if (StringComparer.OrdinalIgnoreCase.Equals(value, OAuth2Constants.AuthorizationEndpoint.FormPostResponseMode) || + StringComparer.OrdinalIgnoreCase.Equals(value, "formpost")) + { + mode = OAuth2ResponseMode.FormPost; + return true; + } + + return false; + } +} diff --git a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs index 05843f9df2..4f55072a47 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Sockets; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -36,6 +38,34 @@ public class OAuth2WebBrowserOptions public class OAuth2SystemWebBrowser : IOAuth2WebBrowser { + // Served during the fragment response flow. The authorization parameters live in the + // URI fragment, which user agents do not transmit to the server, so we reissue them as + // a form POST to the redirect URI - keeping them out of the URL (and thus out of + // browser history and server logs) and letting the listener read them from the body. + private const string FragmentFormPostHtml = @" +Authenticating... +
"; + private readonly ISessionManager _sessionManager; private readonly OAuth2WebBrowserOptions _options; @@ -65,26 +95,28 @@ public Uri UpdateRedirectUri(Uri uri) return uri; } - public async Task GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct) + public async Task> GetAuthenticationResponseAsync( + Uri authorizationUri, Uri redirectUri, OAuth2ResponseMode responseMode, CancellationToken ct) { if (!redirectUri.IsLoopback) { throw new ArgumentException("Only localhost is supported as a redirect URI.", nameof(redirectUri)); } - Task interceptTask = InterceptRequestsAsync(redirectUri, ct); + Task> interceptTask = InterceptRequestsAsync(redirectUri, responseMode, ct); _sessionManager.OpenBrowser(authorizationUri); return await interceptTask; } - private async Task InterceptRequestsAsync(Uri listenUri, CancellationToken ct) + private async Task> InterceptRequestsAsync( + Uri listenUri, OAuth2ResponseMode responseMode, CancellationToken ct) { // Create a TaskCompletionSource which completes when we're asked to cancel. - // We can then await the this task together with other tasks that don't take a + // We can then await this task together with other tasks that don't take a // CancellationToken and exit the method quickly when cancelled. - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource>(); ct.Register(() => tcs.SetCanceled()); // Prefixes must end with a '/' @@ -99,25 +131,40 @@ private async Task InterceptRequestsAsync(Uri listenUri, CancellationToken try { - Task contextTask = listener.GetContextAsync(); - Task cancelTask = tcs.Task; + while (true) + { + Task contextTask = listener.GetContextAsync(); + Task> cancelTask = tcs.Task; - Task completedTask = await Task.WhenAny(contextTask, tcs.Task); + Task completedTask = await Task.WhenAny(contextTask, cancelTask); - // Check if we 'completed' the context task or the cancellation task - if (completedTask == cancelTask) - { - // We were cancelled! - return await cancelTask; - } + // Check if we 'completed' the context task or the cancellation task + if (completedTask == cancelTask) + { + // We were cancelled! + return await cancelTask; + } + + // We intercepted a request! + HttpListenerContext context = await contextTask; - // We intercepted a request! - HttpListenerContext context = await contextTask; + IDictionary parameters = await GetResponseParametersAsync(context.Request); - await HandleInterceptedRequestAsync(context.Request, context.Response); + // In fragment mode the authorization parameters are in the URI fragment, which + // user agents do not send to the server. The first leg is therefore a parameterless + // GET; reply with a script that reissues the parameters as a form POST so we can + // read them from the body on the next iteration. + if (responseMode == OAuth2ResponseMode.Fragment && parameters.Count == 0) + { + await context.Response.WriteResponseAsync(FragmentFormPostHtml); + context.Response.Close(); + continue; + } - // Return the final intercepted URI - return context.Request.Url; + await WriteFinalResponseAsync(context.Response, parameters); + + return parameters; + } } finally { @@ -126,14 +173,41 @@ private async Task InterceptRequestsAsync(Uri listenUri, CancellationToken } } - private async Task HandleInterceptedRequestAsync(HttpListenerRequest request, HttpListenerResponse response) + private static async Task> GetResponseParametersAsync(HttpListenerRequest request) + { + // Form post responses - and the form POST used to forward fragment responses - carry + // the authorization parameters in the urlencoded request body. + if (StringComparer.OrdinalIgnoreCase.Equals(request.HttpMethod, Constants.Http.MethodPost) && + IsFormUrlEncoded(request.ContentType)) + { + using var reader = new StreamReader(request.InputStream, request.ContentEncoding ?? Encoding.UTF8); + string body = await reader.ReadToEndAsync(); + return UriExtensions.ParseQueryString(body); + } + + // Query responses carry the parameters in the request query string. + return request.QueryString.ToDictionary(StringComparer.OrdinalIgnoreCase); + } + + internal static bool IsFormUrlEncoded(string contentType) { - IDictionary queryParams = request.QueryString.ToDictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(contentType)) + { + return false; + } + + // Compare only the media type, ignoring any parameters such as "; charset=utf-8". + // The media type is everything up to the first ';'. + string mediaType = contentType.Split(';')[0].Trim(); + return StringComparer.OrdinalIgnoreCase.Equals(mediaType, Constants.Http.MimeTypeFormUrlEncoded); + } + private async Task WriteFinalResponseAsync(HttpListenerResponse response, IDictionary parameters) + { // If we have an error value then the request failed and we should reply with a page containing the error information - bool hasError = queryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorCodeParameter, out string errorCode); - queryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorDescriptionParameter, out string errorDescription); - queryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorUriParameter, out string errorUri); + bool hasError = parameters.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorCodeParameter, out string errorCode); + parameters.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorDescriptionParameter, out string errorDescription); + parameters.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.ErrorUriParameter, out string errorUri); if (hasError) { string FormatError(string format) diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 6fecc2b38d..d906d3a55c 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -129,6 +129,7 @@ public static class EnvironmentVariables public const string OAuthDeviceEndpoint = "GCM_OAUTH_DEVICE_ENDPOINT"; public const string OAuthClientAuthHeader = "GCM_OAUTH_USE_CLIENT_AUTH_HEADER"; public const string OAuthDefaultUserName = "GCM_OAUTH_DEFAULT_USERNAME"; + public const string OAuthResponseMode = "GCM_OAUTH_RESPONSE_MODE"; public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS"; public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING"; public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES"; @@ -145,6 +146,10 @@ public static class Http public const string WwwAuthenticateNtlmScheme = "NTLM"; public const string MimeTypeJson = "application/json"; + public const string MimeTypeFormUrlEncoded = "application/x-www-form-urlencoded"; + + public const string MethodGet = "GET"; + public const string MethodPost = "POST"; } public static class GitConfiguration @@ -191,6 +196,7 @@ public static class Credential public const string OAuthDeviceEndpoint = "oauthDeviceEndpoint"; public const string OAuthClientAuthHeader = "oauthUseClientAuthHeader"; public const string OAuthDefaultUserName = "oauthDefaultUserName"; + public const string OAuthResponseMode = "oauthResponseMode"; } public static class Http diff --git a/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs b/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs index e4dba08224..ad64b7f810 100644 --- a/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs +++ b/src/shared/Core/Diagnostics/MicrosoftAuthenticationDiagnostic.cs @@ -17,7 +17,15 @@ protected override async Task RunInternalAsync(StringBuilder log, IList GetOAuthAccessToken(Uri remoteUri, string userNa trace2, config.RedirectUri, config.ClientSecret, - config.UseAuthHeader); + config.UseAuthHeader, + config.ResponseMode); // // Prepend "refresh_token" to the hostname to get a (hopefully) unique service name that diff --git a/src/shared/Core/GenericOAuthConfig.cs b/src/shared/Core/GenericOAuthConfig.cs index 522d89fec3..098541babb 100644 --- a/src/shared/Core/GenericOAuthConfig.cs +++ b/src/shared/Core/GenericOAuthConfig.cs @@ -134,6 +134,23 @@ public static bool TryGet(ITrace trace, ISettings settings, InputArguments input config.UseAuthHeader = true; } + // Response mode is optional and defaults to 'query' + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthResponseMode, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthResponseMode, + out string responseModeStr) && !string.IsNullOrWhiteSpace(responseModeStr)) + { + if (OAuth2ResponseModeExtensions.TryParse(responseModeStr, out OAuth2ResponseMode responseMode)) + { + config.ResponseMode = responseMode; + } + else + { + trace.WriteLine($"Invalid OAuth configuration - unknown response mode '{responseModeStr}'; using default"); + } + } + config.DefaultUserName = settings.TryGetSetting( Constants.EnvironmentVariables.OAuthDefaultUserName, Constants.GitConfiguration.Credential.SectionName, @@ -152,6 +169,7 @@ public static bool TryGet(ITrace trace, ISettings settings, InputArguments input public Uri RedirectUri { get; set; } public string[] Scopes { get; set; } public bool UseAuthHeader { get; set; } + public OAuth2ResponseMode ResponseMode { get; set; } public string DefaultUserName { get; set; } public bool SupportsDeviceCode => Endpoints.DeviceAuthorizationEndpoint != null; diff --git a/src/shared/Core/ISessionManager.cs b/src/shared/Core/ISessionManager.cs index 0ad0204c4b..8ee291f300 100644 --- a/src/shared/Core/ISessionManager.cs +++ b/src/shared/Core/ISessionManager.cs @@ -67,7 +67,13 @@ public void OpenBrowser(Uri uri) throw new ArgumentException("Can only open HTTP/HTTPS URIs", nameof(uri)); } - OpenBrowserInternal(uri.ToString()); + // Important! Use AbsoluteUri to ensure that the URL is properly + // escaped (e.g. spaces are converted to %20). + // The 'shell execute' handler on some operating systems (e.g. macOS) + // will try to validate the URL handed to it and if it sees any + // unescaped characters it will decide that the rest of the query + // parameters also need esacaping leading to double escaping! + OpenBrowserInternal(uri.AbsoluteUri); } protected virtual void OpenBrowserInternal(string url) diff --git a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj index b367bb48aa..f1a57bdc96 100644 --- a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj +++ b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj @@ -18,6 +18,7 @@ + diff --git a/src/shared/Git-Credential-Manager/Program.cs b/src/shared/Git-Credential-Manager/Program.cs index 59f579b9fd..d637976a48 100644 --- a/src/shared/Git-Credential-Manager/Program.cs +++ b/src/shared/Git-Credential-Manager/Program.cs @@ -5,6 +5,7 @@ using GitHub; using GitLab; using Microsoft.AzureRepos; +using Microsoft.ManagedApps; using GitCredentialManager.Authentication; using GitCredentialManager.UI; @@ -61,6 +62,7 @@ private static void AppMain(object o) app.RegisterProvider(new BitbucketHostProvider(context), HostProviderPriority.Normal); app.RegisterProvider(new GitHubHostProvider(context), HostProviderPriority.Normal); app.RegisterProvider(new GitLabHostProvider(context), HostProviderPriority.Normal); + app.RegisterProvider(new ManagedAppsHostProvider(context), HostProviderPriority.Normal); app.RegisterProvider(new GenericHostProvider(context), HostProviderPriority.Low); _exitCode = app.RunAsync(args) diff --git a/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsBindingManagerTests.cs b/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsBindingManagerTests.cs new file mode 100644 index 0000000000..beb11f02e0 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsBindingManagerTests.cs @@ -0,0 +1,77 @@ +using GitCredentialManager.Tests.Objects; +using Xunit; + +namespace Microsoft.ManagedApps.Tests +{ + public class ManagedAppsBindingManagerTests + { + private const string Host = "https://4899945d6e51f1f0326cda880ec7a7.09.environment.api.powerplatform.com"; + + [Fact] + public void ManagedAppsBindingManager_GetAccount_NoBinding_ReturnsNull() + { + var manager = new ManagedAppsBindingManager(new NullTrace(), new TestGit()); + + string account = manager.GetAccount(Host); + + Assert.Null(account); + } + + [Fact] + public void ManagedAppsBindingManager_SignIn_ThenGetAccount_ReturnsBoundAccount() + { + var git = new TestGit(); + var manager = new ManagedAppsBindingManager(new NullTrace(), git); + + manager.SignIn(Host, "user@example.com"); + + Assert.Equal("user@example.com", manager.GetAccount(Host)); + } + + [Fact] + public void ManagedAppsBindingManager_SignIn_NullAccount_DoesNotThrowAndDoesNotBind() + { + var manager = new ManagedAppsBindingManager(new NullTrace(), new TestGit()); + + manager.SignIn(Host, null); + + Assert.Null(manager.GetAccount(Host)); + } + + [Fact] + public void ManagedAppsBindingManager_SignOut_RemovesBinding() + { + var git = new TestGit(); + var manager = new ManagedAppsBindingManager(new NullTrace(), git); + manager.SignIn(Host, "user@example.com"); + + manager.SignOut(Host); + + Assert.Null(manager.GetAccount(Host)); + } + + [Fact] + public void ManagedAppsBindingManager_SignOut_NoExistingBinding_DoesNotThrow() + { + var manager = new ManagedAppsBindingManager(new NullTrace(), new TestGit()); + + manager.SignOut(Host); + + Assert.Null(manager.GetAccount(Host)); + } + + [Fact] + public void ManagedAppsBindingManager_Bindings_AreIndependentPerHost() + { + const string otherHost = "https://c0a12cc2ff79f7306d6ef9fc69c2062.1.environment.api.preprod.powerplatform.com"; + var git = new TestGit(); + var manager = new ManagedAppsBindingManager(new NullTrace(), git); + + manager.SignIn(Host, "user1@example.com"); + manager.SignIn(otherHost, "user2@example.com"); + + Assert.Equal("user1@example.com", manager.GetAccount(Host)); + Assert.Equal("user2@example.com", manager.GetAccount(otherHost)); + } + } +} diff --git a/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsCloudEnvironmentTests.cs b/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsCloudEnvironmentTests.cs new file mode 100644 index 0000000000..b009c482b5 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsCloudEnvironmentTests.cs @@ -0,0 +1,250 @@ +using System.Collections.Generic; +using GitCredentialManager.Tests.Objects; +using Xunit; + +namespace Microsoft.ManagedApps.Tests +{ + public class ManagedAppsCloudEnvironmentTests + { + private static readonly ManagedAppsCloudEnvironment CompleteCloudEnvironment = new ManagedAppsCloudEnvironment( + "prod", + ".environment.api.powerplatform.com", + "https://api.powerplatform.com", + new[] { "https://api.powerplatform.com/GitRepositories.Repositories.Read", "offline_access" }); + + private static readonly ManagedAppsCloudEnvironment IncompleteCloudEnvironment = new ManagedAppsCloudEnvironment( + "preprod", + ".environment.api.preprod.powerplatform.com", + null, + null); + + #region IsComplete + + [Fact] + public void ManagedAppsCloudEnvironment_IsComplete_AllFieldsPresent_ReturnsTrue() + { + Assert.True(CompleteCloudEnvironment.IsComplete); + } + + [Fact] + public void ManagedAppsCloudEnvironment_IsComplete_MissingResourceAndScopes_ReturnsFalse() + { + Assert.False(IncompleteCloudEnvironment.IsComplete); + } + + [Fact] + public void ManagedAppsCloudEnvironment_IsComplete_EmptyScopesList_ReturnsFalse() + { + var cloudEnvironment = new ManagedAppsCloudEnvironment("x", "suffix", "https://resource", new string[0]); + Assert.False(cloudEnvironment.IsComplete); + } + + #endregion + + #region TryMatch (pure matching logic) + + [Theory] + [InlineData("335c2de2afba6fd0e64fbed1b077de.09.environment.api.powerplatform.com")] + [InlineData("4899945d6e51f1f0326cda880ec7a7.09.environment.api.powerplatform.com")] + public void ManagedAppsCloudEnvironment_TryMatch_CompleteCloudEnvironment_MatchesExampleHosts(string host) + { + var candidates = new[] { CompleteCloudEnvironment, IncompleteCloudEnvironment }; + + bool result = ManagedAppsCloudEnvironment.TryMatch(new NullTrace(), candidates, host, out ManagedAppsCloudEnvironment cloudEnvironment); + + Assert.True(result); + Assert.Equal("prod", cloudEnvironment.Name); + } + + [Theory] + [InlineData("2060e1f7f2d321d53089d1d9a07569e.6.environment.api.preprod.powerplatform.com")] + [InlineData("c0a12cc2ff79f7306d6ef9fc69c2062.1.environment.api.preprod.powerplatform.com")] + public void ManagedAppsCloudEnvironment_TryMatch_IncompleteCloudEnvironment_IsNeverMatched(string host) + { + // Regression guard for the design rule: a cloud environment only participates in + // host matching once it is fully specified (host suffix + resource + scopes). + var candidates = new[] { CompleteCloudEnvironment, IncompleteCloudEnvironment }; + + bool result = ManagedAppsCloudEnvironment.TryMatch(new NullTrace(), candidates, host, out ManagedAppsCloudEnvironment cloudEnvironment); + + Assert.False(result); + Assert.Null(cloudEnvironment); + } + + [Theory] + [InlineData("powerplatform.com")] + [InlineData("api.powerplatform.com")] + [InlineData("xenvironment.api.powerplatform.com")] + [InlineData("environment.api.powerplatform.com.attacker.example")] + [InlineData("evil.example/environment.api.powerplatform.com")] + [InlineData("")] + [InlineData(null)] + public void ManagedAppsCloudEnvironment_TryMatch_LookalikeOrInvalidHosts_ReturnsFalse(string host) + { + var candidates = new[] { CompleteCloudEnvironment, IncompleteCloudEnvironment }; + + bool result = ManagedAppsCloudEnvironment.TryMatch(new NullTrace(), candidates, host, out ManagedAppsCloudEnvironment cloudEnvironment); + + Assert.False(result); + Assert.Null(cloudEnvironment); + } + + [Fact] + public void ManagedAppsCloudEnvironment_TryMatch_LongestSuffixWins() + { + var shortSuffixCloudEnvironment = new ManagedAppsCloudEnvironment( + "short", ".powerplatform.com", "https://short", new[] { "s" }); + var longSuffixCloudEnvironment = new ManagedAppsCloudEnvironment( + "long", ".environment.api.powerplatform.com", "https://long", new[] { "l" }); + + bool result = ManagedAppsCloudEnvironment.TryMatch( + new NullTrace(), + new[] { shortSuffixCloudEnvironment, longSuffixCloudEnvironment }, + "335c2de2afba6fd0e64fbed1b077de.09.environment.api.powerplatform.com", + out ManagedAppsCloudEnvironment cloudEnvironment); + + Assert.True(result); + Assert.Equal("long", cloudEnvironment.Name); + } + + #endregion + + #region Compiled-in defaults + + [Fact] + public void ManagedAppsCloudEnvironment_CompiledInDefaults_ContainsExpectedNames() + { + var names = new List(); + foreach (ManagedAppsCloudEnvironment cloudEnvironment in ManagedAppsCloudEnvironment.CompiledInDefaults) + { + names.Add(cloudEnvironment.Name); + } + + Assert.Contains("prod", names); + Assert.Contains("preprod", names); + Assert.Contains("test", names); + } + + [Fact] + public void ManagedAppsCloudEnvironment_CompiledInDefaults_OnlyProdIsCompleteToday() + { + foreach (ManagedAppsCloudEnvironment cloudEnvironment in ManagedAppsCloudEnvironment.CompiledInDefaults) + { + if (cloudEnvironment.Name == "prod") + { + Assert.True(cloudEnvironment.IsComplete); + } + else + { + // preprod/test: host suffix known, resource/scopes not yet defined by + // the service - must remain incomplete until deliberately completed. + Assert.False(cloudEnvironment.IsComplete); + } + } + } + + [Theory] + [InlineData("335c2de2afba6fd0e64fbed1b077de.09.environment.api.powerplatform.com")] + [InlineData("4899945d6e51f1f0326cda880ec7a7.09.environment.api.powerplatform.com")] + public void ManagedAppsCloudEnvironment_CompiledInDefaults_MatchesProdExampleHosts(string host) + { + bool result = ManagedAppsCloudEnvironment.TryMatch(new NullTrace(), ManagedAppsCloudEnvironment.CompiledInDefaults, host, out ManagedAppsCloudEnvironment cloudEnvironment); + + Assert.True(result); + Assert.Equal("prod", cloudEnvironment.Name); + Assert.Equal(2, cloudEnvironment.Scopes.Count); + Assert.Contains("offline_access", cloudEnvironment.Scopes); + Assert.Contains("https://api.powerplatform.com/.default", cloudEnvironment.Scopes); + } + + [Theory] + [InlineData("2060e1f7f2d321d53089d1d9a07569e.6.environment.api.preprod.powerplatform.com")] + [InlineData("c0a12cc2ff79f7306d6ef9fc69c2062.1.environment.api.preprod.powerplatform.com")] + [InlineData("a0eb0a858adcee649ed990b1c9f25aa.8.environment.api.test.powerplatform.com")] + [InlineData("276050cb757bff044120192320a7614.4.environment.api.test.powerplatform.com")] + public void ManagedAppsCloudEnvironment_CompiledInDefaults_DoesNotYetMatchPreprodOrTestExampleHosts(string host) + { + // Regression guard: these hosts must keep falling through to the next + // provider (e.g. the generic OAuth provider) until preprod/test are completed. + bool result = ManagedAppsCloudEnvironment.TryMatch(new NullTrace(), ManagedAppsCloudEnvironment.CompiledInDefaults, host, out ManagedAppsCloudEnvironment cloudEnvironment); + + Assert.False(result); + Assert.Null(cloudEnvironment); + } + + #endregion + + #region GetEffectiveCloudEnvironments (configuration merge) + + [Fact] + public void ManagedAppsCloudEnvironment_GetEffectiveCloudEnvironments_AddsBrandNewCustomCloudEnvironment() + { + var git = new TestGit(); + git.Configuration.Global["credential.managedAppsCloudEnvironment.gov.hostSuffix"] = + new List { ".environment.api.gov.powerplatform.com" }; + git.Configuration.Global["credential.managedAppsCloudEnvironment.gov.resource"] = + new List { "https://api.gov.powerplatform.com" }; + git.Configuration.Global["credential.managedAppsCloudEnvironment.gov.scopes"] = + new List { "https://api.gov.powerplatform.com/GitRepositories.Repositories.Read offline_access" }; + + IReadOnlyList cloudEnvironments = ManagedAppsCloudEnvironment.GetEffectiveCloudEnvironments(git.Configuration); + + ManagedAppsCloudEnvironment govCloudEnvironment = FindCloudEnvironment(cloudEnvironments, "gov"); + Assert.NotNull(govCloudEnvironment); + Assert.True(govCloudEnvironment.IsComplete); + Assert.Equal(".environment.api.gov.powerplatform.com", govCloudEnvironment.HostSuffix); + Assert.Equal("https://api.gov.powerplatform.com", govCloudEnvironment.ResourceAudience); + Assert.Contains("offline_access", govCloudEnvironment.Scopes); + + bool matched = ManagedAppsCloudEnvironment.TryMatch( + new NullTrace(), cloudEnvironments, "abc123.09.environment.api.gov.powerplatform.com", out ManagedAppsCloudEnvironment matchedCloudEnvironment); + Assert.True(matched); + Assert.Equal("gov", matchedCloudEnvironment.Name); + } + + [Fact] + public void ManagedAppsCloudEnvironment_GetEffectiveCloudEnvironments_CompletesPartialCompiledInCloudEnvironment_FieldLevelMerge() + { + var git = new TestGit(); + // Supply only the missing fields for the compiled-in "preprod" cloud environment - + // the host suffix should still come from the compiled-in default (field-level merge). + git.Configuration.Global["credential.managedAppsCloudEnvironment.preprod.resource"] = + new List { "https://api.preprod.powerplatform.com" }; + git.Configuration.Global["credential.managedAppsCloudEnvironment.preprod.scopes"] = + new List { "https://api.preprod.powerplatform.com/GitRepositories.Repositories.Read offline_access" }; + + IReadOnlyList cloudEnvironments = ManagedAppsCloudEnvironment.GetEffectiveCloudEnvironments(git.Configuration); + + ManagedAppsCloudEnvironment preprodCloudEnvironment = FindCloudEnvironment(cloudEnvironments, "preprod"); + Assert.NotNull(preprodCloudEnvironment); + Assert.True(preprodCloudEnvironment.IsComplete); + Assert.Equal(".environment.api.preprod.powerplatform.com", preprodCloudEnvironment.HostSuffix); // from compiled-in default + Assert.Equal("https://api.preprod.powerplatform.com", preprodCloudEnvironment.ResourceAudience); // from configuration + } + + [Fact] + public void ManagedAppsCloudEnvironment_GetEffectiveCloudEnvironments_NoConfiguration_ReturnsCompiledInDefaultsOnly() + { + var git = new TestGit(); + + IReadOnlyList cloudEnvironments = ManagedAppsCloudEnvironment.GetEffectiveCloudEnvironments(git.Configuration); + + Assert.Equal(ManagedAppsCloudEnvironment.CompiledInDefaults.Count, cloudEnvironments.Count); + } + + private static ManagedAppsCloudEnvironment FindCloudEnvironment(IEnumerable cloudEnvironments, string name) + { + foreach (ManagedAppsCloudEnvironment cloudEnvironment in cloudEnvironments) + { + if (cloudEnvironment.Name == name) + { + return cloudEnvironment; + } + } + + return null; + } + + #endregion + } +} diff --git a/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsHostProviderTests.cs b/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsHostProviderTests.cs new file mode 100644 index 0000000000..29cfc4ee00 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps.Tests/ManagedAppsHostProviderTests.cs @@ -0,0 +1,434 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GitCredentialManager; +using GitCredentialManager.Authentication; +using GitCredentialManager.Tests.Objects; +using Moq; +using Xunit; + +namespace Microsoft.ManagedApps.Tests +{ + public class ManagedAppsHostProviderTests + { + private const string ProdHost = "4899945d6e51f1f0326cda880ec7a7.09.environment.api.powerplatform.com"; + private const string PreprodHost = "c0a12cc2ff79f7306d6ef9fc69c2062.1.environment.api.preprod.powerplatform.com"; + + #region IsSupported + + [Fact] + public void ManagedAppsHostProvider_IsSupported_ProdHost_Https_ReturnsTrue() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + }); + + var provider = new ManagedAppsHostProvider(new TestCommandContext()); + + Assert.True(provider.IsSupported(input)); + } + + [Fact] + public void ManagedAppsHostProvider_IsSupported_ProdHost_UnencryptedHttp_ReturnsTrue() + { + // Reported as supported over HTTP too so that GenerateCredentialAsync can produce + // a helpful "use HTTPS" error, rather than silently falling through to another provider. + var input = new InputArguments(new Dictionary + { + ["protocol"] = "http", + ["host"] = ProdHost, + }); + + var provider = new ManagedAppsHostProvider(new TestCommandContext()); + + Assert.True(provider.IsSupported(input)); + } + + [Fact] + public void ManagedAppsHostProvider_IsSupported_PreprodHost_NotYetComplete_ReturnsFalse() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = PreprodHost, + }); + + var provider = new ManagedAppsHostProvider(new TestCommandContext()); + + Assert.False(provider.IsSupported(input)); + } + + [Fact] + public void ManagedAppsHostProvider_IsSupported_UnrelatedHost_ReturnsFalse() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + }); + + var provider = new ManagedAppsHostProvider(new TestCommandContext()); + + Assert.False(provider.IsSupported(input)); + } + + [Fact] + public void ManagedAppsHostProvider_IsSupported_NullInput_ReturnsFalse() + { + var provider = new ManagedAppsHostProvider(new TestCommandContext()); + + Assert.False(provider.IsSupported((InputArguments)null)); + } + + #endregion + + #region GetServiceName + + [Fact] + public void ManagedAppsHostProvider_GetServiceName_DropsPathAndUserInfo() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + ["path"] = "appframework/git/repositories/9f3a1c4e-8b02-4d17-a5c6-2e7f0b41d38a", + ["username"] = "someuser", + }); + + var provider = new ManagedAppsHostProvider(new TestCommandContext()); + + Assert.Equal($"https://{ProdHost}", provider.GetServiceName(input)); + } + + #endregion + + #region GenerateCredentialAsync - interactive user auth + + [Fact] + public async Task ManagedAppsHostProvider_GetCredentialAsync_Prod_ReturnsCredentialFromMsal() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + }); + + var context = new TestCommandContext(); + const string expectedAuthority = "https://login.microsoftonline.com/organizations"; + const string upn = "user@example.com"; + const string accessToken = "ACCESS-TOKEN"; + + var msAuthMock = new Mock(MockBehavior.Strict); + msAuthMock + .Setup(x => x.GetTokenForUserAsync( + expectedAuthority, + ManagedAppsConstants.AadClientId, + ManagedAppsConstants.AadRedirectUri, + It.Is(s => s.Length == 2 && System.Array.IndexOf(s, "offline_access") >= 0 + && System.Array.IndexOf(s, "https://api.powerplatform.com/.default") >= 0), + null, + false)) + .ReturnsAsync(new MockMsAuthResult { AccountUpn = upn, AccessToken = accessToken }); + + var bindingMgrMock = new Mock(MockBehavior.Strict); + bindingMgrMock.Setup(x => x.GetAccount($"https://{ProdHost}")).Returns((string)null); + + var provider = new ManagedAppsHostProvider(context, msAuthMock.Object, bindingMgrMock.Object); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + + Assert.Equal(upn, result.Credential.Account); + Assert.Equal(accessToken, result.Credential.Password); + + // Never consult the OS credential store - there is no PAT-equivalent credential + // for this provider to cache; MSAL handles its own silent-refresh cache instead. + Assert.Equal(0, context.CredentialStore.Count); + } + + [Fact] + public async Task ManagedAppsHostProvider_GetCredentialAsync_UsesRemoteUserNameOverBindingHint() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + ["username"] = "url-user@example.com", + }); + + var context = new TestCommandContext(); + + var msAuthMock = new Mock(MockBehavior.Strict); + msAuthMock + .Setup(x => x.GetTokenForUserAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + "url-user@example.com", false)) + .ReturnsAsync(new MockMsAuthResult { AccountUpn = "url-user@example.com", AccessToken = "TOKEN" }); + + // Binding manager must not even be consulted when the remote URL specifies a user. + var bindingMgrMock = new Mock(MockBehavior.Strict); + + var provider = new ManagedAppsHostProvider(context, msAuthMock.Object, bindingMgrMock.Object); + + await provider.GetCredentialAsync(input); + + msAuthMock.VerifyAll(); + } + + [Fact] + public async Task ManagedAppsHostProvider_GetCredentialAsync_FallsBackToBindingManagerHint() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + }); + + var context = new TestCommandContext(); + + var msAuthMock = new Mock(MockBehavior.Strict); + msAuthMock + .Setup(x => x.GetTokenForUserAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + "bound-user@example.com", false)) + .ReturnsAsync(new MockMsAuthResult { AccountUpn = "bound-user@example.com", AccessToken = "TOKEN" }); + + var bindingMgrMock = new Mock(MockBehavior.Strict); + bindingMgrMock.Setup(x => x.GetAccount($"https://{ProdHost}")).Returns("bound-user@example.com"); + + var provider = new ManagedAppsHostProvider(context, msAuthMock.Object, bindingMgrMock.Object); + + await provider.GetCredentialAsync(input); + + msAuthMock.VerifyAll(); + } + + [Fact] + public async Task ManagedAppsHostProvider_GenerateCredentialAsync_UnrecognizedHost_Throws() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + }); + + var provider = new ManagedAppsHostProvider(new TestCommandContext(), + Mock.Of(), Mock.Of()); + + await Assert.ThrowsAnyAsync(() => provider.GenerateCredentialAsync(input)); + } + + [Fact] + public async Task ManagedAppsHostProvider_GenerateCredentialAsync_UnencryptedHttp_ThrowsByDefault() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "http", + ["host"] = ProdHost, + }); + + var provider = new ManagedAppsHostProvider(new TestCommandContext(), + Mock.Of(), Mock.Of()); + + await Assert.ThrowsAnyAsync(() => provider.GenerateCredentialAsync(input)); + } + + [Fact] + public async Task ManagedAppsHostProvider_GenerateCredentialAsync_UnencryptedHttp_AllowUnsafeRemotes_Succeeds() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "http", + ["host"] = ProdHost, + }); + + var context = new TestCommandContext(); + context.Settings.AllowUnsafeRemotes = true; + + var msAuthMock = new Mock(); + msAuthMock + .Setup(x => x.GetTokenForUserAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), false)) + .ReturnsAsync(new MockMsAuthResult { AccountUpn = "user@example.com", AccessToken = "TOKEN" }); + + var bindingMgrMock = new Mock(); + bindingMgrMock.Setup(x => x.GetAccount(It.IsAny())).Returns((string)null); + + var provider = new ManagedAppsHostProvider(context, msAuthMock.Object, bindingMgrMock.Object); + + ICredential credential = await provider.GenerateCredentialAsync(input); + + Assert.Equal("user@example.com", credential.Account); + } + + #endregion + + #region GenerateCredentialAsync - non-interactive modes + + [Fact] + public async Task ManagedAppsHostProvider_GenerateCredentialAsync_ManagedIdentity_UsesResourceNotScopes() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + }); + + var context = new TestCommandContext(); + context.Environment.Variables[ManagedAppsConstants.EnvironmentVariables.ManagedIdentity] = "system"; + + var msAuthMock = new Mock(MockBehavior.Strict); + msAuthMock + .Setup(x => x.GetTokenForManagedIdentityAsync("system", "https://api.powerplatform.com")) + .ReturnsAsync(new MockMsAuthResult { AccessToken = "MI-TOKEN" }); + + var provider = new ManagedAppsHostProvider(context, msAuthMock.Object, Mock.Of()); + + ICredential credential = await provider.GenerateCredentialAsync(input); + + Assert.Equal("system", credential.Account); + Assert.Equal("MI-TOKEN", credential.Password); + } + + [Fact] + public async Task ManagedAppsHostProvider_GenerateCredentialAsync_ServicePrincipal_UsesScopes() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + }); + + var context = new TestCommandContext(); + context.Environment.Variables[ManagedAppsConstants.EnvironmentVariables.ServicePrincipalId] = + "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222"; + context.Environment.Variables[ManagedAppsConstants.EnvironmentVariables.ServicePrincipalSecret] = "shh"; + + var msAuthMock = new Mock(MockBehavior.Strict); + msAuthMock + .Setup(x => x.GetTokenForServicePrincipalAsync( + It.Is(sp => + sp.TenantId == "11111111-1111-1111-1111-111111111111" && + sp.Id == "22222222-2222-2222-2222-222222222222" && + sp.ClientSecret == "shh"), + It.Is(s => s.Length == 2))) + .ReturnsAsync(new MockMsAuthResult { AccessToken = "SP-TOKEN" }); + + var provider = new ManagedAppsHostProvider(context, msAuthMock.Object, Mock.Of()); + + ICredential credential = await provider.GenerateCredentialAsync(input); + + Assert.Equal("22222222-2222-2222-2222-222222222222", credential.Account); + Assert.Equal("SP-TOKEN", credential.Password); + } + + [Fact] + public async Task ManagedAppsHostProvider_GenerateCredentialAsync_WorkloadFederationGeneric_UsesScopes() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + }); + + var context = new TestCommandContext(); + context.Environment.Variables[ManagedAppsConstants.EnvironmentVariables.WorkloadFederation] = "generic"; + context.Environment.Variables[ManagedAppsConstants.EnvironmentVariables.WorkloadFederationClientId] = + "11111111-1111-1111-1111-111111111111"; + context.Environment.Variables[ManagedAppsConstants.EnvironmentVariables.WorkloadFederationTenantId] = + "22222222-2222-2222-2222-222222222222"; + context.Environment.Variables[ManagedAppsConstants.EnvironmentVariables.WorkloadFederationAssertion] = + "eyJhbGci..."; + + var msAuthMock = new Mock(MockBehavior.Strict); + msAuthMock + .Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.Is(o => + o.Scenario == MicrosoftWorkloadFederationScenario.Generic && + o.GenericClientAssertion == "eyJhbGci..."), + It.Is(s => s.Length == 2))) + .ReturnsAsync(new MockMsAuthResult { AccessToken = "WIF-TOKEN" }); + + var provider = new ManagedAppsHostProvider(context, msAuthMock.Object, Mock.Of()); + + ICredential credential = await provider.GenerateCredentialAsync(input); + + Assert.Equal("11111111-1111-1111-1111-111111111111", credential.Account); + Assert.Equal("WIF-TOKEN", credential.Password); + } + + #endregion + + #region Store / Erase + + [Fact] + public async Task ManagedAppsHostProvider_StoreCredentialAsync_Interactive_RecordsBinding() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + ["username"] = "user@example.com", + }); + + var bindingMgrMock = new Mock(MockBehavior.Strict); + bindingMgrMock.Setup(x => x.SignIn($"https://{ProdHost}", "user@example.com")); + + var provider = new ManagedAppsHostProvider(new TestCommandContext(), + Mock.Of(), bindingMgrMock.Object); + + await provider.StoreCredentialAsync(input); + + bindingMgrMock.VerifyAll(); + } + + [Fact] + public async Task ManagedAppsHostProvider_StoreCredentialAsync_ManagedIdentity_DoesNotRecordBinding() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + }); + + var context = new TestCommandContext(); + context.Environment.Variables[ManagedAppsConstants.EnvironmentVariables.ManagedIdentity] = "system"; + + // Strict mock with no setups - any call would throw. + var bindingMgrMock = new Mock(MockBehavior.Strict); + + var provider = new ManagedAppsHostProvider(context, Mock.Of(), bindingMgrMock.Object); + + await provider.StoreCredentialAsync(input); + } + + [Fact] + public async Task ManagedAppsHostProvider_EraseCredentialAsync_Interactive_RemovesBinding() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = ProdHost, + }); + + var bindingMgrMock = new Mock(MockBehavior.Strict); + bindingMgrMock.Setup(x => x.SignOut($"https://{ProdHost}")); + + var provider = new ManagedAppsHostProvider(new TestCommandContext(), + Mock.Of(), bindingMgrMock.Object); + + await provider.EraseCredentialAsync(input); + + bindingMgrMock.VerifyAll(); + } + + #endregion + + private class MockMsAuthResult : IMicrosoftAuthenticationResult + { + public string AccessToken { get; set; } + public string AccountUpn { get; set; } + } + } +} diff --git a/src/shared/Microsoft.ManagedApps.Tests/Microsoft.ManagedApps.Tests.csproj b/src/shared/Microsoft.ManagedApps.Tests/Microsoft.ManagedApps.Tests.csproj new file mode 100644 index 0000000000..d43308db35 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps.Tests/Microsoft.ManagedApps.Tests.csproj @@ -0,0 +1,29 @@ + + + + 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.ManagedApps/InternalsVisibleTo.cs b/src/shared/Microsoft.ManagedApps/InternalsVisibleTo.cs new file mode 100644 index 0000000000..e902d49b4f --- /dev/null +++ b/src/shared/Microsoft.ManagedApps/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly:InternalsVisibleTo("Microsoft.ManagedApps.Tests")] diff --git a/src/shared/Microsoft.ManagedApps/ManagedAppsBindingManager.cs b/src/shared/Microsoft.ManagedApps/ManagedAppsBindingManager.cs new file mode 100644 index 0000000000..9590529755 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps/ManagedAppsBindingManager.cs @@ -0,0 +1,93 @@ +using GitCredentialManager; + +namespace Microsoft.ManagedApps +{ + /// + /// Remembers which Microsoft Entra account was last used to authenticate against a given + /// Microsoft Managed Apps environment host, so that subsequent silent token acquisitions + /// (via MSAL) can be given the right account hint without prompting the user again. + /// + /// + /// This mirrors Microsoft.AzureRepos.AzureReposBindingManager. Only a non-secret + /// account/UPN value is stored, in Git configuration (not the secure credential store) - + /// there is no long-lived credential to cache ourselves; the access token itself always + /// comes fresh from MSAL's own cache. + /// + public interface IManagedAppsBindingManager + { + /// + /// Get the account last bound to the given environment host, or null if none exists. + /// + string GetAccount(string host); + + /// + /// Bind an account to the given environment host. + /// + void SignIn(string host, string account); + + /// + /// Remove any account binding for the given environment host. + /// + void SignOut(string host); + } + + public class ManagedAppsBindingManager : IManagedAppsBindingManager + { + private readonly ITrace _trace; + private readonly IGit _git; + + public ManagedAppsBindingManager(ICommandContext context) : this(context.Trace, context.Git) { } + + public ManagedAppsBindingManager(ITrace trace, IGit git) + { + EnsureArgument.NotNull(trace, nameof(trace)); + EnsureArgument.NotNull(git, nameof(git)); + + _trace = trace; + _git = git; + } + + public string GetAccount(string host) + { + EnsureArgument.NotNullOrWhiteSpace(host, nameof(host)); + + IGitConfiguration config = _git.GetConfiguration(); + + if (config.TryGet(GitConfigurationLevel.Global, GitConfigurationType.Raw, GetAccountKey(host), out string account)) + { + return account; + } + + return null; + } + + public void SignIn(string host, string account) + { + EnsureArgument.NotNullOrWhiteSpace(host, nameof(host)); + + if (string.IsNullOrWhiteSpace(account)) + { + _trace.WriteLine("Not recording an account binding - no account name is available."); + return; + } + + _trace.WriteLine($"Binding account '{account}' to Microsoft Managed Apps host '{host}'..."); + IGitConfiguration config = _git.GetConfiguration(); + config.Set(GitConfigurationLevel.Global, GetAccountKey(host), account); + } + + public void SignOut(string host) + { + EnsureArgument.NotNullOrWhiteSpace(host, nameof(host)); + + _trace.WriteLine($"Removing account binding for Microsoft Managed Apps host '{host}'..."); + IGitConfiguration config = _git.GetConfiguration(); + config.Unset(GitConfigurationLevel.Global, GetAccountKey(host)); + } + + private static string GetAccountKey(string host) + { + return $"{Constants.GitConfiguration.Credential.SectionName}.managedApps.{host}.account"; + } + } +} diff --git a/src/shared/Microsoft.ManagedApps/ManagedAppsCloudEnvironment.cs b/src/shared/Microsoft.ManagedApps/ManagedAppsCloudEnvironment.cs new file mode 100644 index 0000000000..3f52db60e4 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps/ManagedAppsCloudEnvironment.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GitCredentialManager; + +namespace Microsoft.ManagedApps +{ + /// + /// Represents a single Microsoft Managed Apps deployment cloud environment (for example + /// "prod", "preprod", "test", or a future sovereign cloud such as + /// "gov"/"high"/"dod"/"mooncake"). + /// + /// + /// A "cloud environment" here is distinct from a Power Platform/Dataverse "environment": + /// many individual Power Platform environments (each with their own opaque per-environment + /// Git host) belong to the same cloud environment. and the + /// resource/scopes are tracked independently rather than derived from one another, since + /// they are not guaranteed to follow the same naming pattern across cloud environments. + /// + public sealed class ManagedAppsCloudEnvironment + { + public ManagedAppsCloudEnvironment(string name, string hostSuffix, string resourceAudience, IReadOnlyList scopes) + { + EnsureArgument.NotNullOrWhiteSpace(name, nameof(name)); + + Name = name; + HostSuffix = hostSuffix; + ResourceAudience = resourceAudience; + Scopes = scopes; + } + + /// + /// Cloud environment identifier, e.g. "prod", "preprod", "test", "gov", "high", "dod", + /// "mooncake". + /// + public string Name { get; } + + /// + /// Host suffix used to recognize a Git remote as belonging to this cloud environment, + /// e.g. ".environment.api.preprod.powerplatform.com". + /// + public string HostSuffix { get; } + + /// + /// Resource URI used only for Managed Identity token requests (a single resource + /// string, not a scopes array - see ). + /// + public string ResourceAudience { get; } + + /// + /// Full OAuth scope URIs (plus "offline_access") requested for interactive user, + /// service principal, and workload federation authentication. + /// + public IReadOnlyList Scopes { get; } + + /// + /// A cloud environment only participates in host matching once it has a complete + /// definition (host suffix, resource, and scopes all present). This deliberately means + /// an incomplete cloud environment (e.g. a known host suffix with resource/scopes not + /// yet defined by the service) is never claimed-then-failed; it simply isn't matched, + /// and the request safely falls through to the next provider (typically the generic + /// OAuth provider). + /// + public bool IsComplete => + !string.IsNullOrWhiteSpace(HostSuffix) && + !string.IsNullOrWhiteSpace(ResourceAudience) && + Scopes != null && Scopes.Count > 0; + + #region Compiled-in defaults + + /// + /// Compiled-in cloud environment defaults. Adding, extending, or completing a cloud + /// environment should be limited to a single entry in this table - no other code in + /// this project should ever need to branch on cloud environment name/identity. + /// + public static readonly IReadOnlyList CompiledInDefaults = new[] + { + new ManagedAppsCloudEnvironment( + name: "prod", + hostSuffix: ".environment.api.powerplatform.com", + resourceAudience: "https://api.powerplatform.com", + scopes: new[] + { + "https://api.powerplatform.com/.default", + "offline_access", + }), + + // preprod/test: host suffix is already known, but resource/scopes have not yet + // been defined by the service. Left as incomplete (null) entries on purpose - + // TryMatch will not match these hosts until both fields are filled in here, or + // completed via `credential.managedAppsCloudEnvironment..resource` / `.scopes` + // configuration (field-level config merge - see ApplyConfigOverrides below). + new ManagedAppsCloudEnvironment( + name: "preprod", + hostSuffix: ".environment.api.preprod.powerplatform.com", + resourceAudience: null, + scopes: null), + + new ManagedAppsCloudEnvironment( + name: "test", + hostSuffix: ".environment.api.test.powerplatform.com", + resourceAudience: null, + scopes: null), + + // Gov/High/DoD/Mooncake, and any future sovereign clouds: add one complete + // entry each here once the service confirms host suffix + resource + scopes. + // No other code changes should be required. + }; + + #endregion + + #region Matching + + /// + /// Compute the effective cloud environment table: compiled-in defaults merged, + /// field-by-field, with any `credential.managedAppsCloudEnvironment.<name>.*` + /// Git configuration. + /// + public static IReadOnlyList GetEffectiveCloudEnvironments(IGitConfiguration config) + { + EnsureArgument.NotNull(config, nameof(config)); + + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (ManagedAppsCloudEnvironment cloudEnvironment in CompiledInDefaults) + { + byName[cloudEnvironment.Name] = CloudEnvironmentBuilder.FromCloudEnvironment(cloudEnvironment); + } + + ApplyConfigOverrides(config, byName); + + return byName.Values.Select(b => b.ToCloudEnvironment()).ToArray(); + } + + private static void ApplyConfigOverrides(IGitConfiguration config, IDictionary byName) + { + void Apply(string property, Action assign) + { + // Enumerating across all configuration levels relies on Git's own + // system -> global -> local listing order, so a later (more specific) entry + // for the same cloud environment/property correctly overrides an earlier one. + config.Enumerate(GitConfigurationLevel.All, Constants.GitConfiguration.Credential.SectionName, property, entry => + { + if (GitConfigurationKeyComparer.TrySplit(entry.Key, out _, out string scope, out _) && + scope != null && + scope.StartsWith(ManagedAppsConstants.CloudEnvironmentConfigScopePrefix, StringComparison.Ordinal)) + { + string cloudEnvironmentName = scope.Substring(ManagedAppsConstants.CloudEnvironmentConfigScopePrefix.Length); + if (!string.IsNullOrWhiteSpace(cloudEnvironmentName)) + { + if (!byName.TryGetValue(cloudEnvironmentName, out CloudEnvironmentBuilder builder)) + { + builder = new CloudEnvironmentBuilder(cloudEnvironmentName); + byName[cloudEnvironmentName] = builder; + } + + assign(builder, entry.Value); + } + } + + return true; + }); + } + + Apply(ManagedAppsConstants.GitConfigCloudEnvironmentKeys.HostSuffix, (b, v) => b.HostSuffix = v); + Apply(ManagedAppsConstants.GitConfigCloudEnvironmentKeys.Resource, (b, v) => b.ResourceAudience = v); + Apply(ManagedAppsConstants.GitConfigCloudEnvironmentKeys.Scopes, + (b, v) => b.Scopes = v?.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)); + } + + private sealed class CloudEnvironmentBuilder + { + public CloudEnvironmentBuilder(string name) => Name = name; + + public string Name { get; } + public string HostSuffix { get; set; } + public string ResourceAudience { get; set; } + public IReadOnlyList Scopes { get; set; } + + public static CloudEnvironmentBuilder FromCloudEnvironment(ManagedAppsCloudEnvironment cloudEnvironment) => + new CloudEnvironmentBuilder(cloudEnvironment.Name) + { + HostSuffix = cloudEnvironment.HostSuffix, + ResourceAudience = cloudEnvironment.ResourceAudience, + Scopes = cloudEnvironment.Scopes, + }; + + public ManagedAppsCloudEnvironment ToCloudEnvironment() => + new ManagedAppsCloudEnvironment(Name, HostSuffix, ResourceAudience, Scopes); + } + + /// + /// Try and find the (complete) cloud environment matching the given host, taking into + /// account any configuration-based additions/completions. Incomplete cloud + /// environments are never matched - see . + /// + public static bool TryMatch(ITrace trace, IGitConfiguration config, string host, out ManagedAppsCloudEnvironment cloudEnvironment) + { + EnsureArgument.NotNull(trace, nameof(trace)); + + return TryMatch(trace, GetEffectiveCloudEnvironments(config), host, out cloudEnvironment); + } + + /// + /// Try and find the (complete) cloud environment matching the given host within the + /// supplied candidate table. Exposed separately from + /// for ease of unit testing pure matching + /// behavior. + /// + internal static bool TryMatch(ITrace trace, IEnumerable candidates, string host, out ManagedAppsCloudEnvironment cloudEnvironment) + { + cloudEnvironment = null; + + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + ManagedAppsCloudEnvironment best = null; + + foreach (ManagedAppsCloudEnvironment candidate in candidates) + { + if (string.IsNullOrEmpty(candidate.HostSuffix) || + !host.EndsWith(candidate.HostSuffix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!candidate.IsComplete) + { + trace?.WriteLine( + $"Host '{host}' matches Microsoft Managed Apps cloud environment '{candidate.Name}' by suffix, " + + "but that cloud environment is not yet fully configured (missing resource and/or scopes) - " + + "not claiming this request."); + continue; + } + + // Prefer the longest (most specific) matching suffix. + if (best is null || candidate.HostSuffix.Length > best.HostSuffix.Length) + { + best = candidate; + } + } + + cloudEnvironment = best; + return cloudEnvironment != null; + } + + #endregion + } +} diff --git a/src/shared/Microsoft.ManagedApps/ManagedAppsConstants.cs b/src/shared/Microsoft.ManagedApps/ManagedAppsConstants.cs new file mode 100644 index 0000000000..2c0c6e8486 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps/ManagedAppsConstants.cs @@ -0,0 +1,73 @@ +using System; + +namespace Microsoft.ManagedApps +{ + /// + /// Constants for the Microsoft Managed Apps Git host provider. + /// + public static class ManagedAppsConstants + { + // Microsoft Entra ID authority base URL. + public const string AadAuthorityBaseUrl = "https://login.microsoftonline.com/"; + + public const string AadAuthoritySegment = "organizations"; + + // Well-known public client ID for this integration. + // This is a public/native client (no client secret) - not a secret value. + public const string AadClientId = "c4ee713f-aede-4371-91fc-921aa3a5ded9"; + + // Default loopback redirect URI. + public static readonly Uri AadRedirectUri = new Uri("http://localhost"); + + // Prefix for the `credential.managedAppsCloudEnvironment..*` configuration + // subsection used to add or complete cloud environment definitions without a GCM + // code change. + public const string CloudEnvironmentConfigScopePrefix = "managedAppsCloudEnvironment."; + + public static class GitConfigCloudEnvironmentKeys + { + public const string HostSuffix = "hostSuffix"; + public const string Resource = "resource"; + public const string Scopes = "scopes"; + } + + public static class EnvironmentVariables + { + public const string DevAadClientId = "GCM_DEV_MANAGEDAPPS_CLIENTID"; + public const string DevAadRedirectUri = "GCM_DEV_MANAGEDAPPS_REDIRECTURI"; + public const string DevAadAuthorityBaseUri = "GCM_DEV_MANAGEDAPPS_AUTHORITYBASEURI"; + public const string ServicePrincipalId = "GCM_MANAGEDAPPS_SERVICE_PRINCIPAL"; + public const string ServicePrincipalSecret = "GCM_MANAGEDAPPS_SERVICE_PRINCIPAL_SECRET"; + public const string ServicePrincipalCertificateThumbprint = "GCM_MANAGEDAPPS_SERVICE_PRINCIPAL_CERT_THUMBPRINT"; + public const string ServicePrincipalCertificateSendX5C = "GCM_MANAGEDAPPS_SERVICE_PRINCIPAL_CERT_SEND_X5C"; + public const string ManagedIdentity = "GCM_MANAGEDAPPS_MANAGEDIDENTITY"; + public const string WorkloadFederation = "GCM_MANAGEDAPPS_WIF"; + public const string WorkloadFederationClientId = "GCM_MANAGEDAPPS_WIF_CLIENTID"; + public const string WorkloadFederationTenantId = "GCM_MANAGEDAPPS_WIF_TENANTID"; + public const string WorkloadFederationAudience = "GCM_MANAGEDAPPS_WIF_AUDIENCE"; + public const string WorkloadFederationAssertion = "GCM_MANAGEDAPPS_WIF_ASSERTION"; + public const string WorkloadFederationManagedIdentity = "GCM_MANAGEDAPPS_WIF_MANAGEDIDENTITY"; + } + + public static class GitConfiguration + { + public static class Credential + { + public const string DevAadClientId = "managedAppsDevClientId"; + public const string DevAadRedirectUri = "managedAppsDevRedirectUri"; + public const string DevAadAuthorityBaseUri = "managedAppsDevAuthorityBaseUri"; + public const string ServicePrincipal = "managedAppsServicePrincipal"; + public const string ServicePrincipalSecret = "managedAppsServicePrincipalSecret"; + public const string ServicePrincipalCertificateThumbprint = "managedAppsServicePrincipalCertificateThumbprint"; + public const string ServicePrincipalCertificateSendX5C = "managedAppsServicePrincipalCertificateSendX5C"; + public const string ManagedIdentity = "managedAppsManagedIdentity"; + public const string WorkloadFederation = "managedAppsWorkloadFederation"; + public const string WorkloadFederationClientId = "managedAppsWorkloadFederationClientId"; + public const string WorkloadFederationTenantId = "managedAppsWorkloadFederationTenantId"; + public const string WorkloadFederationAudience = "managedAppsWorkloadFederationAudience"; + public const string WorkloadFederationAssertion = "managedAppsWorkloadFederationAssertion"; + public const string WorkloadFederationManagedIdentity = "managedAppsWorkloadFederationManagedIdentity"; + } + } + } +} diff --git a/src/shared/Microsoft.ManagedApps/ManagedAppsHostProvider.cs b/src/shared/Microsoft.ManagedApps/ManagedAppsHostProvider.cs new file mode 100644 index 0000000000..3dd55540e2 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps/ManagedAppsHostProvider.cs @@ -0,0 +1,465 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using GitCredentialManager; +using GitCredentialManager.Authentication; +using KnownGitCfg = GitCredentialManager.Constants.GitConfiguration; + +namespace Microsoft.ManagedApps +{ + /// + /// Host provider for Git repositories hosted by Microsoft Managed Apps' Power Platform + /// environment Git service. + /// + public class ManagedAppsHostProvider : HostProvider + { + private readonly IMicrosoftAuthentication _msAuth; + private readonly IManagedAppsBindingManager _bindingManager; + + public ManagedAppsHostProvider(ICommandContext context) + : this(context, new MicrosoftAuthentication(context), new ManagedAppsBindingManager(context)) + { + } + + public ManagedAppsHostProvider(ICommandContext context, IMicrosoftAuthentication msAuth, + IManagedAppsBindingManager bindingManager) + : base(context) + { + EnsureArgument.NotNull(msAuth, nameof(msAuth)); + EnsureArgument.NotNull(bindingManager, nameof(bindingManager)); + + _msAuth = msAuth; + _bindingManager = bindingManager; + } + + #region IHostProvider + + public override string Id => "microsoft-managed-apps"; + + public override string Name => "Microsoft Managed Apps"; + + public override IEnumerable SupportedAuthorityIds => MicrosoftAuthentication.AuthorityIds; + + public override bool IsSupported(InputArguments input) + { + if (input is null || !input.TryGetHostAndPort(out string hostName, out _)) + { + return false; + } + + bool isHttp = StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http"); + bool isHttps = StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https"); + + return (isHttp || isHttps) && TryMatchCloudEnvironment(hostName, out _); + } + + public override string GetServiceName(InputArguments input) + { + // Authentication is scoped to the environment (host), never the path - one + // sign-in per host, regardless of which repository under it is being cloned. + Uri remote = input.GetRemoteUri(includeUser: false); + return new Uri($"{remote.Scheme}://{remote.Authority}").AbsoluteUri.TrimEnd('/'); + } + + public override async Task GetCredentialAsync(InputArguments input) + { + // Never consult the OS credential store: every authentication mode either + // re-derives a fresh credential via MSAL (which maintains its own silent/refresh + // token cache) or via a non-interactive federated/managed-identity/service-principal + // flow. There is no PAT-equivalent, long-lived credential for us to cache + // ourselves - this mirrors AzureReposHostProvider's OAuth-token (non-PAT) branch. + ICredential credential = await GenerateCredentialAsync(input); + return new GetCredentialResult(credential); + } + + public override Task StoreCredentialAsync(InputArguments input) + { + if (UseManagedIdentity(out _) || UseWorkloadFederation(out _) || UseServicePrincipal(out _)) + { + Context.Trace.WriteLine("Nothing to store for non-interactive authentication."); + return Task.CompletedTask; + } + + string serviceName = GetServiceName(input); + Context.Trace.WriteLine($"Recording account binding for '{serviceName}'..."); + _bindingManager.SignIn(serviceName, input.UserName); + return Task.CompletedTask; + } + + public override Task EraseCredentialAsync(InputArguments input) + { + if (UseManagedIdentity(out _) || UseWorkloadFederation(out _) || UseServicePrincipal(out _)) + { + Context.Trace.WriteLine("Nothing to erase for non-interactive authentication."); + return Task.CompletedTask; + } + + string serviceName = GetServiceName(input); + Context.Trace.WriteLine($"Removing account binding for '{serviceName}'..."); + _bindingManager.SignOut(serviceName); + return Task.CompletedTask; + } + + #endregion + + public override async Task GenerateCredentialAsync(InputArguments input) + { + ThrowIfDisposed(); + ThrowIfUnsafeRemote(input); + + if (!input.TryGetHostAndPort(out string hostName, out _) || !TryMatchCloudEnvironment(hostName, out ManagedAppsCloudEnvironment cloudEnvironment)) + { + throw new Trace2Exception(Context.Trace2, + $"'{input.Host}' is not a recognized Microsoft Managed Apps environment host."); + } + + string[] scopes = cloudEnvironment.Scopes.ToArray(); + Context.Trace.WriteLine($"Matched cloud environment '{cloudEnvironment.Name}' (resource='{cloudEnvironment.ResourceAudience}', scopes=[{string.Join(", ", scopes)}])."); + + if (UseManagedIdentity(out string mid)) + { + Context.Trace.WriteLine($"Getting Azure access token for managed identity '{mid}' (cloud environment '{cloudEnvironment.Name}')..."); + IMicrosoftAuthenticationResult miResult = await _msAuth.GetTokenForManagedIdentityAsync(mid, cloudEnvironment.ResourceAudience); + return new GitCredential(mid, miResult.AccessToken); + } + + if (UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts)) + { + Context.Trace.WriteLine($"Getting Azure access token using workload identity federation (scenario: {fedOpts.Scenario}, cloud environment '{cloudEnvironment.Name}')..."); + IMicrosoftAuthenticationResult fedResult = await _msAuth.GetTokenUsingWorkloadFederationAsync(fedOpts, scopes); + return new GitCredential(fedOpts.ClientId, fedResult.AccessToken); + } + + if (UseServicePrincipal(out ServicePrincipalIdentity sp)) + { + Context.Trace.WriteLine($"Getting Azure access token for service principal '{sp.TenantId}/{sp.Id}' (cloud environment '{cloudEnvironment.Name}')..."); + IMicrosoftAuthenticationResult spResult = await _msAuth.GetTokenForServicePrincipalAsync(sp, scopes); + return new GitCredential(sp.Id, spResult.AccessToken); + } + + // Interactive/silent user authentication (default path). + string serviceName = GetServiceName(input); + string accountHint = input.UserName ?? _bindingManager.GetAccount(serviceName); + + Context.Trace.WriteLine(accountHint is null + ? $"No existing account binding found for '{serviceName}' - will prompt for account selection." + : $"Using account hint '{accountHint}' for '{serviceName}' (cloud environment '{cloudEnvironment.Name}')."); + + IMicrosoftAuthenticationResult result = await _msAuth.GetTokenForUserAsync( + GetAuthority(), GetClientId(), GetRedirectUri(), scopes, accountHint, msaPt: false); + + Context.Trace.WriteLineSecrets( + $"Acquired Azure access token. Account='{result.AccountUpn}' Token='{{0}}'", + new object[] { result.AccessToken }); + + return new GitCredential(result.AccountUpn, result.AccessToken); + } + + private bool TryMatchCloudEnvironment(string host, out ManagedAppsCloudEnvironment cloudEnvironment) + { + return ManagedAppsCloudEnvironment.TryMatch(Context.Trace, Context.Git.GetConfiguration(), host, out cloudEnvironment); + } + + private void ThrowIfUnsafeRemote(InputArguments input) + { + if (!Context.Settings.AllowUnsafeRemotes && + StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) + { + throw new Trace2Exception(Context.Trace2, + "Unencrypted HTTP is not recommended for Microsoft Managed Apps. " + + "Ensure the repository remote URL is using HTTPS " + + $"or see {Constants.HelpUrls.GcmUnsafeRemotes} about how to allow unsafe remotes."); + } + } + + private string GetAuthority() + { + string baseUri = ManagedAppsConstants.AadAuthorityBaseUrl; + + if (Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.DevAadAuthorityBaseUri, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.DevAadAuthorityBaseUri, + out string devBaseUri) && !string.IsNullOrWhiteSpace(devBaseUri)) + { + baseUri = devBaseUri.TrimEnd('/') + "/"; + } + + return baseUri + ManagedAppsConstants.AadAuthoritySegment; + } + + private string GetClientId() + { + if (Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.DevAadClientId, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.DevAadClientId, + out string clientId) && !string.IsNullOrWhiteSpace(clientId)) + { + return clientId; + } + + return ManagedAppsConstants.AadClientId; + } + + private Uri GetRedirectUri() + { + if (Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.DevAadRedirectUri, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.DevAadRedirectUri, + out string redirectUriStr) && Uri.TryCreate(redirectUriStr, UriKind.Absolute, out Uri redirectUri)) + { + return redirectUri; + } + + return ManagedAppsConstants.AadRedirectUri; + } + + private bool UseManagedIdentity(out string mid) + { + return Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.ManagedIdentity, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.ManagedIdentity, + out mid) && + !string.IsNullOrWhiteSpace(mid); + } + + private bool UseServicePrincipal(out ServicePrincipalIdentity sp) + { + if (!Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.ServicePrincipalId, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.ServicePrincipal, + out string spStr) || string.IsNullOrWhiteSpace(spStr)) + { + sp = null; + return false; + } + + string[] split = spStr.Split(new[] { '/' }, count: 2); + + if (split.Length < 1 || string.IsNullOrWhiteSpace(split[0])) + { + Context.Streams.Error.WriteLine("error: unable to use configured service principal - missing tenant ID in configuration"); + sp = null; + return false; + } + + if (split.Length < 2 || string.IsNullOrWhiteSpace(split[1])) + { + Context.Streams.Error.WriteLine("error: unable to use configured service principal - missing client ID in configuration"); + sp = null; + return false; + } + + string tenantId = split[0]; + string clientId = split[1]; + + sp = new ServicePrincipalIdentity + { + Id = clientId, + TenantId = tenantId, + }; + + bool hasClientSecret = Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.ServicePrincipalSecret, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.ServicePrincipalSecret, + out string clientSecret); + + bool hasCertThumbprint = Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.ServicePrincipalCertificateThumbprint, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.ServicePrincipalCertificateThumbprint, + out string certThumbprint); + + if (hasCertThumbprint && hasClientSecret) + { + Context.Streams.Error.WriteLine("warning: both service principal client secret and certificate thumbprint are configured - using certificate"); + } + + if (hasCertThumbprint) + { + sp.SendX5C = Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.ServicePrincipalCertificateSendX5C, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.ServicePrincipalCertificateSendX5C, + out string certHasX5CStr) && certHasX5CStr.ToBooleanyOrDefault(false); + + X509Certificate2 cert = X509Utils.GetCertificateByThumbprint(certThumbprint); + if (cert is null) + { + Context.Streams.Error.WriteLine($"error: unable to find certificate with thumbprint '{certThumbprint}' for service principal"); + sp = null; + return false; + } + + sp.Certificate = cert; + } + else if (hasClientSecret) + { + sp.ClientSecret = clientSecret; + } + + return true; + } + + private bool UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts) + { + if (!Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.WorkloadFederation, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.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( + ManagedAppsConstants.EnvironmentVariables.WorkloadFederationClientId, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.GitConfiguration.Credential.WorkloadFederationClientId, + out string clientId); + + bool hasTenantId = Context.Settings.TryGetSetting( + ManagedAppsConstants.EnvironmentVariables.WorkloadFederationTenantId, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.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( + ManagedAppsConstants.EnvironmentVariables.WorkloadFederationAudience, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.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( + ManagedAppsConstants.EnvironmentVariables.WorkloadFederationAssertion, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.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( + ManagedAppsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity, + KnownGitCfg.Credential.SectionName, + ManagedAppsConstants.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; + } + } +} diff --git a/src/shared/Microsoft.ManagedApps/Microsoft.ManagedApps.csproj b/src/shared/Microsoft.ManagedApps/Microsoft.ManagedApps.csproj new file mode 100644 index 0000000000..52ca889ed4 --- /dev/null +++ b/src/shared/Microsoft.ManagedApps/Microsoft.ManagedApps.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + net10.0;net472 + Microsoft.ManagedApps + Microsoft.ManagedApps + false + latest + + + + + + + + + + + diff --git a/src/shared/TestInfrastructure/Objects/TestOAuth2WebBrowser.cs b/src/shared/TestInfrastructure/Objects/TestOAuth2WebBrowser.cs index 547aaf360b..86f011cddc 100644 --- a/src/shared/TestInfrastructure/Objects/TestOAuth2WebBrowser.cs +++ b/src/shared/TestInfrastructure/Objects/TestOAuth2WebBrowser.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -20,12 +21,13 @@ public Uri UpdateRedirectUri(Uri uri) return uri; } - public async Task GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct) + public async Task> GetAuthenticationResponseAsync( + Uri authorizationUri, Uri redirectUri, OAuth2ResponseMode responseMode, CancellationToken ct) { using (var response = await _httpClient.SendAsync(HttpMethod.Get, authorizationUri)) { response.EnsureSuccessStatusCode(); - return response.Headers.Location; + return response.Headers.Location.GetQueryParameters(); } } }