From 88cc045789b89d0cfb45967ebcf192d8b8894028 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 22:00:06 +0000 Subject: [PATCH 01/17] build(deps): bump actions/setup-dotnet from 5.2.0 to 5.3.0 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.2.0 to 5.3.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5.2.0...v5.3.0) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 943e7c465b..fc18965e0e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: 10.0.x diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 1d4488405e..5105cfae51 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: 10.0.x @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: 10.0.x @@ -129,7 +129,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@v5.3.0 with: dotnet-version: 10.0.x From 5e80db1de6b31121b27627d8e2863558f5bd5ac6 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 4 Jun 2026 18:54:25 +0200 Subject: [PATCH 02/17] globals.json: specify the SDK version precisely According to https://github.com/actions/setup-dotnet/issues/739, this is required if we want to upgrade to `actions/setup-dotnet@5.3.0`. Suggested by Marc Becker. Signed-off-by: Johannes Schindelin --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } } From 3a559b8eb43f356181b475ecf2e611868e2ae1ef Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 18 Jun 2026 13:50:52 +0100 Subject: [PATCH 03/17] VERSION: bump to 2.9.0 Signed-off-by: Matthew John Cheetham --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 0ab902011a..45a92322df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.0.0 +2.9.0.0 From ea84a2534c1721c052a42e81c12e6c7f0a504abb Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 17 Jun 2026 17:17:00 +0100 Subject: [PATCH 04/17] oauth: support non-query response modes The authorization code flow only handled the default 'query' response mode, where the loopback browser reads the response from the request query string and returns a URI for the client to parse. The 'fragment' and 'form_post' modes deliver the response over channels a URI cannot represent - the fragment is never transmitted to the server, and form_post arrives as a urlencoded POST body - so hosts that mandate those modes could not be used. Have the browser return the parsed response parameters regardless of transport and tell it which mode to expect. The system browser reads the POST body for form_post, and for fragment serves a small page that re-submits the parameters as a form POST to the loopback redirect - keeping the authorization code out of the URL, browser history, and server logs. The client sends 'response_mode' only when it is not the default, so existing query-mode requests are unchanged. Assisted-by: Claude Opus 4.8 Signed-off-by: Matthew John Cheetham --- .../Cloud/BitbucketOAuth2ClientTest.cs | 9 +- .../DataCenter/BitbucketOAuth2ClientTest.cs | 9 +- .../Authentication/OAuth2ClientTests.cs | 83 ++++++++++++ .../Authentication/OAuth2ResponseModeTests.cs | 42 ++++++ .../OAuth2SystemWebBrowserTests.cs | 16 +++ .../Authentication/OAuth/IOAuth2WebBrowser.cs | 14 +- .../Core/Authentication/OAuth/OAuth2Client.cs | 28 ++-- .../Authentication/OAuth/OAuth2Constants.cs | 4 + .../OAuth/OAuth2ResponseMode.cs | 90 +++++++++++++ .../OAuth/OAuth2SystemWebBrowser.cs | 122 ++++++++++++++---- src/shared/Core/Constants.cs | 4 + .../Objects/TestOAuth2WebBrowser.cs | 6 +- 12 files changed, 383 insertions(+), 44 deletions(-) create mode 100644 src/shared/Core.Tests/Authentication/OAuth2ResponseModeTests.cs create mode 100644 src/shared/Core/Authentication/OAuth/OAuth2ResponseMode.cs 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/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..9b36d18ca9 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -145,6 +145,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 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(); } } } From 6cefbd9b764d2f7a2d004cde4e6b2a013ba1638d Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 18 Jun 2026 08:37:18 +0100 Subject: [PATCH 05/17] generic-oauth: add response mode setting Now that the OAuth client can request non-query response modes, expose the choice to generic host configurations through a new optional setting (credential..oauthResponseMode, or the GCM_OAUTH_RESPONSE_MODE environment variable). The built-in providers target known hosts that use 'query', so the generic provider is the only place an arbitrary host's response mode needs to be configurable. The setting is optional and defaults to 'query', so existing configurations are unaffected. An unrecognised value is traced and falls back to the default rather than failing configuration outright. Assisted-by: Claude Opus 4.8 Signed-off-by: Matthew John Cheetham --- docs/generic-oauth.md | 24 +++++++ .../Core.Tests/GenericOAuthConfigTests.cs | 71 +++++++++++++++++++ src/shared/Core/Constants.cs | 2 + src/shared/Core/GenericHostProvider.cs | 3 +- src/shared/Core/GenericOAuthConfig.cs | 18 +++++ 5 files changed, 117 insertions(+), 1 deletion(-) 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/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/Constants.cs b/src/shared/Core/Constants.cs index 9b36d18ca9..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"; @@ -195,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/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index a66729a8a1..39af1884cd 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -275,7 +275,8 @@ private async Task 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; From 16e9c7fd725b83b892f436b74541774a608ce4e5 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 18 Jun 2026 13:55:52 +0100 Subject: [PATCH 06/17] msal: update to latest MSAL 4.82.2 Update our MSAL library packages to the current latest release, which is 4.82.2 at time of writing. Signed-off-by: Matthew John Cheetham --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 @@ - - - + + + From 29e2f829c8b394c502cde9499479c13f4f70e59b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 18 Jun 2026 15:18:43 +0100 Subject: [PATCH 07/17] msauth: resolve auth flow before selecting redirect URI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "auto" Microsoft authentication flow type was resolved lazily, deep inside the interactive-token switch via `goto case`, after the MSAL public client application — and thus its redirect URI — had already been built. That entangled flow selection with app creation and made the effective flow hard to follow in traces. Resolve the flow up front in GetFlowType() instead, and drop the Auto pseudo-value from the enum so the method always returns a concrete flow (embedded web view, system web view, or device code). The resolved flow is traced before authentication starts. Knowing the flow up front also lets us choose the redirect URI. Only the system web view needs a real loopback redirect URI registered with the application; the other paths work with MSAL's default native-client redirect URI — "https://login.microsoftonline.com/common/oauth2/nativeclient" on .NET Framework, "http://localhost" on .NET Core. Forward the caller-provided redirect URI only when the system web view might be used and let MSAL supply the default otherwise via WithDefaultRedirectUri(). The Microsoft authentication diagnostic no longer calls GetFlowType() (which now needs a redirect URI and eagerly resolves auto); it reports the raw credential.msAuthFlow override instead. The now-unused IPublicClientApplication argument is dropped from the system web view capability checks. Assisted-by: Claude Opus 4.8 Signed-off-by: Matthew John Cheetham --- .../Authentication/MicrosoftAuthentication.cs | 88 +++++++++++++------ .../MicrosoftAuthenticationDiagnostic.cs | 10 ++- 2 files changed, 71 insertions(+), 27 deletions(-) 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/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 Date: Fri, 19 Jun 2026 09:21:27 +0000 Subject: [PATCH 08/17] build(deps): bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- .github/workflows/lint-docs.yml | 4 ++-- .github/workflows/validate-install-from-source.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index fc18965e0e..19cd069f84 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,7 +22,7 @@ jobs: language: [ 'csharp' ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5.3.0 diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 5105cfae51..08d9274fba 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -25,7 +25,7 @@ jobs: os: windows-11-arm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5.3.0 @@ -82,7 +82,7 @@ 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.3.0 @@ -126,7 +126,7 @@ jobs: runtime: [ osx-x64, osx-arm64 ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5.3.0 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 From 7bb63e8ab7cd20f07c6ff08d6c63782893fcc739 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:32:18 +0000 Subject: [PATCH 09/17] build(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.3.0 to 5.4.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5.3.0...v5.4.0) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 19cd069f84..72d5418879 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.3.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 08d9274fba..9997aa42ae 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.3.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.3.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x @@ -129,7 +129,7 @@ jobs: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5.3.0 + uses: actions/setup-dotnet@v5.4.0 with: dotnet-version: 10.0.x From 69fc517083e7098058fff8ed820dac7b22ef4d93 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 29 Jun 2026 10:15:49 +0100 Subject: [PATCH 10/17] docs: fix broken links identified by linting The link to the Windows Credential Manager docs was broken - it used to point at: https://support.microsoft.com/en-us/windows/accessing-credential-manager-1b5c916a-6a16-889f-8581-fc16e8165ac0 ..but this now resolves instead to: https://support.microsoft.com/en-US/Windows/Security/credential-manager-in-windows ..so let's use that URL directly. Signed-off-by: Matthew John Cheetham --- docs/credstores.md | 2 +- docs/github-apideprecation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 From dcb5fd1cf76fda819187da60a38d931df91e48ef Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 7 Jul 2026 09:52:10 +0200 Subject: [PATCH 11/17] linux: use the appropriate PGP key to sign the Debian packages We have been using an inappropriate key for our Debian package signing; let's use a more appropriate one. Signed-off-by: Johannes Schindelin --- .azure-pipelines/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index a957609d89..6042eba2d3 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -639,7 +639,7 @@ extends: inlineOperation: | [ { - "KeyCode": "CP-453387-Pgp", + "KeyCode": "CP-500207-Pgp", "OperationCode": "LinuxSign", "ToolName": "sign", "ToolVersion": "1.0", From 73696fa693c07d679ef17e56eeeae59eacf99106 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 6 Jul 2026 18:48:36 +0100 Subject: [PATCH 12/17] browser: open AbsoluteUri to avoid double-escaping GCM launches the system browser for interactive OAuth by handing the authorization URL to the OS "shell execute" handler. On macOS that is /usr/bin/open, which validates the URL and, on finding any character that is not legal in a fully percent-encoded URL, re-encodes the whole query string. That step double-escapes parameters we had already encoded -- redirect_uri=http%3A%2F%2F... becomes redirect_uri=http%253A%252F%252F... -- and the authorization server rejects the redirect. Windows ShellExecuteEx forwards the string verbatim, so only macOS is affected. The trigger was a raw space in the query. Uri.ToString() is a display form that unescapes %20 back to a literal space (while leaving %2F alone), so building the launch string that way reintroduced spaces, most easily via the space-delimited scope parameter. This surfaced after MSAL began encoding spaces[1] as %20 rather than +; a literal + is left untouched by ToString(), which had masked the problem. Uri.AbsoluteUri keeps the query fully percent-encoded, so %20 stays %20 and macOS open accepts the URL unchanged. [1]: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/5128 Assisted-by: Claude Opus 4.8 Signed-off-by: Matthew John Cheetham --- src/shared/Core/ISessionManager.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) From ac4391282ccc704531918e29cce45e9d7aef6910 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 7 Jul 2026 09:58:29 +0200 Subject: [PATCH 13/17] linux: adjust the instructions how to verify the signatures With the ESRP-signed packages, there is a slightly different process. Most notably, the PGP key to verify against has changed and needs to be obtained from elsewhere. Signed-off-by: Johannes Schindelin --- docs/linux-validate-gpg.md | 40 +++++++++++++++----------------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/docs/linux-validate-gpg.md b/docs/linux-validate-gpg.md index 49150c1e59..d252f4cc97 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,14 +57,10 @@ 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 \ @@ -78,7 +70,7 @@ curl -s https://api.github.com/repos/ldennington/git-credential-manager/releases | 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 From cd57ef859aedbbed9de1fb567fb47b6ce7c5b76f Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 7 Jul 2026 09:59:44 +0200 Subject: [PATCH 14/17] linux: fix instructions where to download the latest archive Most users will want to stick to Debian packages. Those who have to resort to the archive will want to download them from the correct location. Signed-off-by: Johannes Schindelin --- docs/linux-validate-gpg.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/linux-validate-gpg.md b/docs/linux-validate-gpg.md index d252f4cc97..d19caae96e 100644 --- a/docs/linux-validate-gpg.md +++ b/docs/linux-validate-gpg.md @@ -63,7 +63,7 @@ curl -Os https://packages.microsoft.com/keys/microsoft-2025.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 \" \ From 4f4d57226a59b292f28c38f27f426901d0f7edcb Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 7 Jul 2026 09:16:01 +0100 Subject: [PATCH 15/17] VERSION: bump to 2.9.1 Signed-off-by: Matthew John Cheetham --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 45a92322df..111f6e3a6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.9.0.0 +2.9.1.0 From 6760f0ef069c994aa2bb1d703fb374986ee82a3e Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 7 Jul 2026 09:54:25 +0100 Subject: [PATCH 16/17] release: manually force CFS on release builds Explicitly use Central Feed Services (CFS) feeds for NuGet packages by replacing the normal, nuget.org, config file in the repo root at the start of the build jobs. This is required for compliance, and the auto-injected task that is supposed to do this automatically is flakey (it doesn't run sometimes?!) so do this manually. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/nuget.config | 11 +++++++++++ .azure-pipelines/release.yml | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 .azure-pipelines/nuget.config 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..1e1562dd74 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: @@ -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: From 30e3f7d39a106f4a4b5a4adf60b84fa6adab2931 Mon Sep 17 00:00:00 2001 From: Alan Castellanos Moreno Date: Tue, 11 Aug 2026 10:26:52 -0700 Subject: [PATCH 17/17] Add Microsoft Managed Apps host provider Adds a new GCM host provider, Microsoft.ManagedApps, that automatically authenticates against Git repositories hosted by Microsoft Managed Apps' Power Platform environment Git service, removing the need for users to hand-author a per-environment [credential "https://"] generic OAuth configuration block for every environment they clone from. - Host recognition (ManagedAppsCloudEnvironment): matches hosts against a suffix table per deployment "cloud environment" (prod today; preprod, test, and future sovereign clouds are addable as single compiled-in table entries once their resource/scopes are confirmed). A cloud environment only participates in matching once it has a complete definition (host suffix + resource + scopes), so unconfigured hosts safely fall through to the existing generic OAuth provider with zero regression risk. - Authentication: reuses the existing, shared MicrosoftAuthentication (MSAL-based) component, the same one Microsoft.AzureRepos uses, instead of the generic OAuth provider's per-host OAuth2 client. Because MSAL's token cache is keyed by client/authority/account rather than hostname, a single interactive sign-in is silently reused across every Managed Apps environment. - Non-interactive auth: supports managed identity, service principal, and workload identity federation for CI/CD, mirroring Microsoft.AzureRepos. - Extensibility: new cloud environments are a single-entry addition to a compiled-in table, or addable purely via Git configuration (credential.managedAppsCloudEnvironment..*) ahead of an official release. Known open item: prod's scopes currently use the broad https://api.powerplatform.com/.default grant rather than the originally intended granular GitRepositories.* permissions, which Microsoft Entra ID rejected with AADSTS65002 (first-party preauthorization required). Reverting once that is granted is a one-line change (see the comment in ManagedAppsCloudEnvironment.CompiledInDefaults). Adds Microsoft.ManagedApps.Tests with unit coverage for host matching, config-merge behavior, all four credential-generation paths, and the account-binding manager. Registered at Normal priority alongside AzureRepos/Bitbucket/GitHub/GitLab, before the generic catch-all provider. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Git-Credential-Manager.sln | 38 ++ .../Git-Credential-Manager.csproj | 1 + src/shared/Git-Credential-Manager/Program.cs | 2 + .../ManagedAppsBindingManagerTests.cs | 77 +++ .../ManagedAppsCloudEnvironmentTests.cs | 250 ++++++++++ .../ManagedAppsHostProviderTests.cs | 434 ++++++++++++++++ .../Microsoft.ManagedApps.Tests.csproj | 29 ++ .../InternalsVisibleTo.cs | 3 + .../ManagedAppsBindingManager.cs | 93 ++++ .../ManagedAppsCloudEnvironment.cs | 251 ++++++++++ .../ManagedAppsConstants.cs | 73 +++ .../ManagedAppsHostProvider.cs | 465 ++++++++++++++++++ .../Microsoft.ManagedApps.csproj | 20 + 13 files changed, 1736 insertions(+) create mode 100644 src/shared/Microsoft.ManagedApps.Tests/ManagedAppsBindingManagerTests.cs create mode 100644 src/shared/Microsoft.ManagedApps.Tests/ManagedAppsCloudEnvironmentTests.cs create mode 100644 src/shared/Microsoft.ManagedApps.Tests/ManagedAppsHostProviderTests.cs create mode 100644 src/shared/Microsoft.ManagedApps.Tests/Microsoft.ManagedApps.Tests.csproj create mode 100644 src/shared/Microsoft.ManagedApps/InternalsVisibleTo.cs create mode 100644 src/shared/Microsoft.ManagedApps/ManagedAppsBindingManager.cs create mode 100644 src/shared/Microsoft.ManagedApps/ManagedAppsCloudEnvironment.cs create mode 100644 src/shared/Microsoft.ManagedApps/ManagedAppsConstants.cs create mode 100644 src/shared/Microsoft.ManagedApps/ManagedAppsHostProvider.cs create mode 100644 src/shared/Microsoft.ManagedApps/Microsoft.ManagedApps.csproj 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/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 + + + + + + + + + + +