diff --git a/src/Cake.Common.Tests/Fixtures/Build/GitHubNuGetLoginCommandsFixture.cs b/src/Cake.Common.Tests/Fixtures/Build/GitHubNuGetLoginCommandsFixture.cs new file mode 100644 index 0000000000..e3bd5bd304 --- /dev/null +++ b/src/Cake.Common.Tests/Fixtures/Build/GitHubNuGetLoginCommandsFixture.cs @@ -0,0 +1,160 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Cake.Common.Build.GitHubActions.Commands; +using Cake.Common.Tests.Fakes; +using Cake.Core; +using Cake.Testing; +using NSubstitute; + +namespace Cake.Common.Tests.Fixtures.Build +{ + /// + /// Provides a fake and GitHub Actions environment for + /// tests. + /// + internal class GitHubNuGetLoginCommandsFixture : HttpMessageHandler + { + /// + /// Base OIDC request URL (without audience); matches GitHub Actions format. + /// + public const string OidcRequestUrl = "https://github.example/api/v2/token?foo=1"; + + /// + /// NuGet token service URL used in tests that override defaults. + /// + public const string TokenServiceUrl = "https://nuget.example/api/v2/token"; + + /// + /// Default OIDC audience string (matches default). + /// + public const string DefaultAudience = "https://www.nuget.org"; + + /// + /// Default NuGet token service URL (matches default). + /// + public const string DefaultTokenServiceUrl = "https://www.nuget.org/api/v2/token"; + + /// + /// Bearer token used for the GitHub OIDC request token environment variable. + /// + public const string ActionsIdTokenRequestToken = "actions-id-token-request-token"; + + /// + /// Expected full OIDC GET URI including audience query parameter for . + /// + public static readonly string ExpectedOidcGetUriDefaultAudience = BuildExpectedOidcUri(OidcRequestUrl, DefaultAudience); + + private readonly GitHubActionsInfoFixture _gitHubActionsInfoFixture; + + /// + /// Initializes a new instance of the class. + /// + public GitHubNuGetLoginCommandsFixture() + { + _gitHubActionsInfoFixture = new GitHubActionsInfoFixture(); + FileSystem = new FakeFileSystem(_gitHubActionsInfoFixture.Environment); + FileSystem.CreateDirectory("/opt"); + Writer = new FakeBuildSystemServiceMessageWriter(); + Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN").Returns(ActionsIdTokenRequestToken); + Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL").Returns(OidcRequestUrl); + } + + /// + /// Gets the substituted Cake environment. + /// + public ICakeEnvironment Environment => _gitHubActionsInfoFixture.Environment; + + /// + /// Gets the fake file system. + /// + public FakeFileSystem FileSystem { get; } + + /// + /// Gets the fake workflow command writer. + /// + public FakeBuildSystemServiceMessageWriter Writer { get; } + + /// + /// Creates wired to this handler. + /// + /// A new commands instance. + public GitHubActionsCommands CreateGitHubActionsCommands() + { + return new GitHubActionsCommands(Environment, FileSystem, Writer, _gitHubActionsInfoFixture.CreateEnvironmentInfo(), _ => new HttpClient(this)); + } + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return HandleAsync(request, cancellationToken); + } + + /// + /// Dispatches the request; override in derived fixtures for scenario-specific behavior. + /// + /// The HTTP request. + /// The cancellation token. + /// The HTTP response. + protected virtual Task HandleAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri is null) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)); + } + + var absolute = request.RequestUri.AbsoluteUri; + + if (request.Method == HttpMethod.Get + && absolute == ExpectedOidcGetUriDefaultAudience + && request.Headers.Authorization?.Scheme == "Bearer" + && request.Headers.Authorization.Parameter == ActionsIdTokenRequestToken) + { + var json = JsonSerializer.Serialize(new { value = "mock-oidc-jwt" }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Post + && absolute == TokenServiceUrl + && request.Headers.Authorization?.Scheme == "Bearer" + && request.Headers.Authorization.Parameter == "mock-oidc-jwt" + && request.Headers.UserAgent.ToString().Contains("nuget/login-action", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"apiKey":"expected-api-key"}""", Encoding.UTF8, "application/json") + }); + } + + if (request.Method == HttpMethod.Post + && absolute == DefaultTokenServiceUrl + && request.Headers.Authorization?.Scheme == "Bearer" + && request.Headers.Authorization.Parameter == "mock-oidc-jwt" + && request.Headers.UserAgent.ToString().Contains("nuget/login-action", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"apiKey":"expected-api-key-default-url"}""", Encoding.UTF8, "application/json") + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + + private static string BuildExpectedOidcUri(string oidcRequestUrl, string audience) + { + var separator = oidcRequestUrl.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + return $"{oidcRequestUrl}{separator}audience={Uri.EscapeDataString(audience)}"; + } + } +} diff --git a/src/Cake.Common.Tests/Unit/Build/GitHubActions/Commands/GitHubActionsCommandsTests.cs b/src/Cake.Common.Tests/Unit/Build/GitHubActions/Commands/GitHubActionsCommandsTests.cs index 1f25cf0e1f..5e8c22e7bb 100644 --- a/src/Cake.Common.Tests/Unit/Build/GitHubActions/Commands/GitHubActionsCommandsTests.cs +++ b/src/Cake.Common.Tests/Unit/Build/GitHubActions/Commands/GitHubActionsCommandsTests.cs @@ -4,9 +4,14 @@ using System.Collections.Generic; using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; using System.Threading.Tasks; using Cake.Common.Build; using Cake.Common.Build.GitHubActions.Commands; +using Cake.Common.Build.GitHubActions.Commands.NuGet; using Cake.Common.Build.GitHubActions.Data; using Cake.Common.Tests.Fixtures.Build; using Cake.Core; @@ -758,5 +763,270 @@ public async Task Should_Download(string workingDirectory, string testPath) Assert.Equal("Cake", file.GetTextContent()); } } + + /// + /// Tests for and + /// . + /// + public sealed class TheNuGetLoginMethod + { + [Fact] + public async Task Should_Return_Api_Key_When_Login_With_UserName_Only() + { + // Given + var fixture = new GitHubNuGetLoginCommandsFixture(); + var commands = fixture.CreateGitHubActionsCommands(); + + // When + var apiKey = await commands.NuGetLogin("testuser", TestContext.Current.CancellationToken); + + // Then + Assert.Equal("expected-api-key-default-url", apiKey); + } + + [Fact] + public async Task Should_Mask_Sensitive_Values() + { + // Given + var fixture = new GitHubNuGetLoginCommandsFixture(); + var commands = fixture.CreateGitHubActionsCommands(); + + // When + var apiKey = await commands.NuGetLogin("testuser", TestContext.Current.CancellationToken); + + // Then + Assert.Equal("expected-api-key-default-url", apiKey); + Assert.Equal(3, fixture.Writer.Entries.Count); + Assert.Contains($"::add-mask::{GitHubNuGetLoginCommandsFixture.ActionsIdTokenRequestToken}", fixture.Writer.Entries); + Assert.Contains("::add-mask::mock-oidc-jwt", fixture.Writer.Entries); + Assert.Contains("::add-mask::expected-api-key-default-url", fixture.Writer.Entries); + } + + [Fact] + public async Task Should_Return_Api_Key_When_Settings_Use_Custom_Token_Service_Url() + { + // Given + var fixture = new GitHubNuGetLoginCommandsFixture(); + var commands = fixture.CreateGitHubActionsCommands(); + var settings = new GitHubNuGetLoginSettings( + "testuser", + GitHubNuGetLoginCommandsFixture.TokenServiceUrl, + GitHubNuGetLoginCommandsFixture.DefaultAudience); + + // When + var apiKey = await commands.NuGetLogin(settings, TestContext.Current.CancellationToken); + + // Then + Assert.Equal("expected-api-key", apiKey); + } + + [Fact] + public async Task Should_Throw_If_Settings_Is_Null() + { + // Given + var commands = new GitHubNuGetLoginCommandsFixture().CreateGitHubActionsCommands(); + GitHubNuGetLoginSettings settings = null; + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin(settings, TestContext.Current.CancellationToken)); + + // Then + AssertEx.IsArgumentNullException(result, "settings"); + } + + [Fact] + public async Task Should_Throw_If_UserName_Is_Null() + { + // Given + var commands = new GitHubNuGetLoginCommandsFixture().CreateGitHubActionsCommands(); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin((string)null, TestContext.Current.CancellationToken)); + + // Then + AssertEx.IsArgumentNullException(result, "userName"); + } + + [Fact] + public async Task Should_Throw_If_UserName_Is_Whitespace() + { + // Given + var commands = new GitHubNuGetLoginCommandsFixture().CreateGitHubActionsCommands(); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin(" ", TestContext.Current.CancellationToken)); + + // Then + Assert.IsType(result); + Assert.Equal("userName", ((ArgumentException)result).ParamName); + } + + [Fact] + public async Task Should_Throw_If_Settings_UserName_Is_Whitespace() + { + // Given + var commands = new GitHubNuGetLoginCommandsFixture().CreateGitHubActionsCommands(); + var settings = new GitHubNuGetLoginSettings(" "); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin(settings, TestContext.Current.CancellationToken)); + + // Then + Assert.IsType(result); + Assert.Equal("userName", ((ArgumentException)result).ParamName); + } + + [Fact] + public async Task Should_Throw_If_Token_Service_Url_Is_Null() + { + // Given + var commands = new GitHubNuGetLoginCommandsFixture().CreateGitHubActionsCommands(); + var settings = new GitHubNuGetLoginSettings("user", null); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin(settings, TestContext.Current.CancellationToken)); + + // Then + AssertEx.IsArgumentNullException(result, "tokenServiceUrl"); + } + + [Fact] + public async Task Should_Throw_If_Token_Service_Url_Is_Whitespace() + { + // Given + var commands = new GitHubNuGetLoginCommandsFixture().CreateGitHubActionsCommands(); + var settings = new GitHubNuGetLoginSettings("user", " "); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin(settings, TestContext.Current.CancellationToken)); + + // Then + Assert.IsType(result); + Assert.Equal("tokenServiceUrl", ((ArgumentException)result).ParamName); + } + + [Fact] + public async Task Should_Throw_If_Audience_Is_Null() + { + // Given + var commands = new GitHubNuGetLoginCommandsFixture().CreateGitHubActionsCommands(); + var settings = new GitHubNuGetLoginSettings("user", GitHubNuGetLoginCommandsFixture.DefaultTokenServiceUrl, null); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin(settings, TestContext.Current.CancellationToken)); + + // Then + AssertEx.IsArgumentNullException(result, "audience"); + } + + [Fact] + public async Task Should_Throw_If_Audience_Is_Whitespace() + { + // Given + var commands = new GitHubNuGetLoginCommandsFixture().CreateGitHubActionsCommands(); + var settings = new GitHubNuGetLoginSettings("user", GitHubNuGetLoginCommandsFixture.DefaultTokenServiceUrl, " "); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin(settings, TestContext.Current.CancellationToken)); + + // Then + Assert.IsType(result); + Assert.Equal("audience", ((ArgumentException)result).ParamName); + } + + [Fact] + public async Task Should_Throw_If_Missing_Oidc_Request_Token() + { + // Given + var fixture = new GitHubNuGetLoginCommandsFixture(); + fixture.Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN").Returns((string)null); + var commands = fixture.CreateGitHubActionsCommands(); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin("user", TestContext.Current.CancellationToken)); + + // Then + AssertEx.IsCakeException(result, "Missing GitHub OIDC request environment variables."); + } + + [Fact] + public async Task Should_Throw_If_Missing_Oidc_Request_Url() + { + // Given + var fixture = new GitHubNuGetLoginCommandsFixture(); + fixture.Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL").Returns((string)null); + var commands = fixture.CreateGitHubActionsCommands(); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin("user", TestContext.Current.CancellationToken)); + + // Then + AssertEx.IsCakeException(result, "Missing GitHub OIDC request environment variables."); + } + + [Fact] + public async Task Should_Throw_If_Oidc_Request_Fails() + { + // Given + var fixture = new BrokenOidcGitHubNuGetLoginFixture(); + var commands = fixture.CreateGitHubActionsCommands(); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin("user", TestContext.Current.CancellationToken)); + + // Then + AssertEx.IsCakeException(result, "Failed to retrieve OIDC token from GitHub (401 Unauthorized): oidc-failed"); + } + + [Fact] + public async Task Should_Throw_If_Token_Exchange_Fails_With_Json_Error() + { + // Given + var fixture = new FailingTokenExchangeGitHubNuGetLoginFixture(); + var commands = fixture.CreateGitHubActionsCommands(); + var settings = new GitHubNuGetLoginSettings( + "user", + GitHubNuGetLoginCommandsFixture.TokenServiceUrl, + GitHubNuGetLoginCommandsFixture.DefaultAudience); + + // When + var result = await Record.ExceptionAsync(() => commands.NuGetLogin(settings, TestContext.Current.CancellationToken)); + + // Then + AssertEx.IsCakeException(result, "Token exchange failed (422 UnprocessableEntity): invalid_grant"); + } + + private sealed class BrokenOidcGitHubNuGetLoginFixture : GitHubNuGetLoginCommandsFixture + { + protected override Task HandleAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get && request.RequestUri?.AbsoluteUri == ExpectedOidcGetUriDefaultAudience) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("oidc-failed", Encoding.UTF8, "text/plain") + }); + } + + return base.HandleAsync(request, cancellationToken); + } + } + + private sealed class FailingTokenExchangeGitHubNuGetLoginFixture : GitHubNuGetLoginCommandsFixture + { + protected override Task HandleAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == TokenServiceUrl) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.UnprocessableEntity) + { + Content = new StringContent("""{"error":"invalid_grant"}""", Encoding.UTF8, "application/json") + }); + } + + return base.HandleAsync(request, cancellationToken); + } + } + } } -} \ No newline at end of file +} diff --git a/src/Cake.Common/Build/GitHubActions/Commands/GitHubActionsCommands.cs b/src/Cake.Common/Build/GitHubActions/Commands/GitHubActionsCommands.cs index e207f11b0d..37c67c13c7 100644 --- a/src/Cake.Common/Build/GitHubActions/Commands/GitHubActionsCommands.cs +++ b/src/Cake.Common/Build/GitHubActions/Commands/GitHubActionsCommands.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -7,8 +7,10 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Cake.Common.Build.GitHubActions.Commands.Artifact; +using Cake.Common.Build.GitHubActions.Commands.NuGet; using Cake.Common.Build.GitHubActions.Data; using Cake.Core; using Cake.Core.IO; @@ -25,6 +27,7 @@ public sealed class GitHubActionsCommands private readonly IBuildSystemServiceMessageWriter _writer; private readonly GitHubActionsEnvironmentInfo _actionsEnvironment; private readonly GitHubActionsArtifactService _artifactsService; + private readonly GitHubNuGetLoginService _nuGetLoginService; /// /// Initializes a new instance of the class. @@ -47,7 +50,9 @@ public GitHubActionsCommands( _actionsEnvironment = actionsEnvironment ?? throw new ArgumentNullException(nameof(actionsEnvironment)); // Internal service class, keeping public API unchanged, // introduced in pr https://github.com/cake-build/cake/pull/4350 - _artifactsService = new GitHubActionsArtifactService(environment, fileSystem, actionsEnvironment, createHttpClient ?? throw new ArgumentNullException(nameof(createHttpClient))); + var createClient = createHttpClient ?? throw new ArgumentNullException(nameof(createHttpClient)); + _artifactsService = new GitHubActionsArtifactService(environment, fileSystem, actionsEnvironment, createClient); + _nuGetLoginService = new GitHubNuGetLoginService(environment, createClient, SetSecret); } /// @@ -392,6 +397,75 @@ public async Task DownloadArtifact(string artifactName, DirectoryPath path) await _artifactsService.DownloadArtifactFiles(artifactName, directory.Path); } + /// + /// Exchanges a GitHub Actions OIDC identity token for a short-lived NuGet.org API key (trusted publishing). + /// Default token service URL is https://www.nuget.org/api/v2/token and default OIDC audience is https://www.nuget.org. + /// Requires permissions: id-token: write in the GitHub Actions workflow. + /// OIDC tokens and the returned API key are automatically masked in the build log. + /// + /// + /// + /// var nuGetUserName = EnvironmentVariable("NUGET_USERNAME"); + /// var apiKey = await GitHubActions.Commands.NuGetLogin(nuGetUserName); + /// + /// var settings = new DotNetNuGetPushSettings + /// { + /// ApiKey = apiKey, + /// Source = "https://api.nuget.org/v3/index.json" + /// }; + /// + /// foreach (var packageFilePath in GetFiles("./*.nupkg")) + /// { + /// DotNetNuGetPush(packageFilePath, settings); + /// } + /// + /// + /// The NuGet.org account user name. + /// The cancellation token. + /// The NuGet API key. + public Task NuGetLogin(string userName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userName); + + return NuGetLogin(new GitHubNuGetLoginSettings(userName), cancellationToken); + } + + /// + /// Exchanges a GitHub Actions OIDC identity token for a NuGet API key using the specified token service URL and audience. + /// Requires permissions: id-token: write in the GitHub Actions workflow. + /// OIDC tokens and the returned API key are automatically masked in the build log. + /// + /// + /// + /// var loginSettings = new GitHubNuGetLoginSettings( + /// userName: "myorg", + /// tokenServiceUrl: "https://www.nuget.org/api/v2/token", + /// audience: "https://www.nuget.org"); + /// + /// var apiKey = await GitHubActions.Commands.NuGetLogin(loginSettings); + /// + /// var settings = new DotNetNuGetPushSettings + /// { + /// ApiKey = apiKey, + /// Source = EnvironmentVariable("NUGET_API_URL") + /// }; + /// + /// foreach (var packageFilePath in GetFiles("./*.nupkg")) + /// { + /// DotNetNuGetPush(packageFilePath, settings); + /// } + /// + /// + /// The user name, token service URL, and OIDC audience. + /// The cancellation token. + /// The NuGet API key. + public Task NuGetLogin(GitHubNuGetLoginSettings settings, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settings); + + return _nuGetLoginService.LoginAsync(settings, cancellationToken); + } + internal void WriteCommand(string command, string message = null) { WriteCommand(command, new Dictionary(), message); diff --git a/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginJsonContext.cs b/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginJsonContext.cs new file mode 100644 index 0000000000..623dad4c8d --- /dev/null +++ b/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginJsonContext.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Cake.Common.Build.GitHubActions.Commands.NuGet +{ + /// + /// Source-generated JSON serialization context for GitHub NuGet login HTTP payloads. + /// + [JsonSerializable(typeof(GitHubOidcTokenResponse))] + [JsonSerializable(typeof(NuGetTokenExchangeRequest))] + [JsonSerializable(typeof(NuGetTokenExchangeResponse))] + [JsonSerializable(typeof(NuGetTokenErrorResponse))] + internal partial class GitHubNuGetLoginJsonContext : JsonSerializerContext + { + } +} diff --git a/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginModels.cs b/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginModels.cs new file mode 100644 index 0000000000..945f39727f --- /dev/null +++ b/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginModels.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Cake.Common.Build.GitHubActions.Commands.NuGet +{ +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + /// + /// JSON payload returned by the GitHub Actions OIDC token endpoint. + /// + /// The OIDC JWT value. + internal sealed record GitHubOidcTokenResponse( + [property: JsonPropertyName("value")] string Value); + + /// + /// JSON body sent to the NuGet token exchange endpoint. + /// + /// The NuGet user name. + /// The token type (always ApiKey for this flow). + internal sealed record NuGetTokenExchangeRequest( + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("tokenType")] string TokenType); + + /// + /// JSON payload returned on successful NuGet token exchange. + /// + /// The NuGet API key. + internal sealed record NuGetTokenExchangeResponse( + [property: JsonPropertyName("apiKey")] string ApiKey); + + /// + /// JSON payload returned when NuGet token exchange fails. + /// + /// The error message from the service. + internal sealed record NuGetTokenErrorResponse( + [property: JsonPropertyName("error")] string Error); +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter +} diff --git a/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginService.cs b/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginService.cs new file mode 100644 index 0000000000..7fa129173e --- /dev/null +++ b/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginService.cs @@ -0,0 +1,191 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Cake.Core; + +namespace Cake.Common.Build.GitHubActions.Commands.NuGet +{ + /// + /// Exchanges a GitHub Actions OIDC token for a NuGet API key via the NuGet token service. + /// + internal sealed class GitHubNuGetLoginService + { + private const string OidcRequestTokenVariable = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; + private const string OidcRequestUrlVariable = "ACTIONS_ID_TOKEN_REQUEST_URL"; + private const string UserAgent = "nuget/login-action"; + + private readonly ICakeEnvironment _environment; + private readonly Func _createHttpClient; + private readonly Action _maskSecret; + + /// + /// Initializes a new instance of the class. + /// + /// The Cake environment. + /// A factory that creates an for the given logical operation name. + /// A callback that masks sensitive values in the build log. + public GitHubNuGetLoginService( + ICakeEnvironment environment, + Func createHttpClient, + Action maskSecret) + { + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _createHttpClient = createHttpClient ?? throw new ArgumentNullException(nameof(createHttpClient)); + _maskSecret = maskSecret ?? throw new ArgumentNullException(nameof(maskSecret)); + } + + /// + /// Requests an OIDC token from GitHub and exchanges it for a NuGet API key. + /// + /// User name, token service URL, and OIDC audience. + /// The cancellation token. + /// The NuGet API key. + public async Task LoginAsync(GitHubNuGetLoginSettings settings, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentException.ThrowIfNullOrWhiteSpace(settings.UserName, "userName"); + ArgumentException.ThrowIfNullOrWhiteSpace(settings.TokenServiceUrl, "tokenServiceUrl"); + ArgumentException.ThrowIfNullOrWhiteSpace(settings.Audience, "audience"); + + var oidcRequestToken = _environment.GetEnvironmentVariable(OidcRequestTokenVariable); + var oidcRequestUrl = _environment.GetEnvironmentVariable(OidcRequestUrlVariable); + + if (string.IsNullOrWhiteSpace(oidcRequestToken) || string.IsNullOrWhiteSpace(oidcRequestUrl)) + { + throw new CakeException("Missing GitHub OIDC request environment variables."); + } + + MaskSecret(oidcRequestToken); + + var tokenRequestUri = BuildOidcTokenRequestUri(oidcRequestUrl, settings.Audience); + + var oidcJwt = await RequestGitHubOidcTokenAsync(oidcRequestToken, tokenRequestUri, cancellationToken).ConfigureAwait(false); + + MaskSecret(oidcJwt); + + var apiKey = await ExchangeForNuGetApiKeyAsync(settings.UserName, settings.TokenServiceUrl, oidcJwt, cancellationToken).ConfigureAwait(false); + + MaskSecret(apiKey); + + return apiKey; + } + + private static Uri BuildOidcTokenRequestUri(string oidcRequestUrl, string nugetAudience) + { + var separator = oidcRequestUrl.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + var raw = $"{oidcRequestUrl}{separator}audience={Uri.EscapeDataString(nugetAudience)}"; + return new Uri(raw, UriKind.Absolute); + } + + private async Task RequestGitHubOidcTokenAsync(string oidcRequestToken, Uri tokenUrl, CancellationToken cancellationToken) + { + using var httpClient = _createHttpClient(nameof(RequestGitHubOidcTokenAsync)); + using var request = new HttpRequestMessage(HttpMethod.Get, tokenUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", oidcRequestToken); + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new CakeException( + $"Failed to retrieve OIDC token from GitHub ({(int)response.StatusCode} {response.StatusCode}): {body}"); + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var tokenResponse = await JsonSerializer.DeserializeAsync( + stream, + GitHubNuGetLoginJsonContext.Default.GitHubOidcTokenResponse, + cancellationToken).ConfigureAwait(false); + + if (tokenResponse is null || string.IsNullOrEmpty(tokenResponse.Value)) + { + throw new CakeException("Failed to retrieve OIDC token from GitHub."); + } + + return tokenResponse.Value; + } + + private async Task ExchangeForNuGetApiKeyAsync( + string userName, + string tokenServiceUrl, + string oidcJwt, + CancellationToken cancellationToken) + { + var body = new NuGetTokenExchangeRequest(userName, "ApiKey"); + + var json = JsonSerializer.SerializeToUtf8Bytes(body, GitHubNuGetLoginJsonContext.Default.NuGetTokenExchangeRequest); + + using var httpClient = _createHttpClient(nameof(ExchangeForNuGetApiKeyAsync)); + using var request = new HttpRequestMessage(HttpMethod.Post, tokenServiceUrl) + { + Content = new ByteArrayContent(json) + }; + + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", oidcJwt); + request.Headers.UserAgent.ParseAdd(UserAgent); + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var message = BuildTokenExchangeErrorMessage(response.StatusCode, errorBody); + throw new CakeException(message); + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var exchange = await JsonSerializer.DeserializeAsync( + stream, + GitHubNuGetLoginJsonContext.Default.NuGetTokenExchangeResponse, + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(exchange?.ApiKey)) + { + throw new CakeException("Response did not contain \"apiKey\"."); + } + + return exchange.ApiKey; + } + + private static string BuildTokenExchangeErrorMessage(HttpStatusCode statusCode, string errorBody) + { + var errorMessage = $"Token exchange failed ({(int)statusCode} {statusCode})"; + + try + { + var errorJson = JsonSerializer.Deserialize( + errorBody, + GitHubNuGetLoginJsonContext.Default.NuGetTokenErrorResponse); + + if (!string.IsNullOrEmpty(errorJson?.Error)) + { + return $"{errorMessage}: {errorJson.Error}"; + } + } + catch (JsonException) + { + // Fall through to raw body + } + + return $"{errorMessage}: {errorBody}"; + } + + private void MaskSecret(string secret) + { + if (!string.IsNullOrEmpty(secret)) + { + _maskSecret(secret); + } + } + } +} diff --git a/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginSettings.cs b/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginSettings.cs new file mode 100644 index 0000000000..023b8acfb1 --- /dev/null +++ b/src/Cake.Common/Build/GitHubActions/Commands/NuGet/GitHubNuGetLoginSettings.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Cake.Common.Build.GitHubActions.Commands.NuGet +{ + /// + /// Configuration for exchanging a GitHub Actions OIDC token for a NuGet API key via the NuGet token service. + /// + /// The NuGet.org account user name. + /// The NuGet token service URL. + /// The OIDC audience passed to the GitHub Actions ID token request. +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public record GitHubNuGetLoginSettings( + string UserName, + string TokenServiceUrl = "https://www.nuget.org/api/v2/token", + string Audience = "https://www.nuget.org"); +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter +} diff --git a/tests/integration/Cake.Common/Build/GitHubActions/GitHubActionsProvider.cake b/tests/integration/Cake.Common/Build/GitHubActions/GitHubActionsProvider.cake index 4c70b1b9b4..49267c5657 100644 --- a/tests/integration/Cake.Common/Build/GitHubActions/GitHubActionsProvider.cake +++ b/tests/integration/Cake.Common/Build/GitHubActions/GitHubActionsProvider.cake @@ -2,6 +2,10 @@ #load "./../../../utilities/paths.cake" #load "./../../../utilities/io.cake" +using System.Net; +using System.Net.Http; +using System.Text.Json; + Task("Cake.Common.Build.GitHubActionsProvider.Provider") .Does(() => { Assert.Equal(BuildProvider.GitHubActions, BuildSystem.Provider); @@ -179,6 +183,24 @@ Task("Cake.Common.Build.GitHubActionsProvider.Commands.DownloadArtifact.Previous Assert.Equal("Cake Integration Tests\n", System.IO.File.ReadAllText(targetArtifactPath.FullPath)); }); +Task("Cake.Common.Build.GitHubActionsProvider.Commands.NuGetLogin") + .WithCriteria((context, data) => !string.IsNullOrWhiteSpace(data.NuGetUserName)) + .Does(async data => { + // When (trusted publishing: OIDC from ACTIONS_ID_TOKEN_REQUEST_* → NuGet API key) + var apiKey = await GitHubActions.Commands.NuGetLogin(data.NuGetUserName); + + // Then + Assert.True(!string.IsNullOrWhiteSpace(apiKey)); + + if (!string.IsNullOrWhiteSpace(data.NuGetPackageId)) + { + // Validate the API key against nuget.org using the verify-scope protocol: + // https://learn.microsoft.com/en-us/nuget/api/nuget-protocols#api-to-request-a-verify-scope-key + var expires = await NuGetVerifyScopeValidator.ValidateApiKeyAsync(apiKey, data.NuGetPackageId, data.NuGetPackageVersion); + Information("NuGet verify-scope key expires: {0}", expires); + } +}); + Task("Cake.Common.Build.GitHubActionsProvider.Environment.Runner.Architecture") .Does(() => { // Given / When @@ -226,17 +248,21 @@ if (GitHubActions.IsRunningOnGitHubActions) if (GitHubActions.Environment.Runtime.IsRuntimeAvailable) { - Setup(context => new GitHubActionsData { + Setup(context => new GitHubActionsData { AssemblyPath = typeof(ICakeContext).GetTypeInfo().Assembly.Location, FileArtifactName = $"File_{GitHubActions.Environment.Runner.ImageOS ?? GitHubActions.Environment.Runner.OS}_{GitHubActions.Environment.Runner.Architecture}_{Context.Environment.Runtime.BuiltFramework.Identifier}_{Context.Environment.Runtime.BuiltFramework.Version}", - DirectoryArtifactName = $"Directory_{GitHubActions.Environment.Runner.ImageOS ?? GitHubActions.Environment.Runner.OS}_{GitHubActions.Environment.Runner.Architecture}_{Context.Environment.Runtime.BuiltFramework.Identifier}_{Context.Environment.Runtime.BuiltFramework.Version}" + DirectoryArtifactName = $"Directory_{GitHubActions.Environment.Runner.ImageOS ?? GitHubActions.Environment.Runner.OS}_{GitHubActions.Environment.Runner.Architecture}_{Context.Environment.Runtime.BuiltFramework.Identifier}_{Context.Environment.Runtime.BuiltFramework.Version}", + NuGetUserName = EnvironmentVariable("CAKE_INTEGRATIONTEST_NUGET_USERNAME") ?? string.Empty, + NuGetPackageId = EnvironmentVariable("CAKE_INTEGRATIONTEST_NUGET_PACKAGE_ID") ?? string.Empty, + NuGetPackageVersion = EnvironmentVariable("CAKE_INTEGRATIONTEST_NUGET_PACKAGE_VERSION") ?? string.Empty }); gitHubActionsProviderTask .IsDependentOn("Cake.Common.Build.GitHubActionsProvider.Commands.UploadArtifact.File") .IsDependentOn("Cake.Common.Build.GitHubActionsProvider.Commands.UploadArtifact.Directory") .IsDependentOn("Cake.Common.Build.GitHubActionsProvider.Commands.DownloadArtifact") - .IsDependentOn("Cake.Common.Build.GitHubActionsProvider.Commands.DownloadArtifact.PreviousJob"); + .IsDependentOn("Cake.Common.Build.GitHubActionsProvider.Commands.DownloadArtifact.PreviousJob") + .IsDependentOn("Cake.Common.Build.GitHubActionsProvider.Commands.NuGetLogin"); } public class GitHubActionsData @@ -244,4 +270,73 @@ public class GitHubActionsData public FilePath AssemblyPath { get; set; } public string FileArtifactName { get; set; } public string DirectoryArtifactName { get; set; } -} \ No newline at end of file + public string NuGetUserName { get; set; } + public string NuGetPackageId { get; set; } + public string NuGetPackageVersion { get; set; } +} + +/// +/// Validates a NuGet.org API key using the verify-scope protocol (nuget.org protocol 4.1.0). +/// +public static class NuGetVerifyScopeValidator +{ + private const string NuGetOrgBaseUrl = "https://www.nuget.org"; + + /// + /// Requests a verify-scope key with the API key, then verifies it, proving the API key is accepted by nuget.org. + /// + /// The NuGet API key to validate. + /// A package ID owned by the NuGet account. + /// An optional package version. + /// The verify-scope key expiration from the create-verification-key response. + public static async System.Threading.Tasks.Task ValidateApiKeyAsync(string apiKey, string packageId, string packageVersion = null) + { + using var client = new HttpClient(); + + var createVerificationKeyUrl = BuildVerifyScopeUrl( + "/api/v2/package/create-verification-key", + packageId, + packageVersion); + + using var createRequest = new HttpRequestMessage(HttpMethod.Post, createVerificationKeyUrl); + createRequest.Headers.Add("X-NuGet-ApiKey", apiKey); + + using var createResponse = await client.SendAsync(createRequest); + var createBody = await createResponse.Content.ReadAsStringAsync(); + + Assert.True( + createResponse.IsSuccessStatusCode, + $"create-verification-key failed ({(int)createResponse.StatusCode} {createResponse.StatusCode}): {createBody}"); + + using var createJson = JsonDocument.Parse(createBody); + var verifyScopeKey = createJson.RootElement.GetProperty("Key").GetString(); + var expires = createJson.RootElement.GetProperty("Expires").GetString(); + + Assert.False(string.IsNullOrWhiteSpace(verifyScopeKey), "create-verification-key response did not contain \"Key\"."); + Assert.False(string.IsNullOrWhiteSpace(expires), "create-verification-key response did not contain \"Expires\"."); + + var verifyKeyUrl = BuildVerifyScopeUrl("/api/v2/verifykey", packageId, packageVersion); + + using var verifyRequest = new HttpRequestMessage(HttpMethod.Get, verifyKeyUrl); + verifyRequest.Headers.Add("X-NuGet-ApiKey", verifyScopeKey); + + using var verifyResponse = await client.SendAsync(verifyRequest); + + Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode); + + return expires; + } + + private static string BuildVerifyScopeUrl(string pathPrefix, string packageId, string packageVersion) + { + var encodedId = Uri.EscapeDataString(packageId); + + if (string.IsNullOrWhiteSpace(packageVersion)) + { + return $"{NuGetOrgBaseUrl}{pathPrefix}/{encodedId}"; + } + + return $"{NuGetOrgBaseUrl}{pathPrefix}/{encodedId}/{Uri.EscapeDataString(packageVersion)}"; + } +} +