From 30e3f7d39a106f4a4b5a4adf60b84fa6adab2931 Mon Sep 17 00:00:00 2001 From: Alan Castellanos Moreno Date: Tue, 11 Aug 2026 10:26:52 -0700 Subject: [PATCH] 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 + + + + + + + + + + +