Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
<Compile Include="Support\Clients\V3IndexClient.cs" />
<Compile Include="Support\CommonCollection.cs" />
<Compile Include="Support\CommonFixture.cs" />
<Compile Include="Support\E2EPublishingKeyProvider.cs" />
<Compile Include="Support\Configuration\ServiceDetails.cs" />
<Compile Include="ConnectivityTests.cs" />
<Compile Include="NuGetExeTests.cs" />
Expand All @@ -75,6 +76,7 @@
<Compile Include="Support\Configuration\E2ESecretConfigurationReader.cs" />
<Compile Include="Support\Configuration\GalleryConfiguration.cs" />
<Compile Include="Support\Configuration\SearchServiceConfiguration.cs" />
<Compile Include="Support\Configuration\TrustedPublishingSettings.cs" />
<Compile Include="Support\ExceptionExtensions.cs" />
<Compile Include="Support\HttpRequestMessageException.cs" />
<Compile Include="Support\HttpResponseMessageExtensions.cs" />
Expand Down Expand Up @@ -153,6 +155,9 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity">
<Version>1.12.0</Version>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.WebUtilities">
<Version>2.2.0</Version>
</PackageReference>
Expand Down
2 changes: 2 additions & 0 deletions src/NuGet.Services.EndToEnd/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

[assembly: AssemblyTitle("NuGet.Services.EndToEnd")]
[assembly: InternalsVisibleTo("NuGet.Services.EndToEnd.Test")]
[assembly: ComVisible(false)]
[assembly: Guid("35ca0b78-de1a-4c4e-a97f-85e84db17a2f")]
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,7 @@ public GalleryClient(SimpleHttpClient httpClient, TestSettings testSettings)

public Uri GetGalleryServiceBaseUrl()
{
var serviceBaseUrl = _testSettings.GalleryConfiguration.ServiceDetails?.BaseUrl;
if (serviceBaseUrl != null)
{
return new Uri(serviceBaseUrl);
}

return GetGalleryBaseUrl();
return new Uri(_testSettings.GalleryConfiguration.GetServiceBaseUrl());
}

public Uri GetGalleryBaseUrl()
Expand Down
2 changes: 2 additions & 0 deletions src/NuGet.Services.EndToEnd/Support/CommonFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public class CommonFixture : IAsyncLifetime
public virtual async Task InitializeAsync()
{
TestSettings = await TestSettings.CreateAsync();
// Initialize publishing authentication only during execution.
await TestSettings.InitializePublishingKeyAsync();
Clients = Clients.Initialize(TestSettings);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@ public class GalleryConfiguration
public string GalleryBaseUrl { get; set; }

public ServiceDetails ServiceDetails { get; set; }

// Use GalleryBaseUrl only when the service URL is null. Keep empty or invalid
// service URLs so they fail validation instead of silently targeting a different Gallery.
internal string GetServiceBaseUrl() => ServiceDetails?.BaseUrl ?? GalleryBaseUrl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

namespace NuGet.Services.EndToEnd.Support
{
public sealed class TrustedPublishingSettings
{
public string Environment { get; set; }

public string ResourceUri { get; set; }

public string[] AllowedGalleryHosts { get; set; }
}
}
209 changes: 209 additions & 0 deletions src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Azure.Identity;
using Newtonsoft.Json;

namespace NuGet.Services.EndToEnd.Support
{
/// <summary>
/// Exchanges the CloudTest VM's managed identity token for a short-lived Gallery key.
/// This does not use the pipeline identity, developer credentials, or a stored API key.
/// </summary>
internal sealed class E2EPublishingKeyProvider
Comment thread
agr marked this conversation as resolved.
{
private readonly Func<string, TokenCredential> _createCredential;
private readonly Func<HttpClient> _createHttpClient;
private readonly Func<DateTimeOffset> _utcNow;

internal E2EPublishingKeyProvider()
: this(CreateCredential, CreateHttpClient, () => DateTimeOffset.UtcNow)
{
}

internal E2EPublishingKeyProvider(
Func<string, TokenCredential> createCredential,
Func<HttpClient> createHttpClient,
Func<DateTimeOffset> utcNow)
{
_createCredential = createCredential ?? throw new ArgumentNullException(nameof(createCredential));
_createHttpClient = createHttpClient ?? throw new ArgumentNullException(nameof(createHttpClient));
_utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow));
}

internal async Task<PublishingKey> AcquireAsync(TestSettings settings)
{
// Validate everything before constructing a credential or making any network request.
var context = GetContext(settings);
if (!Guid.TryParse(settings.ManagedIdentityClientId, out var clientId) || clientId == Guid.Empty)
{
throw new InvalidOperationException("E2E trusted publishing requires a non-empty managed identity client ID.");
}

AccessToken token;
try
{
var credential = _createCredential(clientId.ToString());
var request = new TokenRequestContext(new[] { context.ResourceUri });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Azure.Identity 1.12.0 supports a bare resource URI for managed identity. Its IMDS implementation calls ScopesToResource(), which strips /.default when present and otherwise passes the resource through unchanged.

token = await credential.GetTokenAsync(request, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception)
{
// SDK exceptions can contain HTTP details.
throw new InvalidOperationException("Cannot acquire an Entra token using the CloudTest managed identity.");
}

if (string.IsNullOrWhiteSpace(token.Token))
{
throw new InvalidOperationException("Managed identity did not return an access token.");
}

using (var client = _createHttpClient())
using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(context.GalleryUri, "api/v2/token")))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
request.Headers.UserAgent.ParseAdd("NuGet-E2E-CloudTest/1.0");
request.Content = new StringContent(JsonConvert.SerializeObject(new
{
username = settings.TestAccountOwner,
tokenType = "ApiKey"
}), Encoding.UTF8, "application/json");

string content;
try
{
// Do not use SimpleHttpClient/RetryUtility here: a token can be exchanged only once.
using (var response = await client.SendAsync(request).ConfigureAwait(false))
{
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"Gallery token exchange failed (HTTP {(int)response.StatusCode}); not retrying. Check the environment policy and possible token replay.");
}

content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
}
catch (HttpRequestException)
{
throw new InvalidOperationException("Gallery token exchange failed; not retrying an ambiguous request. Details omitted.");
}
catch (OperationCanceledException)
{
throw new InvalidOperationException("Gallery token exchange timed out or was canceled; not retrying an ambiguous request.");
}

