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();
}
}
}