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)}";
+ }
+}
+