PublishingKey result;
try
{
result = JsonConvert.DeserializeObject<PublishingKey>(content);
}
catch (JsonException)
{
throw new InvalidOperationException("Gallery returned invalid JSON; response omitted.");
}

if (result == null || result.TokenType != "ApiKey" || string.IsNullOrWhiteSpace(result.ApiKey))
{
throw new InvalidOperationException("Gallery returned an invalid token exchange response. Expected tokenType 'ApiKey' and a non-empty apiKey.");
}

if (!result.Expires.HasValue || result.Expires.Value <= _utcNow())
{
throw new InvalidOperationException("Gallery returned a publishing key with a missing or expired timestamp.");
}

return result;
}
}

internal static PublishingContext GetContext(TestSettings settings)
{
if (settings == null) { throw new ArgumentNullException(nameof(settings)); }
var match = Regex.Match(settings.ConfigurationName ?? string.Empty,
@"\A(Dev|Int|Prod)(?:-[A-Za-z0-9][A-Za-z0-9_-]*)?\z", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
Comment thread
agr marked this conversation as resolved.
Outdated
if (!match.Success)
{
throw new InvalidOperationException("E2E trusted publishing requires a Dev, Int, or Prod configuration.");
}
if (string.IsNullOrWhiteSpace(settings.TestAccountOwner))
{
throw new InvalidOperationException("E2E trusted publishing requires a non-empty policy username.");
}

// Resource and destination allowlist come from NuGet.Services deployment configuration.
var publishing = settings.TrustedPublishing;
if (publishing == null ||
!string.Equals(publishing.Environment, match.Groups[1].Value, StringComparison.OrdinalIgnoreCase) ||
!TryGetHttpsRootUri(publishing.ResourceUri, out _) ||
publishing.AllowedGalleryHosts == null || publishing.AllowedGalleryHosts.Length == 0 ||
publishing.AllowedGalleryHosts.Any(host => Uri.CheckHostName(host) != UriHostNameType.Dns))
{
throw new InvalidOperationException("E2E trusted publishing requires deployment configuration with a matching environment, HTTPS resource URI, and approved Gallery hosts.");
}

var baseUrl = settings.GalleryConfiguration?.GetServiceBaseUrl();
if (!TryGetHttpsRootUri(baseUrl, out var gallery) ||
!publishing.AllowedGalleryHosts.Contains(gallery.Host, StringComparer.OrdinalIgnoreCase))
{
throw new InvalidOperationException("E2E trusted publishing requires an approved HTTPS Gallery root URL for the configuration environment.");
}

// Keep the configured resource spelling; Uri.AbsoluteUri would append a slash.
return new PublishingContext(publishing.ResourceUri, gallery);
}

private static bool TryGetHttpsRootUri(string value, out Uri uri)
{
return Uri.TryCreate(value, UriKind.Absolute, out uri) &&
uri.Scheme == Uri.UriSchemeHttps && uri.IsDefaultPort &&
string.IsNullOrEmpty(uri.UserInfo) && string.IsNullOrEmpty(uri.Query) &&
string.IsNullOrEmpty(uri.Fragment) && uri.AbsolutePath == "/";
}

private static TokenCredential CreateCredential(string clientId)
{
var options = new TokenCredentialOptions();
options.Retry.MaxRetries = 2;
options.Retry.NetworkTimeout = TimeSpan.FromSeconds(20);
options.Diagnostics.IsLoggingContentEnabled = false;
return new ManagedIdentityCredential(clientId, options);
}

private static HttpClient CreateHttpClient()
{
return new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromSeconds(20)
};
}

