diff --git a/CredentialProvider.Microsoft.Tests/CredentialProvider.Microsoft.Tests.csproj b/CredentialProvider.Microsoft.Tests/CredentialProvider.Microsoft.Tests.csproj index 2b475a4c..7f86fc08 100644 --- a/CredentialProvider.Microsoft.Tests/CredentialProvider.Microsoft.Tests.csproj +++ b/CredentialProvider.Microsoft.Tests/CredentialProvider.Microsoft.Tests.csproj @@ -4,6 +4,8 @@ latest false + true + true @@ -18,5 +20,4 @@ - diff --git a/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/AuthUtilTests.cs b/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/AuthUtilTests.cs index ae77e6a4..1f7f9414 100644 --- a/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/AuthUtilTests.cs +++ b/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/AuthUtilTests.cs @@ -45,27 +45,27 @@ public void TestCleanup() } [TestMethod] - public async Task GetAadAuthorityUri_WithoutAuthenticateHeaders_ReturnsCorrectAuthority() + public async Task GetAuthorizationInfoAsync_WithoutAuthenticateHeaders_ReturnsCorrectAuthority() { var requestUri = new Uri("https://example.pkgs.visualstudio.com/_packaging/feed/nuget/v3/index.json"); - var authorityUri = await authUtil.GetAadAuthorityUriAsync(requestUri, cancellationToken); + var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken); - authorityUri.Should().Be(organizationsAuthority); + authInfo.EntraAuthorityUri.Should().Be(organizationsAuthority); } [TestMethod] - public async Task GetAadAuthorityUri_WithoutAuthenticateHeadersAndPpe_ReturnsCorrectAuthority() + public async Task GetAuthorizationInfoAsync_WithoutAuthenticateHeadersAndPpe_ReturnsCorrectAuthority() { var requestUri = new Uri("https://example.pkgs.vsts.me/_packaging/feed/nuget/v3/index.json"); - var authorityUri = await authUtil.GetAadAuthorityUriAsync(requestUri, cancellationToken); + var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken); - authorityUri.Should().Be(new Uri("https://login.windows-ppe.net/organizations")); + authInfo.EntraAuthorityUri.Should().Be(new Uri("https://login.windows-ppe.net/organizations")); } [TestMethod] - public async Task GetAadAuthorityUri_WithoutAuthenticateHeadersAndPpeAndPpeOverride_ReturnsCorrectAuthority() + public async Task GetAuthorizationInfoAsync_WithoutAuthenticateHeadersAndPpeAndPpeOverride_ReturnsCorrectAuthority() { var ppeUris = new[] { @@ -79,26 +79,26 @@ public async Task GetAadAuthorityUri_WithoutAuthenticateHeadersAndPpeAndPpeOverr foreach (var ppeUri in ppeUris) { - var authorityUri = await authUtil.GetAadAuthorityUriAsync(ppeUri, cancellationToken); + var authInfo = await authUtil.GetAuthorizationInfoAsync(ppeUri, cancellationToken); - authorityUri.Should().Be(new Uri("https://login.windows-ppe.net/organizations")); + authInfo.EntraAuthorityUri.Should().Be(new Uri("https://login.windows-ppe.net/organizations")); } } [TestMethod] - public async Task GetAadAuthorityUri_WithAuthenticateHeaders_ReturnsCorrectAuthority() + public async Task GetAuthorizationInfoAsync_WithAuthenticateHeaders_ReturnsCorrectAuthority() { var requestUri = new Uri("https://example.pkgs.visualstudio.com/_packaging/feed/nuget/v3/index.json"); MockAadAuthorityHeaders(testAuthority); - var authorityUri = await authUtil.GetAadAuthorityUriAsync(requestUri, cancellationToken); + var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken); - authorityUri.Should().Be(testAuthority); + authInfo.EntraAuthorityUri.Should().Be(testAuthority); } [TestMethod] - public async Task GetAadAuthorityUri_WithAuthenticateHeadersAndEnvironmentOverride_ReturnsOverrideAuthority() + public async Task GetAuthorizationInfoAsync_WithAuthenticateHeadersAndEnvironmentOverride_ReturnsOverrideAuthority() { var requestUri = new Uri("https://example.pkgs.visualstudio.com/_packaging/feed/nuget/v3/index.json"); var overrideAuthority = new Uri("https://override.aad.authority.com"); @@ -106,9 +106,22 @@ public async Task GetAadAuthorityUri_WithAuthenticateHeadersAndEnvironmentOverri MockAadAuthorityHeaders(testAuthority); Environment.SetEnvironmentVariable(EnvUtil.MsalAuthorityEnvVar, overrideAuthority.ToString()); - var authorityUri = await authUtil.GetAadAuthorityUriAsync(requestUri, cancellationToken); + var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken); - authorityUri.Should().Be(overrideAuthority); + authInfo.EntraAuthorityUri.Should().Be(overrideAuthority); + } + + [TestMethod] + public async Task GetAuthorizationInfoAsync_WithTenantHeaders_ReturnsCorrectTenantId() + { + var requestUri = new Uri("https://example.pkgs.visualstudio.com/_packaging/feed/nuget/v3/index.json"); + + var testTenant = Guid.NewGuid(); + MockVssResourceTenantHeader(testTenant); + + var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken); + + authInfo.EntraTenantId.Should().Be(testTenant.ToString()); } [TestMethod] @@ -203,9 +216,9 @@ private void MockResponseHeaders(string key, string value) authUtil.HttpResponseHeaders.Add(key, value); } - private void MockVssResourceTenantHeader() + private void MockVssResourceTenantHeader(Guid? guid = null) { - MockResponseHeaders(AuthUtil.VssResourceTenant, Guid.NewGuid().ToString()); + MockResponseHeaders(AuthUtil.VssResourceTenant,(guid ?? Guid.NewGuid()).ToString()); } private void MockVssAuthorizationEndpointHeader() diff --git a/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/VstsCredentialProviderTests.cs b/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/VstsCredentialProviderTests.cs index 163a9e74..dedc44be 100644 --- a/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/VstsCredentialProviderTests.cs +++ b/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/VstsCredentialProviderTests.cs @@ -53,8 +53,8 @@ public void TestInitialize() mockAuthUtil = new Mock(); mockAuthUtil - .Setup(x => x.GetAadAuthorityUriAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(testAuthority)); + .Setup(x => x.GetAuthorizationInfoAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new AuthorizationInfo() { EntraAuthorityUri = testAuthority })); vstsCredentialProvider = new VstsCredentialProvider( mockLogger.Object, diff --git a/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs b/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs index bfb3cfcb..bc17a31b 100644 --- a/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs +++ b/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs @@ -3,179 +3,275 @@ // Licensed under the MIT license. using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using CredentialProvider.Microsoft.Tests.CredentialProviders.Vsts; -using FluentAssertions; +using Microsoft.Artifacts.Authentication; +using Microsoft.Identity.Client; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using NuGet.Protocol.Plugins; +using NuGetCredentialProvider.CredentialProviders.Vsts; using NuGetCredentialProvider.CredentialProviders.VstsBuildTaskServiceEndpoint; using NuGetCredentialProvider.Logging; using NuGetCredentialProvider.Util; -namespace CredentialProvider.Microsoft.Tests.CredentialProviders.VstsBuildTaskServiceEndpoint +namespace CredentialProvider.Microsoft.Tests.CredentialProviders.VstsBuildTaskServiceEndpoint; + +[TestClass] +public class VstsBuildTaskServiceEndpointCredentialProviderTests { - [TestClass] - public class VstsBuildTaskServiceEndpointCredentialProviderTests + + private Mock mockLogger; + + private Mock mockTokenProviderFactory; + + private VstsBuildTaskServiceEndpointCredentialProvider vstsCredentialProvider; + + private IDisposable environmentLock; + + [TestInitialize] + public void TestInitialize() + { + mockLogger = new Mock(); + var mockAuthUtil = new Mock(); + mockTokenProviderFactory = new Mock(); + + vstsCredentialProvider = new VstsBuildTaskServiceEndpointCredentialProvider( + mockLogger.Object, + mockTokenProviderFactory.Object, + mockAuthUtil.Object); + environmentLock = EnvironmentLock.WaitAsync().Result; + } + + [TestCleanup] + public virtual void TestCleanup() + { + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, null); + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, null); + environmentLock?.Dispose(); + } + + [TestMethod] + public async Task CanProvideCredentials_ReturnsFalseForWhenEnvVarIsNotSet() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJsonEnvVarold = EnvUtil.BuildTaskExternalEndpoints; + string feedEndPointJsonEnvVarnew = EnvUtil.EndpointCredentials; + + // Setting environment variable to null + Environment.SetEnvironmentVariable(feedEndPointJsonEnvVarold, null); + Environment.SetEnvironmentVariable(feedEndPointJsonEnvVarnew, null); + + var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri); + Assert.AreEqual(false, result); + } + + [DataTestMethod] + [DataRow(EnvUtil.BuildTaskExternalEndpoints)] + [DataRow(EnvUtil.EndpointCredentials)] + public async Task CanProvideCredentials_ReturnsTrueForCorrectEnvironmentVariable(string feedEndPointJsonEnvVar) + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; + + Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); + + var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri); + Assert.AreEqual(true, result); + } + + [TestMethod] + public async Task HandleRequestAsync_WithExternalEndpoint_ReturnsSuccess() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; + + Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success); + Assert.AreEqual(result.Username, "testUser"); + Assert.AreEqual(result.Password, "testToken"); + } + + [TestMethod] + public async Task HandleRequestAsync_WithEndpoint_ReturnsSuccess() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJsonEnvVar = EnvUtil.EndpointCredentials; + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"clientId\": \"testClientId\"}]}"; + + Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, null); + + mockTokenProviderFactory.Setup(x => + x.GetAsync(It.IsAny())) + .ReturnsAsync(new List() + { + SetUpMockManagedIdentityTokenProvider("someTokenValue").Object + }); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success); + Assert.AreEqual(result.Username, "testClientId"); + Assert.AreEqual(result.Password, "someTokenValue"); + } + + [TestMethod] + public async Task HandleRequestAsync_ExternalEndpoints_ReturnsSuccessWhenMultipleSourcesInJson() + { + Uri sourceUri = new Uri(@"http://example3.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + + string feedEndPointJson = "{\"endpointCredentials\":[" + + "{\"endpoint\":\"http://example1.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser1\", \"password\":\"testToken1\"}, " + + "{\"endpoint\":\"http://example2.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser2\", \"password\":\"testToken2\"}, " + + "{\"endpoint\":\"http://example3.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser3\", \"password\":\"testToken3\"}, " + + "{\"endpoint\":\"http://example4.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser4\", \"password\":\"testToken4\"}" + + "]}"; + + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success); + Assert.AreEqual(result.Username, "testUser3"); + Assert.AreEqual(result.Password, "testToken3"); + } + + [TestMethod] + public async Task HandleRequestAsync_ExternalEndpoints_ReturnsErrorWhenMatchingEndpointIsNotFound() + { + Uri sourceUri = new Uri(@"http://exampleThatDoesNotMatch.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; + + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error); + } + + [TestMethod] + public async Task HandleRequestAsync_Endpoints_ReturnsErrorWhenMatchingEndpointIsNotFound() + { + Uri sourceUri = new Uri(@"http://exampleThatDoesNotMatch.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"clientId\": \"someClientId\"}]}"; + + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error); + } + + [TestMethod] + public async Task HandleRequestAsync_MatchesEndpointURLCaseInsensitive() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_Packaging/TestFEED/nuget/v3/index.json"); + + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; + + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(MessageResponseCode.Success, result.ResponseCode); + Assert.AreEqual("testUser", result.Username); + Assert.AreEqual("testToken", result.Password); + } + + [TestMethod] + public async Task HandleRequestAsync_MatchesEndpointURLWithSpaces() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json"); + + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; + + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(MessageResponseCode.Success, result.ResponseCode); + Assert.AreEqual("testUser", result.Username); + Assert.AreEqual("testToken", result.Password); + } + + [TestMethod] + public async Task HandleRequestAsync_WithInvalidBearer_ReturnsError() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJson = $"{{\"endpointCredentials\":[{{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"azureClientId\":\"\"}}]}}"; + + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + mockTokenProviderFactory.Setup(x => + x.GetAsync(It.IsAny())) + .ReturnsAsync(new List() { + SetUpMockManagedIdentityTokenProvider(null).Object }); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error); + } + + [TestMethod] + public async Task HandleRequestAsync_WithNoTokenProvider_ReturnsError() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJson = $"{{\"endpointCredentials\":[{{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\",\"clientId\":\"someClientId\"}}]}}"; + + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var mockTokenProvider = new Mock(); + mockTokenProvider.Setup(x => x.Name).Returns("wrong name"); + + mockTokenProviderFactory.Setup(x => + x.GetAsync(It.IsAny())) + .ReturnsAsync(new List() { mockTokenProvider.Object }); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error); + } + + [TestMethod] + public async Task HandleRequestAsync_OnTokenProviderError_ReturnsError() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJson = $"{{\"endpointCredentials\":[{{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"clientId\":\"\"}}]}}"; + + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var mockTokenProvider = new Mock(); + mockTokenProvider.Setup(x => x.Name).Returns("MSAL Managed Identity"); + mockTokenProvider.Setup(x => x.IsInteractive).Returns(false); + mockTokenProvider.Setup(x => x.CanGetToken(It.IsAny())).Returns(true); + mockTokenProvider.Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("some Message")); + + mockTokenProviderFactory.Setup(x => + x.GetAsync(It.IsAny())) + .ReturnsAsync(new List() { mockTokenProvider.Object }); + + var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); + Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error); + } + + private static Mock SetUpMockManagedIdentityTokenProvider(string token) { - private readonly Uri feedUri = new Uri("https://example.aad.authority.com"); - - private Mock mockLogger; - - private VstsBuildTaskServiceEndpointCredentialProvider vstsCredentialProvider; - - private IDisposable environmentLock; - - [TestInitialize] - public void TestInitialize() - { - mockLogger = new Mock(); - - vstsCredentialProvider = new VstsBuildTaskServiceEndpointCredentialProvider(mockLogger.Object); - environmentLock = EnvironmentLock.WaitAsync().Result; - } - - [TestCleanup] - public virtual void TestCleanup() - { - environmentLock?.Dispose(); - } - - [TestMethod] - public async Task CanProvideCredentials_ReturnsFalseForWhenEnvVarIsNotSet() - { - Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - - // Setting environment variable to null - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, null); - - var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri); - Assert.AreEqual(false, result); - } - - [TestMethod] - public async Task CanProvideCredentials_ReturnsTrueForCorrectEnvironmentVariable() - { - Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; - - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); - - var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri); - Assert.AreEqual(true, result); - } - - [TestMethod] - public async Task HandleRequestAsync_ReturnsSuccess() - { - Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; - - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); - - var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); - Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success); - Assert.AreEqual(result.Username, "testUser"); - Assert.AreEqual(result.Password, "testToken"); - } - - [TestMethod] - public async Task HandleRequestAsync_ReturnsSuccessWhenSingleQuotesInJson() - { - Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - string feedEndPointJson = "{\'endpointCredentials\':[{\'endpoint\':\'http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\', \'username\': \'testUser\', \'password\':\'testToken\'}]}"; - - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); - - var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); - Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success); - Assert.AreEqual(result.Username, "testUser"); - Assert.AreEqual(result.Password, "testToken"); - } - - [TestMethod] - public async Task HandleRequestAsync_ReturnsSuccessWhenMultipleSourcesInJson() - { - Uri sourceUri = new Uri(@"http://example3.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - - string feedEndPointJson = "{\"endpointCredentials\":[" + - "{\"endpoint\":\"http://example1.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser1\", \"password\":\"testToken1\"}, " + - "{\"endpoint\":\"http://example2.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser2\", \"password\":\"testToken2\"}, " + - "{\"endpoint\":\"http://example3.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser3\", \"password\":\"testToken3\"}, " + - "{\"endpoint\":\"http://example4.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser4\", \"password\":\"testToken4\"}" + - "]}"; - - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); - - var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); - Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success); - Assert.AreEqual(result.Username, "testUser3"); - Assert.AreEqual(result.Password, "testToken3"); - } - - [TestMethod] - public async Task HandleRequestAsync_ReturnsErrorWhenMatchingEndpointIsNotFound() - { - Uri sourceUri = new Uri(@"http://exampleThatDoesNotMatch.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; - - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); - - var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); - Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error); - } - - [TestMethod] - public void HandleRequestAsync_ThrowsWithInvalidJson() - { - Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - string invalidFeedEndPointJson = "this is not json"; - - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, invalidFeedEndPointJson); - - Func act = async () => await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); - act.Should().Throw(); - } - - [TestMethod] - public async Task HandleRequestAsync_MatchesEndpointURLCaseInsensitive() - { - Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_Packaging/TestFEED/nuget/v3/index.json"); - - string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); - - var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); - Assert.AreEqual(MessageResponseCode.Success, result.ResponseCode); - Assert.AreEqual("testUser", result.Username); - Assert.AreEqual("testToken", result.Password); - } - - - [TestMethod] - public async Task HandleRequestAsync_MatchesEndpointURLWithSpaces() - { - Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json"); - - string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; - string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints; - - Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); - - var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); - Assert.AreEqual(MessageResponseCode.Success, result.ResponseCode); - Assert.AreEqual("testUser", result.Username); - Assert.AreEqual("testToken", result.Password); - } + var mockTokenProvider = new Mock(); + mockTokenProvider.Setup(x => x.Name).Returns("MSAL Managed Identity"); + mockTokenProvider.Setup(x => x.IsInteractive).Returns(false); + mockTokenProvider.Setup(x => x.CanGetToken(It.IsAny())).Returns(true); + mockTokenProvider.Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AuthenticationResult( + token, + false, + null, + DateTimeOffset.MinValue, + DateTimeOffset.MaxValue, + null, + Mock.Of(), + null, + new List() { }, + Guid.Empty)); + + return mockTokenProvider; } } diff --git a/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs b/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs new file mode 100644 index 00000000..9904caab --- /dev/null +++ b/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. +// +// Licensed under the MIT license. + +using System; +using CredentialProvider.Microsoft.Tests.CredentialProviders.Vsts; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using NuGetCredentialProvider.Util; +using ILogger = NuGetCredentialProvider.Logging.ILogger; + +namespace CredentialProvider.Microsoft.Tests.Util; + +[TestClass] +public class FeedEndpointCredentialParserTests +{ + private IDisposable environmentLock; + private Mock loggerMock; + + public FeedEndpointCredentialParserTests() + { + environmentLock = EnvironmentLock.WaitAsync().Result; + loggerMock = new Mock(); + } + + [TestCleanup] + public virtual void TestCleanup() + { + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, null); + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, null); + environmentLock?.Dispose(); + } + + [TestMethod] + public void ParseFeedEndpointsJsonToDictionary_ReturnsCredentials() + { + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"clientId\": \"testClientId\"}]}"; + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Count.Should().Be(1); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].ClientId.Should().Be("testClientId"); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("invalid json")] + public void ParseFeedEndpointsJsonToDictionary_WhenInputInvalid_ReturnsEmpty(string invalidInput) + { + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, invalidInput); + + var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Should().BeEmpty(); + } + + [TestMethod] + public void ParseFeedEndpointsJsonToDictionary_WithNoClientId_ReturnsEmpty() + { + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\"}]}"; + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Should().BeEmpty(); + } + + [TestMethod] + public void ParseFeedEndpointsJsonToDictionary_WithCertificateFilePath_ReturnsCredentials() + { + string feedEndPointJson = @"{""endpointCredentials"":[{""endpoint"":""http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"", ""clientId"": ""testClientId"", ""clientCertificateFilePath"": ""test\\file\\path""}]}"; + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Count.Should().Be(1); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].ClientId.Should().Be("testClientId"); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].CertificateFilePath.Should().Be("test\\file\\path"); + } + + + [TestMethod] + public void ParseFeedEndpointsJsonToDictionary_WithCertificateUnixFilePath_ReturnsCredentials() + { + string feedEndPointJson = @"{""endpointCredentials"":[{""endpoint"":""http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"", ""clientId"": ""testClientId"", ""clientCertificateFilePath"": ""test/file/path""}]}"; + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Should().NotBeNull(); + result.Count.Should().Be(1); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].ClientId.Should().Be("testClientId"); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].CertificateFilePath.Should().Be("test/file/path"); + } + + [TestMethod] + public void ParseFeedEndpointsJsonToDictionary_WithSubjectName_ReturnsCredentials() + { + string feedEndPointJson = @"{""endpointCredentials"":[{""endpoint"":""http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"", ""clientId"": ""testClientId"", ""clientCertificateSubjectName"": ""someSubjectName""}]}"; + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Count.Should().Be(1); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].ClientId.Should().Be("testClientId"); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].CertificateSubjectName.Should().Be("someSubjectName"); + } + + [TestMethod] + public void ParseFeedEndpointsJsonToDictionary_WithCertificateUnixFilePathAndSubjectName_ReturnsEmpty() + { + string feedEndPointJson = @"{""endpointCredentials"":[{""endpoint"":""http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"", ""clientId"": ""testClientId"", ""clientCertificateFilePath"": ""test/file/path"", , ""clientCertificateSubjectName"": ""someSubjectName""}]}"; + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Should().BeEmpty(); + } + + [TestMethod] + public void ParseFeedEndpointsJsonToDictionary_WhenSingleQuotePresent_ReturnsEmpty() + { + string feedEndPointJson = "{'endpointCredentials':['endpoint':'http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json', 'clientId': 'testClientId'}]}"; + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Should().BeEmpty(); + } + + [TestMethod] + public void ParseExternalFeedEndpointsJsonToDictionary_ReturnsCredentials() + { + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testuser\", \"password\": \"testPassword\"}]}"; + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Count.Should().Be(1); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("testuser"); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Password.Should().Be("testPassword"); + } + + [TestMethod] + public void ParseExternalFeedEndpointsJsonToDictionary_WithoutUserName_ReturnsCredentials() + { + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"password\": \"testPassword\"}]}"; + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Count.Should().Be(1); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("VssSessionToken"); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Password.Should().Be("testPassword"); + } + + [TestMethod] + public void ParseExternalFeedEndpointsJsonToDictionary_WithSingleQuotes_ReturnsCredentials() + { + string feedEndPointJson = "{\'endpointCredentials\':[{\'endpoint\':\'http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\', \'username\': \'testuser\', \'password\': \'testPassword\'}]}"; + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson); + + var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Count.Should().Be(1); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("testuser"); + result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Password.Should().Be("testPassword"); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("invalid json")] + public void ParseFeedEndpointsJsonToDictionary_WhenInvalidInput_ReturnsEmpty(string input) + { + Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, input); + + var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object); + + result.Should().BeEmpty(); + } +} diff --git a/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs b/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs index fbdebe3f..5fe38921 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs @@ -16,7 +16,7 @@ namespace NuGetCredentialProvider.CredentialProviders.Vsts { public interface IAuthUtil { - Task GetAadAuthorityUriAsync(Uri uri, CancellationToken cancellationToken); + Task GetAuthorizationInfoAsync(Uri uri, CancellationToken cancellationToken); Task GetAzDevDeploymentType(Uri uri); @@ -30,6 +30,12 @@ public enum AzDevDeploymentType OnPrem } + public struct AuthorizationInfo + { + public Uri EntraAuthorityUri { get; set; } + public string EntraTenantId { get; set; } + }; + public class AuthUtil : IAuthUtil { public const string VssResourceTenant = "X-VSS-ResourceTenant"; @@ -44,47 +50,15 @@ public AuthUtil(ILogger logger) this.logger = logger; } - public async Task GetAadAuthorityUriAsync(Uri uri, CancellationToken cancellationToken) + public async Task GetAuthorizationInfoAsync(Uri uri, CancellationToken cancellationToken) { - var environmentAuthority = EnvUtil.GetAuthorityFromEnvironment(logger); - if (environmentAuthority != null) - { - return environmentAuthority; - } - var headers = await GetResponseHeadersAsync(uri, cancellationToken); - var bearerHeaders = headers.WwwAuthenticate.Where(x => x.Scheme.Equals("Bearer", StringComparison.Ordinal)); - foreach (var param in bearerHeaders) + return new AuthorizationInfo { - if (param.Parameter == null) - { - // MSA-backed accounts don't expose a parameter - continue; - } - - var equalSplit = param.Parameter.Split(new[] { "=" }, StringSplitOptions.RemoveEmptyEntries); - if (equalSplit.Length == 2) - { - if (equalSplit[0].Equals("authorization_uri", StringComparison.OrdinalIgnoreCase)) - { - if (Uri.TryCreate(equalSplit[1], UriKind.Absolute, out Uri parsedUri)) - { - logger.Verbose(string.Format(Resources.FoundAADAuthorityFromHeaders, parsedUri)); - return parsedUri; - } - } - } - } - - // Return the common tenant - var aadBase = UsePpeAadUrl(uri) ? "https://login.windows-ppe.net" : "https://login.microsoftonline.com"; - logger.Verbose(string.Format(Resources.AADAuthorityNotFound, aadBase)); - - // The Azure Artifacts application has MSA-Passthrough enabled which requires the use of the organizations - // tenant when requesting tokens for MSA users. This covers both organizations and consumers in cases where - // a tenant ID cannot be obtained from authenticate headers. - return new Uri($"{aadBase}/organizations"); + EntraAuthorityUri = GetAuthority(uri, headers), + EntraTenantId = GetTenantId(headers), + }; } public async Task GetAzDevDeploymentType(Uri uri) @@ -152,6 +126,59 @@ protected virtual async Task GetResponseHeadersAsync(Uri ur } } + private Uri GetAuthority(Uri uri, HttpResponseHeaders responseHeaders) + { + var environmentAuthority = EnvUtil.GetAuthorityFromEnvironment(logger); + if (environmentAuthority != null) + { + return environmentAuthority; + } + + var bearerHeaders = responseHeaders.WwwAuthenticate.Where(x => x.Scheme.Equals("Bearer", StringComparison.Ordinal)); + + foreach (var param in bearerHeaders) + { + if (param.Parameter == null) + { + // MSA-backed accounts don't expose a parameter + continue; + } + + var equalSplit = param.Parameter.Split(new[] { "=" }, StringSplitOptions.RemoveEmptyEntries); + if (equalSplit.Length == 2) + { + if (equalSplit[0].Equals("authorization_uri", StringComparison.OrdinalIgnoreCase)) + { + if (Uri.TryCreate(equalSplit[1], UriKind.Absolute, out Uri parsedUri)) + { + logger.Verbose(string.Format(Resources.FoundAADAuthorityFromHeaders, parsedUri)); + return parsedUri; + } + } + } + } + + // Return the common tenant + var aadBase = UsePpeAadUrl(uri) ? "https://login.windows-ppe.net" : "https://login.microsoftonline.com"; + logger.Verbose(string.Format(Resources.AADAuthorityNotFound, aadBase)); + + // The Azure Artifacts application has MSA-Passthrough enabled which requires the use of the organizations + // tenant when requesting tokens for MSA users. This covers both organizations and consumers in cases where + // a tenant ID cannot be obtained from authenticate headers. + return new Uri($"{aadBase}/organizations"); + } + + private string GetTenantId(HttpResponseHeaders responseHeaders) + { + if (responseHeaders.Contains(VssResourceTenant)) + { + responseHeaders.TryGetValues(VssResourceTenant, out var tenantId); + return tenantId.FirstOrDefault(); + } + + return null; + } + private bool UsePpeAadUrl(Uri uri) { var ppeHosts = EnvUtil.GetHostsFromEnvironment(logger, EnvUtil.PpeHostsEnvVar, new[] diff --git a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs index 6bd9eb27..56904823 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs @@ -41,11 +41,15 @@ public override async Task CanProvideCredentialsAsync(Uri uri) { // If for any reason we reach this point and any of the three build task env vars are set, // we should not try get credentials with this cred provider. - string feedEndPointsJsonEnvVar = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints); + string feedEndPointsJsonEnvVar = Environment.GetEnvironmentVariable(EnvUtil.EndpointCredentials); + string externalFeedEndPointsJsonEnvVar = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints); string uriPrefixesStringEnvVar = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskUriPrefixes); string accessTokenEnvVar = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskAccessToken); - if (string.IsNullOrWhiteSpace(feedEndPointsJsonEnvVar) == false || string.IsNullOrWhiteSpace(uriPrefixesStringEnvVar) == false || string.IsNullOrWhiteSpace(accessTokenEnvVar) == false) + if (string.IsNullOrWhiteSpace(feedEndPointsJsonEnvVar) == false || + string.IsNullOrWhiteSpace(externalFeedEndPointsJsonEnvVar) == false || + string.IsNullOrWhiteSpace(uriPrefixesStringEnvVar) == false || + string.IsNullOrWhiteSpace(accessTokenEnvVar) == false) { Verbose(Resources.BuildTaskCredProviderIsUsedError); return false; @@ -96,10 +100,10 @@ public override async Task HandleRequestAs canShowDialog = forceCanShowDialogTo.Value; } - Uri authority = await authUtil.GetAadAuthorityUriAsync(request.Uri, cancellationToken); - Verbose(string.Format(Resources.UsingAuthority, authority)); + var authInfo = await authUtil.GetAuthorizationInfoAsync(request.Uri, cancellationToken); + Verbose(string.Format(Resources.UsingAuthority, authInfo.EntraAuthorityUri)); - IEnumerable tokenProviders = await tokenProvidersFactory.GetAsync(authority); + IEnumerable tokenProviders = await tokenProvidersFactory.GetAsync(authInfo.EntraAuthorityUri); cancellationToken.ThrowIfCancellationRequested(); var tokenRequest = new TokenRequest(request.Uri) @@ -116,7 +120,7 @@ public override async Task HandleRequestAs Logger.Minimal(string.Format(Resources.DeviceFlowMessage, deviceCodeResult.VerificationUrl, deviceCodeResult.UserCode)); return Task.CompletedTask; - } + }, }; // Try each bearer token provider (e.g. cache, WIA, UI, DeviceCode) in order. diff --git a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsSessionTokenClient.cs b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsSessionTokenClient.cs index 94ce5522..9cd11085 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsSessionTokenClient.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsSessionTokenClient.cs @@ -6,9 +6,9 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Newtonsoft.Json; using NuGetCredentialProvider.Logging; using NuGetCredentialProvider.Util; @@ -18,6 +18,12 @@ public class VstsSessionTokenClient : IVstsSessionTokenClient { private const string TokenScope = "vso.packaging_write vso.drop_write"; + private static readonly JsonSerializerOptions options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + private readonly Uri vstsUri; private readonly string bearerToken; private readonly IAuthUtil authUtil; @@ -45,7 +51,7 @@ private HttpRequestMessage CreateRequest(Uri uri, DateTime? validTo) }; request.Content = new StringContent( - JsonConvert.SerializeObject(tokenRequest), + JsonSerializer.Serialize(tokenRequest, options), Encoding.UTF8, "application/json"); @@ -77,7 +83,6 @@ public async Task CreateSessionTokenAsync(VstsTokenType tokenType, DateT string serializedResponse; if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) { - request.Dispose(); response.Dispose(); @@ -97,7 +102,7 @@ public async Task CreateSessionTokenAsync(VstsTokenType tokenType, DateT serializedResponse = await response.Content.ReadAsStringAsync(); } - var responseToken = JsonConvert.DeserializeObject(serializedResponse); + var responseToken = JsonSerializer.Deserialize(serializedResponse, options); if (validTo.Subtract(responseToken.ValidTo.Value).TotalHours > 1.0) { diff --git a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskMsalTokenProvidersFactory.cs b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskMsalTokenProvidersFactory.cs new file mode 100644 index 00000000..2cdf1c92 --- /dev/null +++ b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskMsalTokenProvidersFactory.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. +// +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Artifacts.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; +using NuGetCredentialProvider.Util; + +namespace NuGetCredentialProvider.CredentialProviders.VstsBuildTaskServiceEndpoint; + +internal class VstsBuildTaskMsalTokenProvidersFactory : ITokenProvidersFactory +{ + private readonly ILogger logger; + + public VstsBuildTaskMsalTokenProvidersFactory(ILogger logger) + { + this.logger = logger; + } + + public Task> GetAsync(Uri authority) + { + var app = AzureArtifacts.CreateDefaultBuilder(authority) + .WithBroker(EnvUtil.MsalAllowBrokerEnabled(), logger) + .WithHttpClientFactory(HttpClientFactory.Default) + .WithLogging( + (level, message, containsPii) => + { + // We ignore containsPii param because we are passing in enablePiiLogging below. + logger.LogTrace("MSAL Log ({level}): {message}", level, message); + }, + enablePiiLogging: EnvUtil.GetLogPIIEnabled() + ) + .Build(); + + return Task.FromResult(ConstructTokenProvidersList(app)); + } + + private IEnumerable ConstructTokenProvidersList(IPublicClientApplication app) + { + yield return new MsalServicePrincipalTokenProvider(app, logger); + yield return new MsalManagedIdentityTokenProvider(app, logger); + } +} diff --git a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs index 8b551a55..a9c3fc28 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs @@ -4,45 +4,41 @@ using System; using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; -using Newtonsoft.Json; +using Microsoft.Artifacts.Authentication; using NuGet.Protocol.Plugins; +using NuGetCredentialProvider.CredentialProviders.Vsts; using NuGetCredentialProvider.Util; using ILogger = NuGetCredentialProvider.Logging.ILogger; namespace NuGetCredentialProvider.CredentialProviders.VstsBuildTaskServiceEndpoint { - public class EndpointCredentials - { - [JsonProperty("endpoint")] - public string Endpoint { get; set; } - [JsonProperty("username")] - public string Username { get; set; } - [JsonProperty("password")] - public string Password { get; set; } - } - - public class EndpointCredentialsContainer - { - [JsonProperty("endpointCredentials")] - public EndpointCredentials[] EndpointCredentials { get; set; } - } - public sealed class VstsBuildTaskServiceEndpointCredentialProvider : CredentialProviderBase { private Lazy> LazyCredentials; + private Lazy> LazyExternalCredentials; + private ITokenProvidersFactory TokenProvidersFactory; + private IAuthUtil AuthUtil; // Dictionary that maps an endpoint string to EndpointCredentials private Dictionary Credentials => LazyCredentials.Value; - - public VstsBuildTaskServiceEndpointCredentialProvider(ILogger logger) + private Dictionary ExternalCredentials => LazyExternalCredentials.Value; + + public VstsBuildTaskServiceEndpointCredentialProvider(ILogger logger, ITokenProvidersFactory tokenProvidersFactory, IAuthUtil authUtil) : base(logger) { + TokenProvidersFactory = tokenProvidersFactory; LazyCredentials = new Lazy>(() => { - return ParseJsonToDictionary(); + return FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(logger); }); + LazyExternalCredentials = new Lazy>(() => + { + return FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(logger); + }); + AuthUtil = authUtil; } public override bool IsCachable { get { return false; } } @@ -51,8 +47,10 @@ public VstsBuildTaskServiceEndpointCredentialProvider(ILogger logger) public override Task CanProvideCredentialsAsync(Uri uri) { - string feedEndPointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints); - if (string.IsNullOrWhiteSpace(feedEndPointsJson)) + string feedEndPointsJson = Environment.GetEnvironmentVariable(EnvUtil.EndpointCredentials); + string externalFeedEndPointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints); + + if (string.IsNullOrWhiteSpace(feedEndPointsJson) && string.IsNullOrWhiteSpace(externalFeedEndPointsJson)) { Verbose(Resources.BuildTaskEndpointEnvVarError); return Task.FromResult(false); @@ -61,24 +59,89 @@ public override Task CanProvideCredentialsAsync(Uri uri) return Task.FromResult(true); } - public override Task HandleRequestAsync(GetAuthenticationCredentialsRequest request, CancellationToken cancellationToken) + public override async Task HandleRequestAsync(GetAuthenticationCredentialsRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Verbose(string.Format(Resources.IsRetry, request.IsRetry)); string uriString = request.Uri.AbsoluteUri; - bool endpointFound = Credentials.TryGetValue(uriString, out EndpointCredentials matchingEndpoint); - if (endpointFound) + bool externalEndpointFound = ExternalCredentials.TryGetValue(uriString, out ExternalEndpointCredentials matchingExternalEndpoint); + if (externalEndpointFound && !string.IsNullOrWhiteSpace(matchingExternalEndpoint.Password)) { Verbose(string.Format(Resources.BuildTaskEndpointMatchingUrlFound, uriString)); return GetResponse( - matchingEndpoint.Username, - matchingEndpoint.Password, + matchingExternalEndpoint.Username, + matchingExternalEndpoint.Password, null, MessageResponseCode.Success); } + bool endpointFound = Credentials.TryGetValue(uriString, out EndpointCredentials matchingEndpoint); + if (endpointFound && !string.IsNullOrWhiteSpace(matchingEndpoint.ClientId)) + { + var authInfo = await AuthUtil.GetAuthorizationInfoAsync(request.Uri, cancellationToken); + Verbose(string.Format(Resources.UsingAuthority, authInfo.EntraAuthorityUri)); + Verbose(string.Format(Resources.UsingTenant, authInfo.EntraTenantId)); + + var clientCertificate = GetCertificate(matchingEndpoint); + Info(clientCertificate == null + ? (Resources.ClientCertificateNotFound) + : string.Format(Resources.UsingCertificate, clientCertificate.Subject)); + + IEnumerable tokenProviders = await TokenProvidersFactory.GetAsync(authInfo.EntraAuthorityUri); + cancellationToken.ThrowIfCancellationRequested(); + + var tokenRequest = new TokenRequest(request.Uri) + { + IsRetry = request.IsRetry, + IsNonInteractive = true, + CanShowDialog = false, + IsWindowsIntegratedAuthEnabled = false, + InteractiveTimeout = TimeSpan.FromSeconds(EnvUtil.GetDeviceFlowTimeoutFromEnvironmentInSeconds(Logger)), + ClientId = matchingEndpoint.ClientId, + ClientCertificate = clientCertificate, + TenantId = authInfo.EntraTenantId + }; + + foreach(var tokenProvider in tokenProviders) + { + bool shouldRun = tokenProvider.CanGetToken(tokenRequest); + if (!shouldRun) + { + Verbose(string.Format(Resources.NotRunningBearerTokenProvider, tokenProvider.Name)); + continue; + } + + Verbose(string.Format(Resources.AttemptingToAcquireBearerTokenUsingProvider, tokenProvider.Name)); + + string bearerToken; + try + { + var result = await tokenProvider.GetTokenAsync(tokenRequest, cancellationToken); + bearerToken = result?.AccessToken; + } + catch (Exception ex) + { + Verbose(string.Format(Resources.BearerTokenProviderException, tokenProvider.Name, ex)); + continue; + } + + if (string.IsNullOrWhiteSpace(bearerToken)) + { + Verbose(string.Format(Resources.BearerTokenProviderReturnedNull, tokenProvider.Name)); + continue; + } + + Info(string.Format(Resources.AcquireBearerTokenSuccess, tokenProvider.Name)); + return GetResponse( + matchingEndpoint.ClientId, + bearerToken, + null, + MessageResponseCode.Success); + } + } + Verbose(string.Format(Resources.BuildTaskEndpointNoMatchingUrl, uriString)); return GetResponse( null, @@ -87,9 +150,9 @@ public override Task HandleRequestAsync(Ge MessageResponseCode.Error); } - private Task GetResponse(string username, string password, string message, MessageResponseCode responseCode) + private GetAuthenticationCredentialsResponse GetResponse(string username, string password, string message, MessageResponseCode responseCode) { - return Task.FromResult(new GetAuthenticationCredentialsResponse( + return new GetAuthenticationCredentialsResponse( username: username, password: password, message: message, @@ -97,62 +160,22 @@ private Task GetResponse(string username, { "Basic" }, - responseCode: responseCode)); + responseCode: responseCode); } - private Dictionary ParseJsonToDictionary() + private X509Certificate2 GetCertificate(EndpointCredentials credentials) { - string feedEndPointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints); - - try + if (!string.IsNullOrWhiteSpace(credentials.CertificateSubjectName)) { - // Parse JSON from VSS_NUGET_EXTERNAL_FEED_ENDPOINTS - Verbose(Resources.ParsingJson); - if (!string.IsNullOrWhiteSpace(feedEndPointsJson) && feedEndPointsJson.Contains("':")) - { - Warning(Resources.InvalidJsonWarning); - } - Dictionary credsResult = new Dictionary(StringComparer.OrdinalIgnoreCase); - EndpointCredentialsContainer endpointCredentials = JsonConvert.DeserializeObject(feedEndPointsJson); - if (endpointCredentials == null) - { - Verbose(Resources.NoEndpointsFound); - return credsResult; - } - - foreach (EndpointCredentials credentials in endpointCredentials.EndpointCredentials) - { - if (credentials == null) - { - Verbose(Resources.EndpointParseFailure); - break; - } - - if (credentials.Username == null) - { - credentials.Username = "VssSessionToken"; - } - - if (!Uri.TryCreate(credentials.Endpoint, UriKind.Absolute, out var endpointUri)) - { - Verbose(Resources.EndpointParseFailure); - break; - } - - var urlEncodedEndpoint = endpointUri.AbsoluteUri; - if (!credsResult.ContainsKey(urlEncodedEndpoint)) - { - credsResult.Add(urlEncodedEndpoint, credentials); - } - } - - return credsResult; + return CertificateUtil.GetCertificateBySubjectName(Logger, credentials.CertificateSubjectName); } - catch (Exception e) + + if (!string.IsNullOrWhiteSpace(credentials.CertificateFilePath)) { - Verbose(string.Format(Resources.VstsBuildTaskExternalCredentialCredentialProviderError, e)); - throw; + return CertificateUtil.GetCertificateByFilePath(Logger, credentials.CertificateFilePath); } + + return null; } } } diff --git a/CredentialProvider.Microsoft/Program.cs b/CredentialProvider.Microsoft/Program.cs index 2ba1a84d..3059fcb3 100644 --- a/CredentialProvider.Microsoft/Program.cs +++ b/CredentialProvider.Microsoft/Program.cs @@ -5,10 +5,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Artifacts.Authentication; -using Newtonsoft.Json; using NuGet.Common; using NuGet.Protocol.Plugins; using NuGetCredentialProvider.CredentialProviders; @@ -53,11 +53,12 @@ public static async Task Main(string[] args) var authUtil = new AuthUtil(multiLogger); var logger = new NuGetLoggerAdapter(multiLogger, parsedArgs.Verbosity); var tokenProvidersFactory = new MsalTokenProvidersFactory(logger); + var vstsBuildTaskTokenProvidersFactory = new VstsBuildTaskMsalTokenProvidersFactory(logger); var vstsSessionTokenProvider = new VstsSessionTokenFromBearerTokenProvider(authUtil, multiLogger); List credentialProviders = new List { - new VstsBuildTaskServiceEndpointCredentialProvider(multiLogger), + new VstsBuildTaskServiceEndpointCredentialProvider(multiLogger, vstsBuildTaskTokenProvidersFactory, authUtil), new VstsBuildTaskCredentialProvider(multiLogger), new VstsCredentialProvider(multiLogger, authUtil, tokenProvidersFactory, vstsSessionTokenProvider), }; @@ -89,6 +90,7 @@ public static async Task Main(string[] args) EnvUtil.BuildTaskUriPrefixes, EnvUtil.BuildTaskAccessToken, EnvUtil.BuildTaskExternalEndpoints, + EnvUtil.EndpointCredentials, EnvUtil.DefaultMsalCacheLocation, EnvUtil.SessionTokenCacheLocation, EnvUtil.WindowsIntegratedAuthenticationEnvVar, @@ -165,7 +167,7 @@ public static async Task Main(string[] args) if (parsedArgs.OutputFormat == OutputFormat.Json) { // Manually write the JSON output, since we don't use ConsoleLogger in JSON mode (see above) - Console.WriteLine(JsonConvert.SerializeObject(new CredentialResult(resultUsername, resultPassword))); + Console.WriteLine(JsonSerializer.Serialize(new CredentialResult(resultUsername, resultPassword))); } else { diff --git a/CredentialProvider.Microsoft/Resources.resx b/CredentialProvider.Microsoft/Resources.resx index b05182d8..b71a2873 100644 --- a/CredentialProvider.Microsoft/Resources.resx +++ b/CredentialProvider.Microsoft/Resources.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Could not find AAD authority from headers, using default: {0} + Could not find Entra authority from headers, using default: {0} Found AAD authority override: {0} @@ -140,7 +140,7 @@ DeviceFlow: {0} - Using AAD authority: {0} + Using Entra authority: {0} This credential provider must be run under the Team Build tasks for NuGet. Appropriate environment variables must be set. @@ -167,7 +167,7 @@ Command-line v{0}: {1} - Could not parse AAD authority override: {0} + Could not parse Entra authority override: {0} Could not parse Session Time override: {0} @@ -311,42 +311,51 @@ Build Provider Service Endpoint Json Example: {{"endpointCredentials": [{{"endpoint":"http://example.index.json", "username":"optional", "password":"accesstoken"}}]}} +Artifacts Service Endpoint Json + {8} + Json that contains an array of service endpoints, client ids, and certificate + information to authenticate endpoints for Azure Managed Identities + and Service Principals. + Example: {{"endpointCredentials": [{{"endpoint":"http://example.index.json", + "clientId":"required", "clientCertificateFilePath":"optional"," + clientCertificateSubjectName": "optional" }}]}} + Cache Location The Credential Provider uses the following paths to cache credentials. If deleted, the credential provider will re-create them but any credentials will need to be provided again. MSAL Token Cache - {8} + {9} Session Token Cache - {9} + {10} Windows Integrated Authentication - {10} + {11} Boolean to enable/disable using silent Windows Integrated Authentication to authenticate as the logged-in user. Enabled by default. Device Flow Authentication Timeout - {11} + {12} Device Flow authentication timeout in seconds. Default is 90 seconds. NuGet workarounds - {12} + {13} Set to "true" or "false" to override any other sources of the CanShowDialog parameter. MSAL Authority - {13} + {14} Set to override the authority used when fetching an MSAL token. e.g. https://login.microsoftonline.com/organizations MSAL Token File Cache Enabled - {14} + {15} Boolean to enable/disable the MSAL token cache. Enabled by default. Provide MSAL Cache Location - {15} + {16} Provide the location where the MSAL cache should be read and written to. @@ -398,7 +407,7 @@ Provide MSAL Cache Location Could not parse Device Flow Timeout override: {0} - This credential provider must not run if any build task environment variables are set. Variables: VSS_NUGET_URI_PREFIXES, VSS_NUGET_ACCESSTOKEN, VSS_NUGET_EXTERNAL_FEED_ENDPOINTS + This credential provider must not run if any build task environment variables are set. Variables: VSS_NUGET_URI_PREFIXES, VSS_NUGET_ACCESSTOKEN, VSS_NUGET_EXTERNAL_FEED_ENDPOINTS, ARTIFACTS_CREDENTIALPROVIDER_FEED_ENDPOINTS Environment variable VSS_NUGET_EXTERNAL_FEED_ENDPOINTS did not have credentials for endpoint {0} @@ -475,6 +484,29 @@ Provide MSAL Cache Location Detected invalid single quote charater in JSON input. Migrate to double quotes to avoid breaking in future versions. See https://www.rfc-editor.org/rfc/rfc8259.html#section-7 for more information. - + + + Error accessing client certificate. Exception {0}, Message: {1} + + + Certificate with file path {0} not found. + + + Found client certificate for {0}. + + + Unable to find client certificate. + + + Certificate with subject name {0} not found. + + + Certificate information invalid. + + + Using certificate: {0}. + + + Using Entra tenant: {0}. \ No newline at end of file diff --git a/CredentialProvider.Microsoft/Util/CertificateUtil.cs b/CredentialProvider.Microsoft/Util/CertificateUtil.cs new file mode 100644 index 00000000..0c5745be --- /dev/null +++ b/CredentialProvider.Microsoft/Util/CertificateUtil.cs @@ -0,0 +1,74 @@ +using System; +using System.Security.Cryptography.X509Certificates; +using ILogger = NuGetCredentialProvider.Logging.ILogger; + +namespace NuGetCredentialProvider.Util; + +internal static class CertificateUtil +{ + public static X509Certificate2 GetCertificateBySubjectName(ILogger logger, string subjectName) + { + if (string.IsNullOrWhiteSpace(subjectName)) + { + logger.Info(message: Resources.InvalidCertificateInput); + return null; + } + + var locations = new []{ StoreLocation.CurrentUser, StoreLocation.LocalMachine }; + foreach (var location in locations) + { + var store = new X509Store(StoreName.My, location); + try + { + store.Open(OpenFlags.ReadOnly); + var cert = store.Certificates.Find(X509FindType.FindBySubjectDistinguishedName , subjectName, false); + + if (cert.Count > 0) + { + logger.Verbose(string.Format(Resources.ClientCertificateFound, subjectName)); + return cert[0]; + } + } + catch (Exception ex) + { + logger.Error(string.Format(Resources.ClientCertificateError, ex, ex.Message)); + continue; + } + finally + { + store.Close(); + } + } + + logger.Info(string.Format(Resources.ClientCertificateSubjectNameNotFound, subjectName)); + return null; + } + + public static X509Certificate2 GetCertificateByFilePath(ILogger logger, string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + logger.Info(message: Resources.InvalidCertificateInput); + return null; + } + + try + { + var certificate = new X509Certificate2(filePath); + + if (certificate == null) + { + logger.Verbose(string.Format(Resources.ClientCertificateFilePathNotFound, filePath)); + return null; + } + + logger.Verbose(string.Format(Resources.ClientCertificateFound, filePath)); + return certificate; + } + catch (Exception ex) + { + logger.Error(string.Format(Resources.ClientCertificateError, ex, ex.Message)); + return null; + } + } +} diff --git a/CredentialProvider.Microsoft/Util/EnvUtil.cs b/CredentialProvider.Microsoft/Util/EnvUtil.cs index 8a05635e..3d310329 100644 --- a/CredentialProvider.Microsoft/Util/EnvUtil.cs +++ b/CredentialProvider.Microsoft/Util/EnvUtil.cs @@ -29,7 +29,6 @@ public static class EnvUtil public const string BuildTaskUriPrefixes = "VSS_NUGET_URI_PREFIXES"; public const string BuildTaskAccessToken = "VSS_NUGET_ACCESSTOKEN"; - public const string BuildTaskExternalEndpoints = "VSS_NUGET_EXTERNAL_FEED_ENDPOINTS"; public const string MsalLoginHintEnvVar = "NUGET_CREDENTIALPROVIDER_MSAL_LOGIN_HINT"; public const string MsalAuthorityEnvVar = "NUGET_CREDENTIALPROVIDER_MSAL_AUTHORITY"; @@ -37,6 +36,9 @@ public static class EnvUtil public const string MsalFileCacheLocationEnvVar = "NUGET_CREDENTIALPROVIDER_MSAL_FILECACHE_LOCATION"; public const string MsalAllowBrokerEnvVar = "NUGET_CREDENTIALPROVIDER_MSAL_ALLOW_BROKER"; + public const string EndpointCredentials = "ARTIFACTS_CREDENTIALPROVIDER_FEED_ENDPOINTS"; + public const string BuildTaskExternalEndpoints = "VSS_NUGET_EXTERNAL_FEED_ENDPOINTS"; + public static bool GetLogPIIEnabled() { return GetEnabledFromEnvironment(LogPIIEnvVar, defaultValue: false); diff --git a/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs b/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs new file mode 100644 index 00000000..3487bc0e --- /dev/null +++ b/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Newtonsoft.Json; +using ILogger = NuGetCredentialProvider.Logging.ILogger; + +namespace NuGetCredentialProvider.Util; +public class ExternalEndpointCredentials +{ + [JsonProperty("endpoint")] + public string Endpoint { get; set; } + [JsonProperty("username")] + public string Username { get; set; } + [JsonProperty("password")] + public string Password { get; set; } +} + +public class ExternalEndpointCredentialsContainer +{ + [JsonProperty("endpointCredentials")] + public ExternalEndpointCredentials[] EndpointCredentials { get; set; } +} + +public class EndpointCredentials +{ + [JsonPropertyName("endpoint")] + public string Endpoint { get; set; } + [JsonPropertyName("clientId")] + public string ClientId { get; set; } + [JsonPropertyName("clientCertificateFilePath")] + public string CertificateFilePath { get; set; } + [JsonPropertyName("clientCertificateSubjectName")] + public string CertificateSubjectName { get; set; } +} + +public class EndpointCredentialsContainer +{ + [JsonPropertyName("endpointCredentials")] + public EndpointCredentials[] EndpointCredentials { get; set; } +} + +public static class FeedEndpointCredentialsParser +{ + private static readonly JsonSerializerOptions options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public static Dictionary ParseFeedEndpointsJsonToDictionary(ILogger logger) + { + string feedEndpointsJson = Environment.GetEnvironmentVariable(EnvUtil.EndpointCredentials); + if (string.IsNullOrWhiteSpace(feedEndpointsJson)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + try + { + logger.Verbose(Resources.ParsingJson); + Dictionary credsResult = new Dictionary(StringComparer.OrdinalIgnoreCase); + EndpointCredentialsContainer endpointCredentials = System.Text.Json.JsonSerializer.Deserialize(feedEndpointsJson, options); + if (endpointCredentials == null) + { + logger.Verbose(Resources.NoEndpointsFound); + return credsResult; + } + + foreach (var credentials in endpointCredentials.EndpointCredentials) + { + if (credentials == null) + { + logger.Verbose(Resources.EndpointParseFailure); + break; + } + + if (credentials.ClientId == null) + { + logger.Verbose(Resources.EndpointParseFailure); + break; + } + + if (credentials.CertificateSubjectName != null && credentials.CertificateFilePath != null) + { + logger.Verbose(Resources.EndpointParseFailure); + break; + } + + if (!Uri.TryCreate(credentials.Endpoint, UriKind.Absolute, out var endpointUri)) + { + logger.Verbose(Resources.EndpointParseFailure); + break; + } + + var urlEncodedEndpoint = endpointUri.AbsoluteUri; + if (!credsResult.ContainsKey(urlEncodedEndpoint)) + { + credsResult.Add(urlEncodedEndpoint, credentials); + } + } + + return credsResult; + } + catch (Exception ex) + { + logger.Verbose(string.Format(Resources.VstsBuildTaskExternalCredentialCredentialProviderError, ex)); + return new Dictionary(StringComparer.OrdinalIgnoreCase); ; + } + } + + public static Dictionary ParseExternalFeedEndpointsJsonToDictionary(ILogger logger) + { + string feedEndpointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints); + if (string.IsNullOrWhiteSpace(feedEndpointsJson)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + try + { + logger.Verbose(Resources.ParsingJson); + if (feedEndpointsJson.Contains("':")) + { + logger.Warning(Resources.InvalidJsonWarning); + } + + Dictionary credsResult = new Dictionary(StringComparer.OrdinalIgnoreCase); + ExternalEndpointCredentialsContainer endpointCredentials = JsonConvert.DeserializeObject(feedEndpointsJson); + if (endpointCredentials == null) + { + logger.Verbose(Resources.NoEndpointsFound); + return credsResult; + } + + foreach (var credentials in endpointCredentials.EndpointCredentials) + { + if (credentials == null) + { + logger.Verbose(Resources.EndpointParseFailure); + break; + } + + if (credentials.Username == null) + { + credentials.Username = "VssSessionToken"; + } + + if (!Uri.TryCreate(credentials.Endpoint, UriKind.Absolute, out var endpointUri)) + { + logger.Verbose(Resources.EndpointParseFailure); + break; + } + + var urlEncodedEndpoint = endpointUri.AbsoluteUri; + if (!credsResult.ContainsKey(urlEncodedEndpoint)) + { + credsResult.Add(urlEncodedEndpoint, credentials); + } + } + + return credsResult; + } + catch (Exception ex) + { + logger.Verbose(string.Format(Resources.VstsBuildTaskExternalCredentialCredentialProviderError, ex)); + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/CredentialProvider.Microsoft/Util/SessionTokenCache.cs b/CredentialProvider.Microsoft/Util/SessionTokenCache.cs index 5370c9fd..f72420ee 100644 --- a/CredentialProvider.Microsoft/Util/SessionTokenCache.cs +++ b/CredentialProvider.Microsoft/Util/SessionTokenCache.cs @@ -5,8 +5,8 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text.Json; using System.Threading; -using Newtonsoft.Json; using NuGetCredentialProvider.Logging; namespace NuGetCredentialProvider.Util @@ -27,7 +27,7 @@ public SessionTokenCache(string cacheFilePath, ILogger logger, CancellationToken this.mutexName = @"Global\" + cacheFilePath.Replace(Path.DirectorySeparatorChar, '_'); } - private Dictionary Cache + private Dictionary Cache { get { @@ -48,7 +48,7 @@ private Dictionary Cache if (this.cancellationToken.IsCancellationRequested) { logger.Verbose(Resources.SessionTokenCacheCancelMessage); - return new Dictionary(); + return new Dictionary(); } } } @@ -75,7 +75,7 @@ private Dictionary Cache public string this[Uri key] { - get => Cache[key]; + get => Cache[key.ToString()]; set { bool mutexHeld = false, dummy; @@ -108,7 +108,7 @@ public string this[Uri key] mutexHeld = true; var cache = Cache; - cache[key] = value; + cache[key.ToString()] = value; WriteFileBytes(Serialize(cache)); } finally @@ -124,14 +124,14 @@ public string this[Uri key] public bool ContainsKey(Uri key) { - return Cache.ContainsKey(key); + return Cache.ContainsKey(key.ToString()); } public bool TryGetValue(Uri key, out string value) { try { - return Cache.TryGetValue(key, out value); + return Cache.TryGetValue(key.ToString(), out value); } catch (Exception e) { @@ -180,7 +180,7 @@ public void Remove(Uri key) mutexHeld = true; var cache = Cache; - cache.Remove(key); + cache.Remove(key.ToString()); WriteFileBytes(Serialize(cache)); } finally @@ -193,21 +193,19 @@ public void Remove(Uri key) } } - private Dictionary Deserialize(byte[] data) + private Dictionary Deserialize(byte[] data) { if (data == null) { - return new Dictionary(); + return new Dictionary(); } - var serialized = System.Text.Encoding.UTF8.GetString(data); - return JsonConvert.DeserializeObject>(serialized); + return JsonSerializer.Deserialize>(data); } - private byte[] Serialize(Dictionary data) + private byte[] Serialize(Dictionary data) { - var serialized = JsonConvert.SerializeObject(data); - return System.Text.Encoding.UTF8.GetBytes(serialized); + return JsonSerializer.SerializeToUtf8Bytes(data); } private byte[] ReadFileBytes() diff --git a/Directory.Packages.props b/Directory.Packages.props index 47079d55..52100a44 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,7 @@ + diff --git a/src/Authentication.Tests/MsalAuthenticationTests.cs b/src/Authentication.Tests/MsalAuthenticationTests.cs index de5ba758..b7e89e6d 100644 --- a/src/Authentication.Tests/MsalAuthenticationTests.cs +++ b/src/Authentication.Tests/MsalAuthenticationTests.cs @@ -79,6 +79,33 @@ public async Task MsalAcquireTokenWithDeviceCodeTest() Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); } + [TestMethod] + public async Task MsalAquireTokenWithManagedIdentity() + { + var tokenProvider = new MsalManagedIdentityTokenProvider(app, logger); + var tokenRequest = new TokenRequest(PackageUri); + tokenRequest.ClientId = Environment.GetEnvironmentVariable("ARTIFACTS_CREDENTIALPROVIDER_TEST_CLIENTID"); + + var result = await tokenProvider.GetTokenAsync(tokenRequest); + + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + + [TestMethod] + public async Task MsalAquireTokenWithServicePrincipal() + { + var tokenProvider = new MsalServicePrincipalTokenProvider(app, logger); + var tokenRequest = new TokenRequest(PackageUri); + tokenRequest.ClientId = Environment.GetEnvironmentVariable("ARTIFACTS_CREDENTIALPROVIDER_TEST_CLIENTID"); + tokenRequest.ClientCertificate = new X509Certificate2(Environment.GetEnvironmentVariable("ARTIFACTS_CREDENTIALPROVIDER_TEST_CERT_PATH") ?? string.Empty); + + var result = await tokenProvider.GetTokenAsync(tokenRequest); + + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + [System.Runtime.InteropServices.DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); } diff --git a/src/Authentication.Tests/TokenProviderTests.cs b/src/Authentication.Tests/TokenProviderTests.cs index a19cc278..e971fa2e 100644 --- a/src/Authentication.Tests/TokenProviderTests.cs +++ b/src/Authentication.Tests/TokenProviderTests.cs @@ -93,4 +93,60 @@ public void MsalDeviceCodeFlowContractTest() tokenRequest.IsInteractive = true; Assert.IsTrue(tokenProvider.CanGetToken(tokenRequest)); } + + [TestMethod] + public void MsalServicePrincipalContractTest() + { + appMock.Setup(x => x.AppConfig) + .Returns(new Mock().Object); + + var tokenProvider = new MsalServicePrincipalTokenProvider(appMock.Object, loggerMock.Object); + var tokenRequest = new TokenRequest(PackageUri); + + Assert.IsNotNull(tokenProvider.Name); + Assert.IsFalse(tokenProvider.IsInteractive); + + tokenRequest.IsInteractive = true; + Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest)); + + tokenRequest.IsInteractive = false; + tokenRequest.ClientId = "clientId"; + tokenRequest.ClientCertificate = Mock.Of(); + Assert.IsTrue(tokenProvider.CanGetToken(tokenRequest)); + + tokenRequest.IsInteractive = false; + tokenRequest.ClientId = null; + tokenRequest.ClientCertificate = Mock.Of(); + Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest)); + + tokenRequest.IsInteractive = false; + tokenRequest.ClientId = "clientId"; + tokenRequest.ClientCertificate = null; + Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest)); + } + + + [TestMethod] + public void MsalManagedIdentityContractTest() + { + appMock.Setup(x => x.AppConfig) + .Returns(new Mock().Object); + + var tokenProvider = new MsalManagedIdentityTokenProvider(appMock.Object, loggerMock.Object); + var tokenRequest = new TokenRequest(PackageUri); + + Assert.IsNotNull(tokenProvider.Name); + Assert.IsFalse(tokenProvider.IsInteractive); + + tokenRequest.IsInteractive = true; + Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest)); + + tokenRequest.IsInteractive = false; + tokenRequest.ClientId = "clientId"; + Assert.IsTrue(tokenProvider.CanGetToken(tokenRequest)); + + tokenRequest.IsInteractive = false; + tokenRequest.ClientId = null; + Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest)); + } } diff --git a/src/Authentication.Tests/Usings.cs b/src/Authentication.Tests/Usings.cs index dc485570..f5a0dc63 100644 --- a/src/Authentication.Tests/Usings.cs +++ b/src/Authentication.Tests/Usings.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT license. +global using System.Security.Cryptography.X509Certificates; global using Microsoft.Extensions.Logging; global using Microsoft.Identity.Client; global using Microsoft.Identity.Client.Extensions.Msal; diff --git a/src/Authentication/MsalConstants.cs b/src/Authentication/MsalConstants.cs index 8b8d21ac..42cb21db 100644 --- a/src/Authentication/MsalConstants.cs +++ b/src/Authentication/MsalConstants.cs @@ -6,7 +6,7 @@ namespace Microsoft.Artifacts.Authentication; public static class MsalConstants { - private const string AzureDevOpsResource = "499b84ac-1321-427f-aa17-267ca6975798/.default"; + public const string AzureDevOpsResource = "499b84ac-1321-427f-aa17-267ca6975798/.default"; public static readonly IEnumerable AzureDevOpsScopes = Array.AsReadOnly(new[] { AzureDevOpsResource }); public static readonly Guid FirstPartyTenant = Guid.Parse("f8cdef31-a31e-4b4a-93e4-5f571e91255a"); diff --git a/src/Authentication/MsalManagedIdentityTokenProvider.cs b/src/Authentication/MsalManagedIdentityTokenProvider.cs new file mode 100644 index 00000000..f400910a --- /dev/null +++ b/src/Authentication/MsalManagedIdentityTokenProvider.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AppConfig; + +namespace Microsoft.Artifacts.Authentication; + +public class MsalManagedIdentityTokenProvider : ITokenProvider +{ + private readonly ILogger logger; + private readonly IAppConfig appConfig; + + public MsalManagedIdentityTokenProvider(IPublicClientApplication app, ILogger logger) + { + this.appConfig = app.AppConfig; + this.logger = logger; + } + + public string Name => "MSAL Managed Identity"; + + public bool IsInteractive => false; + + public bool CanGetToken(TokenRequest tokenRequest) => + !string.IsNullOrWhiteSpace(tokenRequest.ClientId); + + public async Task GetTokenAsync(TokenRequest tokenRequest, CancellationToken cancellationToken = default) + { + try + { + if (string.IsNullOrWhiteSpace(tokenRequest.ClientId)) + { + logger.LogTrace(string.Format(Resources.MsalClientIdError, tokenRequest.ClientId)); + return null; + } + + IManagedIdentityApplication app = ManagedIdentityApplicationBuilder.Create(CreateManagedIdentityId(tokenRequest.ClientId!)) + .WithHttpClientFactory(appConfig.HttpClientFactory) + .WithLogging(appConfig.LoggingCallback, appConfig.LogLevel, appConfig.EnablePiiLogging, appConfig.IsDefaultPlatformLoggingEnabled) + .Build(); + + AuthenticationResult result = await app.AcquireTokenForManagedIdentity(MsalConstants.AzureDevOpsResource) + .ExecuteAsync() + .ConfigureAwait(false); + + return result; + } + catch (MsalServiceException ex) when (ex.ErrorCode is MsalError.ManagedIdentityRequestFailed) + { + logger.LogTrace(ex.Message); + return null; + } + catch (MsalServiceException ex) when (ex.ErrorCode is MsalError.ManagedIdentityUnreachableNetwork) + { + logger.LogTrace(ex.Message); + return null; + } + } + + private ManagedIdentityId CreateManagedIdentityId(string clientId) + { + return Guid.TryParse(clientId, out var id) + ? ManagedIdentityId.WithUserAssignedClientId(id.ToString()) + : ManagedIdentityId.SystemAssigned; + } +} diff --git a/src/Authentication/MsalServicePrincipalTokenProvider.cs b/src/Authentication/MsalServicePrincipalTokenProvider.cs new file mode 100644 index 00000000..a3c9973e --- /dev/null +++ b/src/Authentication/MsalServicePrincipalTokenProvider.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; + +namespace Microsoft.Artifacts.Authentication +{ + public class MsalServicePrincipalTokenProvider : ITokenProvider + { + public string Name => "MSAL Service Principal"; + + public bool IsInteractive => false; + + private readonly ILogger logger; + private readonly IAppConfig appConfig; + + public MsalServicePrincipalTokenProvider(IPublicClientApplication app, ILogger logger) + { + this.appConfig = app.AppConfig; + this.logger = logger; + } + + public bool CanGetToken(TokenRequest tokenRequest) + { + return !string.IsNullOrWhiteSpace(tokenRequest.ClientId) + && tokenRequest.ClientCertificate != null; + } + + public async Task GetTokenAsync(TokenRequest tokenRequest, CancellationToken cancellationToken = default) + { + try + { + if (!CanGetToken(tokenRequest)) + { + logger.LogTrace("InvalidInputs"); + return null; + } + + var app = ConfidentialClientApplicationBuilder.Create(tokenRequest.ClientId) + .WithHttpClientFactory(appConfig.HttpClientFactory) + .WithLogging(appConfig.LoggingCallback, appConfig.LogLevel, appConfig.EnablePiiLogging, appConfig.IsDefaultPlatformLoggingEnabled) + .WithCertificate(tokenRequest.ClientCertificate, sendX5C: true) + .WithTenantId(tokenRequest.TenantId) + .Build(); + + var result = await app.AcquireTokenForClient(MsalConstants.AzureDevOpsScopes) + .ExecuteAsync() + .ConfigureAwait(false); + + return result; + } + catch (Exception ex) + { + logger.LogTrace(ex.Message); + return null; + } + } + } +} diff --git a/src/Authentication/MsalTokenProviders.cs b/src/Authentication/MsalTokenProviders.cs index f3d6f1c1..63e81eff 100644 --- a/src/Authentication/MsalTokenProviders.cs +++ b/src/Authentication/MsalTokenProviders.cs @@ -11,6 +11,9 @@ public class MsalTokenProviders { public static IEnumerable Get(IPublicClientApplication app, ILogger logger) { + yield return new MsalServicePrincipalTokenProvider(app, logger); + yield return new MsalManagedIdentityTokenProvider(app, logger); + // TODO: Would be more useful if MsalSilentTokenProvider enumerated over each account from the outside yield return new MsalSilentTokenProvider(app, logger); diff --git a/src/Authentication/Resources.resx b/src/Authentication/Resources.resx index 6449a53e..807970dd 100644 --- a/src/Authentication/Resources.resx +++ b/src/Authentication/Resources.resx @@ -1,17 +1,17 @@ - @@ -153,4 +153,7 @@ Cannot use interactive authentication in a non-interactive app - + + Invalid Client Id {clientId} + + \ No newline at end of file diff --git a/src/Authentication/TokenRequest.cs b/src/Authentication/TokenRequest.cs index a0cac518..e5bd873c 100644 --- a/src/Authentication/TokenRequest.cs +++ b/src/Authentication/TokenRequest.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT license. +using System.Security.Cryptography.X509Certificates; using Microsoft.Identity.Client; namespace Microsoft.Artifacts.Authentication; @@ -31,4 +32,10 @@ public TokenRequest(Uri uri) public TimeSpan InteractiveTimeout { get; set; } = TimeSpan.FromMinutes(2); public Func? DeviceCodeResultCallback { get; set; } = null; + + public string? ClientId { get; set; } = null; + + public string? TenantId { get; set; } = null; + + public X509Certificate2? ClientCertificate { get; set; } = null; }