diff --git a/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs b/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs index bc17a31b..f8462e0b 100644 --- a/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs +++ b/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs @@ -204,7 +204,7 @@ public async Task HandleRequestAsync_WithInvalidBearer_ReturnsError() mockTokenProviderFactory.Setup(x => x.GetAsync(It.IsAny())) - .ReturnsAsync(new List() { + .ReturnsAsync(new List() { SetUpMockManagedIdentityTokenProvider(null).Object }); var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None); @@ -274,4 +274,49 @@ private static Mock SetUpMockManagedIdentityTokenProvider(string return mockTokenProvider; } -} + + [DataTestMethod] + [DataRow(EnvUtil.BuildTaskExternalEndpoints)] + [DataRow(EnvUtil.EndpointCredentials)] + public async Task CanProvideCredentialsPrefix_ReturnsTrueForCorrectEnvironmentVariable(string feedEndPointJsonEnvVar) + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpointPrefix\":\"http://example.pkgs.vsts.me/\", \"username\": \"testUser\", \"password\":\"testToken\"}]}"; + + Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson); + + var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri); + Assert.AreEqual(true, result); + } + + [TestMethod] + public async Task HandleRequestAsync_WithExternalEndpointPrefix_ReturnsSuccess() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"); + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpointPrefix\":\"http://example.pkgs.vsts.me/\", \"username\": \"testUser\", \"password\": \"testToken\"}]}"; + + Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, null); + 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_MatchesEndpointPrefixURLWithSpaces() + { + Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json"); + + string feedEndPointJson = "{\"endpointCredentials\":[{\"endpointPrefix\":\"http://example.pkgs.vsts.me/My Collection\", \"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); + } + +} diff --git a/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs b/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs index 9904caab..acdba2c7 100644 --- a/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs +++ b/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs @@ -138,7 +138,7 @@ 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); + var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToList(loggerMock.Object); result.Count.Should().Be(1); result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("testuser"); @@ -151,7 +151,7 @@ public void ParseExternalFeedEndpointsJsonToDictionary_WithoutUserName_ReturnsCr 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); + var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToList(loggerMock.Object); result.Count.Should().Be(1); result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("VssSessionToken"); @@ -164,7 +164,7 @@ public void ParseExternalFeedEndpointsJsonToDictionary_WithSingleQuotes_ReturnsC 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); + var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToList(loggerMock.Object); result.Count.Should().Be(1); result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("testuser"); @@ -180,7 +180,7 @@ public void ParseFeedEndpointsJsonToDictionary_WhenInvalidInput_ReturnsEmpty(str { Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, input); - var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object); + var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToList(loggerMock.Object); result.Should().BeEmpty(); } diff --git a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs index fbcf820a..2c265082 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs @@ -18,13 +18,13 @@ namespace NuGetCredentialProvider.CredentialProviders.VstsBuildTaskServiceEndpoi public sealed class VstsBuildTaskServiceEndpointCredentialProvider : CredentialProviderBase { private Lazy> LazyCredentials; - private Lazy> LazyExternalCredentials; + private Lazy LazyExternalCredentials; private ITokenProvidersFactory TokenProvidersFactory; private IAuthUtil AuthUtil; // Dictionary that maps an endpoint string to EndpointCredentials private Dictionary Credentials => LazyCredentials.Value; - private Dictionary ExternalCredentials => LazyExternalCredentials.Value; + private ExternalCredentialsList ExternalCredentials => LazyExternalCredentials.Value; public VstsBuildTaskServiceEndpointCredentialProvider(ILogger logger, ITokenProvidersFactory tokenProvidersFactory, IAuthUtil authUtil) : base(logger) @@ -34,9 +34,9 @@ public VstsBuildTaskServiceEndpointCredentialProvider(ILogger logger, ITokenProv { return FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(logger); }); - LazyExternalCredentials = new Lazy>(() => + LazyExternalCredentials = new Lazy(() => { - return FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(logger); + return FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToList(logger); }); AuthUtil = authUtil; } @@ -66,7 +66,7 @@ public override async Task HandleRequestAs Verbose(string.Format(Resources.IsRetry, request.IsRetry)); string uriString = request.Uri.AbsoluteUri; - bool externalEndpointFound = ExternalCredentials.TryGetValue(uriString, out ExternalEndpointCredentials matchingExternalEndpoint); + bool externalEndpointFound = ExternalCredentials.FindMatch(uriString, out var matchingExternalEndpoint); if (externalEndpointFound && !string.IsNullOrWhiteSpace(matchingExternalEndpoint.Password)) { Verbose(string.Format(Resources.BuildTaskEndpointMatchingUrlFound, uriString)); diff --git a/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs b/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs index 3487bc0e..dd8abaa0 100644 --- a/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs +++ b/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs @@ -3,23 +3,153 @@ using System.Text.Json; using System.Text.Json.Serialization; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using ILogger = NuGetCredentialProvider.Logging.ILogger; namespace NuGetCredentialProvider.Util; -public class ExternalEndpointCredentials + +public class ExternalCredentialsConverter : Newtonsoft.Json.JsonConverter +{ + public override ExternalCredentialsBase ReadJson(JsonReader reader, Type objectType, ExternalCredentialsBase existingValue, bool hasExistingValue, Newtonsoft.Json.JsonSerializer serializer) + { + // Load the object so we can inspect which discriminator property is present + var jtoken = JObject.Load(reader); + if (jtoken is not JObject obj) + { + return null; + } + + // Determine the concrete type based on endpoint / endpointPrefix and deserialize fully + if (obj.TryGetValue("endpointPrefix", StringComparison.OrdinalIgnoreCase, out var p) && p.Type == JTokenType.String) + { + var c = new ExternalEndpointPrefixCredentials(); + serializer.Populate(jtoken.CreateReader(), c); + return c; + } + else if (obj.TryGetValue("endpoint", StringComparison.OrdinalIgnoreCase, out _)) + { + var c = new ExternalEndpointCredentials(); + serializer.Populate(jtoken.CreateReader(), c); + return c; + } + + // Unknown shape; allow caller to handle validation failure + return null; + } + + public override void WriteJson(JsonWriter writer, ExternalCredentialsBase value, Newtonsoft.Json.JsonSerializer serializer) + { + // Not required + throw new NotImplementedException(); + } +} + +[Newtonsoft.Json.JsonConverter(typeof(ExternalCredentialsConverter))] +public abstract class ExternalCredentialsBase { - [JsonProperty("endpoint")] - public string Endpoint { get; set; } [JsonProperty("username")] public string Username { get; set; } [JsonProperty("password")] public string Password { get; set; } + + internal abstract bool Validate(); + + internal abstract bool IsMatch(string uriString, out ExternalCredentialsBase matchingExternalEndpoint); +} + +public class ExternalEndpointCredentials : ExternalCredentialsBase +{ + [JsonProperty("endpoint")] + public string Endpoint { get; set; } + + internal override bool IsMatch(string uriString, out ExternalCredentialsBase matchingExternalEndpoint) + { + matchingExternalEndpoint = null; + if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpointUri)) + { + return false; + } + var url = endpointUri.AbsoluteUri; + if (url.Equals(uriString, StringComparison.OrdinalIgnoreCase)) + { + matchingExternalEndpoint = this; + return true; + } + return false; + } + + internal override bool Validate() + { + return Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpointUri); + } + +} + +public class ExternalEndpointPrefixCredentials : ExternalCredentialsBase +{ + [JsonProperty("endpointPrefix")] + public string EndpointPrefix { get; set; } + + internal override bool IsMatch(string uriString, out ExternalCredentialsBase matchingExternalEndpoint) + { + matchingExternalEndpoint = null; + if (!Uri.TryCreate(EndpointPrefix, UriKind.Absolute, out var endpointUri)) + { + return false; + } + var url = endpointUri.AbsoluteUri; + if (!url.EndsWith("/")) + { + url += '/'; + } + if (uriString.StartsWith(url, StringComparison.OrdinalIgnoreCase)) + { + matchingExternalEndpoint = this; + return true; + } + return false; + } + + internal override bool Validate() + { + return Uri.TryCreate(EndpointPrefix, UriKind.Absolute, out var endpointUri); + } +} + + +public class ExternalCredentialsList : List +{ + internal bool FindMatch(string uriString, out ExternalCredentialsBase matchingExternalEndpoint) + { + matchingExternalEndpoint = null; + foreach (var credentials in this) + { + if (credentials.IsMatch(uriString, out matchingExternalEndpoint)) + { + return true; + } + } + return false; + } + + + public ExternalCredentialsBase this[string index] + { + get + { + if (FindMatch(index, out var matchingExternalEndpoint)) + { + return matchingExternalEndpoint; + } + return null; + } + } } public class ExternalEndpointCredentialsContainer { [JsonProperty("endpointCredentials")] - public ExternalEndpointCredentials[] EndpointCredentials { get; set; } + public ExternalCredentialsList EndpointCredentials { get; set; } } public class EndpointCredentials @@ -109,12 +239,12 @@ public static Dictionary ParseFeedEndpointsJsonToDi } } - public static Dictionary ParseExternalFeedEndpointsJsonToDictionary(ILogger logger) + public static ExternalCredentialsList ParseExternalFeedEndpointsJsonToList(ILogger logger) { string feedEndpointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints); if (string.IsNullOrWhiteSpace(feedEndpointsJson)) { - return new Dictionary(StringComparer.OrdinalIgnoreCase); + return new ExternalCredentialsList(); } try @@ -124,15 +254,17 @@ public static Dictionary ParseExternalFeedE { logger.Warning(Resources.InvalidJsonWarning); } + logger.Info(feedEndpointsJson); - Dictionary credsResult = new Dictionary(StringComparer.OrdinalIgnoreCase); + var credsResult = new ExternalCredentialsList(); ExternalEndpointCredentialsContainer endpointCredentials = JsonConvert.DeserializeObject(feedEndpointsJson); - if (endpointCredentials == null) + if (endpointCredentials == null || endpointCredentials.EndpointCredentials == null) { logger.Verbose(Resources.NoEndpointsFound); return credsResult; } + foreach (var credentials in endpointCredentials.EndpointCredentials) { if (credentials == null) @@ -146,17 +278,13 @@ public static Dictionary ParseExternalFeedE credentials.Username = "VssSessionToken"; } - if (!Uri.TryCreate(credentials.Endpoint, UriKind.Absolute, out var endpointUri)) + if (!credentials.Validate()) { logger.Verbose(Resources.EndpointParseFailure); break; } - - var urlEncodedEndpoint = endpointUri.AbsoluteUri; - if (!credsResult.ContainsKey(urlEncodedEndpoint)) - { - credsResult.Add(urlEncodedEndpoint, credentials); - } + + credsResult.Add(credentials); } return credsResult; @@ -164,7 +292,7 @@ public static Dictionary ParseExternalFeedE catch (Exception ex) { logger.Verbose(string.Format(Resources.VstsBuildTaskExternalCredentialCredentialProviderError, ex)); - return new Dictionary(StringComparer.OrdinalIgnoreCase); + return new ExternalCredentialsList(); } } }