Skip to content

Commit 2ccd7b4

Browse files
committed
implements nuget api key retrieval - Trusted Publishing
Implements retrieval of the NuGet API key using GitHub OIDC- Trusted Publishing
1 parent 4e4eed2 commit 2ccd7b4

File tree

2 files changed

+115
-21
lines changed

2 files changed

+115
-21
lines changed

.github/workflows/_publish.yml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,7 @@ jobs:
3333
name: nuget
3434
path: ${{ github.workspace }}/artifacts/packages/nuget
3535

36-
-
37-
name: NuGet login (OIDC → temp API key)
38-
uses: NuGet/login@v1
39-
id: login
40-
with:
41-
user: 'gittoolsbot'
4236
-
4337
name: '[Publish]'
4438
shell: pwsh
45-
env:
46-
NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
4739
run: dotnet run/publish.dll --target=Publish${{ matrix.taskName }}
Lines changed: 115 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Net.Http.Headers;
2+
using System.Text.Json;
13
using Cake.Common.Tools.DotNet.NuGet.Push;
24
using Common.Utilities;
35

@@ -10,18 +12,21 @@ public class PublishNuget : FrostingTask<BuildContext>;
1012

1113
[TaskName(nameof(PublishNugetInternal))]
1214
[TaskDescription("Publish nuget packages")]
13-
public class PublishNugetInternal : FrostingTask<BuildContext>
15+
public class PublishNugetInternal : AsyncFrostingTask<BuildContext>
1416
{
1517
public override bool ShouldRun(BuildContext context)
1618
{
1719
var shouldRun = true;
18-
shouldRun &= context.ShouldRun(context.IsGitHubActionsBuild, $"{nameof(PublishNuget)} works only on GitHub Actions.");
19-
shouldRun &= context.ShouldRun(context.IsStableRelease || context.IsTaggedPreRelease || context.IsInternalPreRelease, $"{nameof(PublishNuget)} works only for releases.");
20+
shouldRun &= context.ShouldRun(context.IsGitHubActionsBuild,
21+
$"{nameof(PublishNuget)} works only on GitHub Actions.");
22+
shouldRun &=
23+
context.ShouldRun(context.IsStableRelease || context.IsTaggedPreRelease || context.IsInternalPreRelease,
24+
$"{nameof(PublishNuget)} works only for releases.");
2025

2126
return shouldRun;
2227
}
2328

24-
public override void Run(BuildContext context)
29+
public override async Task RunAsync(BuildContext context)
2530
{
2631
// publish to github packages for commits on main and on original repo
2732
if (context.IsInternalPreRelease)
@@ -32,35 +37,132 @@ public override void Run(BuildContext context)
3237
{
3338
throw new InvalidOperationException("Could not resolve NuGet GitHub Packages API key.");
3439
}
40+
3541
PublishToNugetRepo(context, apiKey, Constants.GithubPackagesUrl);
3642
context.EndGroup();
3743
}
44+
45+
var nugetApiKey = await GetNugetApiKey(context);
3846
// publish to nuget.org for tagged releases
3947
if (context.IsStableRelease || context.IsTaggedPreRelease)
4048
{
4149
context.StartGroup("Publishing to Nuget.org");
42-
var apiKey = context.Credentials?.Nuget?.ApiKey;
43-
if (string.IsNullOrEmpty(apiKey))
50+
if (string.IsNullOrEmpty(nugetApiKey))
4451
{
4552
throw new InvalidOperationException("Could not resolve NuGet org API key.");
4653
}
47-
PublishToNugetRepo(context, apiKey, Constants.NugetOrgUrl);
54+
55+
PublishToNugetRepo(context, nugetApiKey, Constants.NugetOrgUrl);
4856
context.EndGroup();
4957
}
5058
}
59+
5160
private static void PublishToNugetRepo(BuildContext context, string apiKey, string apiUrl)
5261
{
5362
ArgumentNullException.ThrowIfNull(context.Version);
5463
var nugetVersion = context.Version.NugetVersion;
5564
foreach (var (packageName, filePath, _) in context.Packages.Where(x => !x.IsChocoPackage))
5665
{
5766
context.Information($"Package {packageName}, version {nugetVersion} is being published.");
58-
context.DotNetNuGetPush(filePath.FullPath, new DotNetNuGetPushSettings
59-
{
60-
ApiKey = apiKey,
61-
Source = apiUrl,
62-
SkipDuplicate = true
63-
});
67+
context.DotNetNuGetPush(filePath.FullPath,
68+
new DotNetNuGetPushSettings { ApiKey = apiKey, Source = apiUrl, SkipDuplicate = true });
69+
}
70+
}
71+
72+
private static async Task<string?> GetNugetApiKey(BuildContext context)
73+
{
74+
try
75+
{
76+
var oidcToken = await GetGitHubOidcToken(context);
77+
var apiKey = await ExchangeOidcTokenForApiKey(oidcToken);
78+
79+
context.Information($"Successfully exchanged OIDC token for NuGet API key.");
80+
return apiKey;
81+
}
82+
catch (Exception ex)
83+
{
84+
context.Error($"Failed to retrieve NuGet API key: {ex.Message}");
85+
return null;
86+
}
87+
}
88+
89+
private static async Task<string> GetGitHubOidcToken(BuildContext context)
90+
{
91+
const string nugetAudience = "https://www.nuget.org";
92+
93+
var oidcRequestToken = context.Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
94+
var oidcRequestUrl = context.Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL");
95+
96+
if (string.IsNullOrEmpty(oidcRequestToken) || string.IsNullOrEmpty(oidcRequestUrl))
97+
throw new InvalidOperationException("Missing GitHub OIDC request environment variables.");
98+
99+
var tokenUrl = $"{oidcRequestUrl}&audience={Uri.EscapeDataString(nugetAudience)}";
100+
context.Information($"Requesting GitHub OIDC token from: {tokenUrl}");
101+
102+
using var http = new HttpClient();
103+
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", oidcRequestToken);
104+
105+
var responseMessage = await http.GetAsync(tokenUrl);
106+
var tokenBody = await responseMessage.Content.ReadAsStringAsync();
107+
108+
if (!responseMessage.IsSuccessStatusCode)
109+
throw new Exception("Failed to retrieve OIDC token from GitHub.");
110+
111+
using var tokenDoc = JsonDocument.Parse(tokenBody);
112+
return ParseJsonProperty(tokenDoc, "value", "Failed to retrieve OIDC token from GitHub.");
113+
}
114+
115+
private static async Task<string> ExchangeOidcTokenForApiKey(string oidcToken)
116+
{
117+
const string nugetUsername = "gittoolsbot";
118+
const string nugetTokenServiceUrl = "https://www.nuget.org/api/v2/token";
119+
120+
var requestBody = JsonSerializer.Serialize(new { username = nugetUsername, tokenType = "ApiKey" });
121+
122+
using var tokenServiceHttp = new HttpClient();
123+
tokenServiceHttp.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", oidcToken);
124+
tokenServiceHttp.DefaultRequestHeaders.UserAgent.ParseAdd("nuget/login-action");
125+
var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
126+
127+
var responseMessage = await tokenServiceHttp.PostAsync(nugetTokenServiceUrl, content);
128+
var exchangeBody = await responseMessage.Content.ReadAsStringAsync();
129+
130+
if (!responseMessage.IsSuccessStatusCode)
131+
{
132+
var errorMessage = BuildErrorMessage((int)responseMessage.StatusCode, exchangeBody);
133+
throw new Exception(errorMessage);
64134
}
135+
136+
using var respDoc = JsonDocument.Parse(exchangeBody);
137+
return ParseJsonProperty(respDoc, "apiKey", "Response did not contain \"apiKey\".");
138+
}
139+
140+
private static string ParseJsonProperty(JsonDocument document, string propertyName, string errorMessage)
141+
{
142+
if (!document.RootElement.TryGetProperty(propertyName, out var property) ||
143+
property.ValueKind != JsonValueKind.String)
144+
throw new Exception(errorMessage);
145+
146+
return property.GetString() ?? throw new Exception(errorMessage);
147+
}
148+
149+
private static string BuildErrorMessage(int statusCode, string responseBody)
150+
{
151+
var errorMessage = $"Token exchange failed ({statusCode})";
152+
try
153+
{
154+
using var errDoc = JsonDocument.Parse(responseBody);
155+
errorMessage +=
156+
errDoc.RootElement.TryGetProperty("error", out var errProp) &&
157+
errProp.ValueKind == JsonValueKind.String
158+
? $": {errProp.GetString()}"
159+
: $": {responseBody}";
160+
}
161+
catch
162+
{
163+
errorMessage += $": {responseBody}";
164+
}
165+
166+
return errorMessage;
65167
}
66168
}

0 commit comments

Comments
 (0)