internal sealed class PublishingKey
Comment thread
agr marked this conversation as resolved.
Outdated
{
public string TokenType { get; set; }
public string ApiKey { get; set; }
public DateTimeOffset? Expires { get; set; }
}

internal sealed class PublishingContext
{
internal PublishingContext(string resourceUri, Uri galleryUri)
{
ResourceUri = resourceUri;
GalleryUri = galleryUri;
}

internal string ResourceUri { get; }
internal Uri GalleryUri { get; }
}
}
}
63 changes: 58 additions & 5 deletions src/NuGet.Services.EndToEnd/Support/TestSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,53 @@ public class TestSettings

private static TestSettings _testSettings = null;
private static SemaphoreSlim _semaphore = new SemaphoreSlim(1);
private readonly Lazy<Task> _initializePublishingKey;

private TestSettings()
internal TestSettings(E2EPublishingKeyProvider publishingKeyProvider)
{
if (publishingKeyProvider == null) { throw new ArgumentNullException(nameof(publishingKeyProvider)); }

// Share the same authentication task across fixtures, even if it fails.
_initializePublishingKey = new Lazy<Task>(() => InitializePublishingKeyCoreAsync(publishingKeyProvider),
LazyThreadSafetyMode.ExecutionAndPublication);
}

internal string ConfigurationName { get; set; }

internal string ManagedIdentityClientId { get; set; }

internal TrustedPublishingSettings TrustedPublishing { get; set; }

public DateTimeOffset? PublishingKeyExpires { get; private set; }

internal Task InitializePublishingKeyAsync() => _initializePublishingKey.Value;

private async Task InitializePublishingKeyCoreAsync(E2EPublishingKeyProvider provider)
{
// Only standalone local configurations may use a supplied API key.
// Identity or deployment settings always require trusted publishing, with no fallback.
if (IsLocalConfiguration(ConfigurationName) &&
string.IsNullOrEmpty(ManagedIdentityClientId) && TrustedPublishing == null)
{
if (string.IsNullOrWhiteSpace(ApiKey) || ApiKey == "API_KEY")
{
throw new InvalidOperationException("Local E2E execution requires a non-empty TestSettings.ApiKey; replace the API_KEY placeholder with your own key.");
}

return;
}

ApiKey = null;
var key = await provider.AcquireAsync(this).ConfigureAwait(false);
ApiKey = key.ApiKey;
PublishingKeyExpires = key.Expires;
}

private static bool IsLocalConfiguration(string configurationName)
{
return string.Equals(configurationName, "Dev", StringComparison.OrdinalIgnoreCase) ||
string.Equals(configurationName, "Int", StringComparison.OrdinalIgnoreCase) ||
string.Equals(configurationName, "Prod", StringComparison.OrdinalIgnoreCase);
}

public string V3IndexUrl { get; set; }
Expand Down Expand Up @@ -85,9 +129,7 @@ public static TestSettings Create()

public static async Task<TestSettings> CreateLocalTestConfigurationAsync(string configurationName)
{
if (configurationName == "Dev" ||
configurationName == "Int" ||
configurationName == "Prod")
if (IsLocalConfiguration(configurationName))
{
return await CreateInternalAsync(configurationName);
}
Expand Down Expand Up @@ -137,11 +179,22 @@ private static async Task<TestSettings> CreateInternalAsync(string configuration

var configurationRoot = builder.Build();

return await CreateFromConfigurationAsync(configurationRoot, configurationName, new E2EPublishingKeyProvider());
}

internal static async Task<TestSettings> CreateFromConfigurationAsync(
IConfigurationRoot configurationRoot, string configurationName, E2EPublishingKeyProvider publishingKeyProvider)
{
var managedIdentityClientId = configurationRoot["ManagedIdentityClientId"];

var configuration = new E2ESecretConfigurationReader(configurationRoot, new ConfigurationRootSecretReaderFactory(configurationRoot));
await configuration.InjectSecrets();

var testSettings = new TestSettings();
var testSettings = new TestSettings(publishingKeyProvider);
configurationRoot.GetSection("TestSettings").Bind(testSettings);
testSettings.ConfigurationName = configurationName;
testSettings.ManagedIdentityClientId = managedIdentityClientId;
testSettings.TrustedPublishing = configurationRoot.GetSection("TrustedPublishing").Get<TrustedPublishingSettings>();

return testSettings;
}
Expand Down
Loading
Loading