Skip to content

Commit

Permalink
Change manifest to a signed JWT
Browse files Browse the repository at this point in the history
Also add default funding file to targets, initial test boilerplate for manifest endpoint.
  • Loading branch information
kzu committed Apr 5, 2024
1 parent d917fff commit 87ffe21
Show file tree
Hide file tree
Showing 22 changed files with 545 additions and 155 deletions.
10 changes: 10 additions & 0 deletions .netconfig
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,13 @@
sha = 2e84192eea07ecc84d5c15fca6f48f3a7e29bb59
etag = 966d76b2bfff876a7805d794d43e7bfdee18fe8ae64b73979da23bb27bac21b7
weak
[file "src/Tests/System/Threading/Tasks/AsyncLazy.cs"]
url = https://github.com/devlooped/catbag/blob/main/System/Threading/Tasks/AsyncLazy.cs
sha = 9f3330f09713aa5f746047e3a50ee839147a5797
etag = 73320600b7a18e0eb25cadc3d687c69dc79181b0458facf526666e150c634782
weak
[file "src/Tests/Microsoft/Extensions/DependencyInjection/AddAsyncLazyExtension.cs"]
url = https://github.com/devlooped/catbag/blob/main/Microsoft/Extensions/DependencyInjection/AddAsyncLazyExtension.cs
sha = 2f8a7d3dffc4409dbda61afb43326ab9d871c1ec
etag = c8f63b95f4631df1e1bdc1e3fa592260f40c86e36032a979b572b880cf5a4fff
weak
1 change: 1 addition & 0 deletions src/Commands/Commands.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<PackageReference Include="Spectre.Console.Json" Version="0.47.0" />
<PackageReference Include="ThisAssembly.Strings" Version="1.4.1" PrivateAssets="all" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.2.1" PrivateAssets="all" Condition="$(TargetFramework) == 'netstandard2.0'" />
<PackageReference Include="Devlooped.CredentialManager" Version="2.4.1" />
</ItemGroup>

<ItemGroup>
Expand Down
10 changes: 5 additions & 5 deletions src/Commands/GitHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ public static class GitHub
{
public static bool IsInstalled { get; } = TryIsInstalled(out var _);

public static bool TryIsInstalled(out string output)
=> Process.TryExecute("gh", "--version", out output) && output.StartsWith("gh version");
public static bool TryIsInstalled(out string? output)
=> Process.TryExecute("gh", "--version", out output) && output?.StartsWith("gh version") == true;

public static bool TryApi(string endpoint, string jq, out string? json)
{
Expand Down Expand Up @@ -43,13 +43,13 @@ public static bool TryQuery(string query, string? jq, out string? result, params

public static Account? Authenticate()
{
if (!Process.TryExecute("gh", "auth status -h github.com", out var output))
if (!Process.TryExecute("gh", "auth status -h github.com", out var output) || output is null)
return default;

if (output.Contains("gh auth login"))
return default;

if (!Process.TryExecute("gh", "api user", out output))
if (!Process.TryExecute("gh", "api user", out output) || output is null)
return default;

if (JsonSerializer.Deserialize<Account>(output, JsonOptions.Default) is not { } account)
Expand All @@ -61,7 +61,7 @@ public static bool TryQuery(string query, string? jq, out string? result, params

return account with
{
Emails = JsonSerializer.Deserialize<string[]>(output, JsonOptions.Default) ?? Array.Empty<string>()
Emails = JsonSerializer.Deserialize<string[]>(output, JsonOptions.Default) ?? []
};
}
}
171 changes: 81 additions & 90 deletions src/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Logging;
using Microsoft.IdentityModel.Tokens;
using Spectre.Console;
using Spectre.Console.Cli;
using static Devlooped.SponsorLink;

namespace Devlooped.Sponsors;

Expand All @@ -24,19 +21,21 @@ public partial class InitCommand(Account user) : AsyncCommand<InitCommand.Settin
{
public class Settings : CommandSettings
{
[Description("The OpenID issuer URL, used to fetch OpenID configuration automatically.")]
[Description("The base URL of the manifest issuer web app.")]
[CommandArgument(0, "<issuer>")]
public required string Issuer { get; init; }

[Description("The base URL of the deployed SponsorLink API that can initialize and sign manifests.")]
[CommandArgument(0, "<audience>")]
public required string Audience { get; init; }
[Description("The Client ID of the GitHub OAuth application created by the sponsorable account.")]
[CommandArgument(1, "<clientId>")]
public required string ClientId { get; init; }

[Description("Sponsorable account, if different from the authenticated user.")]
[CommandArgument(2, "[audience]")]
public required string? Audience { get; init; }
}

public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
var status = AnsiConsole.Status();

// Authenticated user must match GH user
var principal = await Session.AuthenticateAsync();
if (principal == null)
Expand All @@ -54,102 +53,94 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se
return -1;
}

var audience = settings.Audience ?? user.Login;

// Generate key pair
var rsa = RSA.Create(2048);
var pub = Convert.ToBase64String(rsa.ExportRSAPublicKey());

var keyFile = new FileInfo("signing.key");
var pubFile = new FileInfo("signing.pub");

await File.WriteAllBytesAsync(keyFile.FullName, rsa.ExportRSAPrivateKey());
await File.WriteAllTextAsync(keyFile.FullName + ".txt", Convert.ToBase64String(rsa.ExportRSAPrivateKey()));
await File.WriteAllBytesAsync(pubFile.FullName, rsa.ExportRSAPublicKey());
await File.WriteAllTextAsync(pubFile.FullName + ".txt", Convert.ToBase64String(rsa.ExportRSAPublicKey()));
var options = new JsonSerializerOptions(JsonSerializerOptions.Default)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull,
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers =
{
info =>
{
if (info.Type != typeof(JsonWebKey))
return;

foreach (var prop in info.Properties)
{
// Don't serialize empty lists, makes for more concise JWKs
prop.ShouldSerialize = (obj, value) =>
value is not null &&
(value is not IList<string> list || list.Count > 0);
}
}
}
}
};

AnsiConsole.MarkupLine($":check_mark_button: Generated new signing key");
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: {keyFile.FullName}");
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: {keyFile.FullName}.txt (base64-encoded)");
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: {pubFile.FullName}");
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: {pubFile.FullName}.txt (base64-encoded)");

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Variables.AccessToken);
var baseName = Path.Combine(Directory.GetCurrentDirectory(), audience);

var baseUri = new Uri(settings.Audience.EndsWith('/') ? settings.Audience : settings.Audience + "/");
await File.WriteAllBytesAsync($"{audience}.key", rsa.ExportRSAPrivateKey());
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: [link]{baseName}.key[/] [grey](private key)[/]");

var payload = new
{
iss = settings.Issuer.EndsWith('/') ? settings.Issuer : settings.Issuer + "/",
aud = baseUri.AbsoluteUri,
pub = Convert.ToBase64String(rsa.ExportRSAPublicKey())
};
await File.WriteAllTextAsync($"{audience}.key.txt",
Convert.ToBase64String(rsa.ExportRSAPublicKey()),
Encoding.UTF8);
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: [link]{baseName}.key.txt[/] [grey](base64-encoded)[/]");

// NOTE: to test the local flow end to end, run the SponsorLink functions App project locally. You will
var url = Debugger.IsAttached ? "http://localhost:7288/init" : $"{baseUri.AbsoluteUri}init";
await File.WriteAllTextAsync($"{audience}.key.jwk",
JsonSerializer.Serialize(
JsonWebKeyConverter.ConvertFromRSASecurityKey(new RsaSecurityKey(rsa.ExportParameters(true))),
options),
Encoding.UTF8);
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: [link]{baseName}.key.jwk[/] [grey](JWK string)[/]");

var response = await status.StartAsync(ThisAssembly.Strings.Sync.Signing, async _
=> await http.PostAsJsonAsync(url, payload));
await File.WriteAllBytesAsync($"{audience}.pub", rsa.ExportRSAPublicKey());
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: [link]{baseName}.pub[/] [grey](public key)[/]");

if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
AnsiConsole.MarkupLine(":cross_mark: Could not create new manifest: unauthorized.");
return -1;
}
else if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
if (content is { Length: > 0 })
content = $" ({content})";
await File.WriteAllTextAsync($"{audience}.pub.txt", pub, Encoding.UTF8);
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: [link]{baseName}.pub.txt[/] [grey](base64-encoded)[/]");

AnsiConsole.MarkupLine($":cross_mark: Could not sign new manifest: {response.StatusCode}{content}");
return -1;
}
await File.WriteAllTextAsync($"{audience}.pub.jwk",
JsonSerializer.Serialize(
JsonWebKeyConverter.ConvertFromRSASecurityKey(new RsaSecurityKey(rsa.ExportParameters(false))),
options),
Encoding.UTF8);
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: [link]{baseName}.pub.jwk[/] [grey](JWK string)[/]");

// Attempt to validate the JWT we just got against the known public key from SL
var token = await response.Content.ReadAsStringAsync();
var validation = new TokenValidationParameters
var issuer = settings.Issuer.EndsWith('/') ? settings.Issuer : settings.Issuer + "/";
var claims = new List<Claim>
{
IgnoreTrailingSlashWhenValidatingAudience = true,
ValidAudience = payload.aud,
ValidateAudience = true,
ValidIssuer = "https://sponsorlink.us.auth0.com/",
ValidateIssuer = true,
ValidateLifetime = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(SponsorLink.PublicKey)
new("client_id", settings.ClientId),
new("pub", pub),
};

try
{
#if DEBUG
IdentityModelEventSource.ShowPII = true;
#endif

new JwtSecurityTokenHandler().ValidateToken(token, validation, out var validated);
var securityKey = new RsaSecurityKey(rsa.ExportParameters(true));
var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);

var tokenFile = new FileInfo("sponsorlink.jwt");
await File.WriteAllTextAsync(tokenFile.FullName, token);
AnsiConsole.MarkupLine($":check_mark_button: Persisted new sponsorable token :backhand_index_pointing_right: {tokenFile.FullName}");
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
signingCredentials: signingCredentials);

var jsonFile = new FileInfo("sponsorlink.json");
await File.WriteAllTextAsync(jsonFile.FullName,
JsonSerializer.Serialize(payload,
new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }));
// Serialize the token and return as a string
var jwt = new JwtSecurityTokenHandler().WriteToken(token);
var sponsorable = new FileInfo("sponsorable.jwt");
await File.WriteAllTextAsync(sponsorable.FullName, jwt, Encoding.UTF8);

AnsiConsole.MarkupLine($":check_mark_button: Persisted new sponsorable manifest :backhand_index_pointing_right: {jsonFile.FullName}");
AnsiConsole.MarkupLine($":information: Please upload both files to your [lime][[user/org]]/.github[/] repository.");
AnsiConsole.MarkupLine($":check_mark_button: Generated new sponsorable JWT");
AnsiConsole.MarkupLine($"\t:backhand_index_pointing_right: [link]{sponsorable.FullName}[/] [grey](upload to .github repo)[/]");
AnsiConsole.MarkupLine($"\t:magnifying_glass_tilted_right: [grey]{jwt}[/]");

return 0;
}
catch (SecurityTokenInvalidSignatureException)
{
AnsiConsole.MarkupLine(":cross_mark: The manifest signature is invalid.");
return -2;
}
catch (SecurityTokenException ex)
{
AnsiConsole.MarkupLine($":cross_mark: The manifest is invalid.");
AnsiConsole.WriteException(ex);
return -3;
}
return 0;
}
}
27 changes: 23 additions & 4 deletions src/Commands/Process.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,54 @@

namespace Devlooped.Sponsors;

class Process
public static class Process
{
public static bool TryExecute(string program, string arguments, out string output)
public static bool TryExecute(string program, string arguments, out string? output)
=> TryExecuteCore(program, arguments, null, out output);

public static bool TryExecute(string program, string arguments, string input, out string? output)
=> TryExecuteCore(program, arguments, input, out output);

static bool TryExecuteCore(string program, string arguments, string? input, out string? output)
{
var info = new ProcessStartInfo(program, arguments)
{
RedirectStandardOutput = true,
RedirectStandardError = true
RedirectStandardError = true,
RedirectStandardInput = input != null
};

try
{
var proc = System.Diagnostics.Process.Start(info);
if (proc == null)
{
output = "";
output = null;
return false;
}

var gotError = false;
proc.ErrorDataReceived += (_, __) => gotError = true;

if (input != null)
{
// Write the input to the standard input stream
proc.StandardInput.WriteLine(input);
proc.StandardInput.Close();
}

output = proc.StandardOutput.ReadToEnd();
if (!proc.WaitForExit(5000))
{
proc.Kill();
output = null;
return false;
}

output = output.Trim();
if (string.IsNullOrEmpty(output))
output = null;

return !gotError && proc.ExitCode == 0;
}
catch (Exception ex)
Expand Down
3 changes: 1 addition & 2 deletions src/Core/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;

Expand Down
3 changes: 1 addition & 2 deletions src/Core/GraphQueries.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text;
using Scriban;
using Scriban;
using GraphQuery = (string Query, string? JQ);

namespace Devlooped.Sponsors;
Expand Down
10 changes: 5 additions & 5 deletions src/Core/SponsorsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ public async Task<SponsorableManifest> GetManifestAsync()
using var http = httpFactory.CreateClient("sponsorable");
var response = await http.GetAsync(url);

logger.Assert(response.IsSuccessStatusCode,
"Failed to retrieve manifest from {Url}: {StatusCode} {Reason}",
logger.Assert(response.IsSuccessStatusCode,
"Failed to retrieve manifest from {Url}: {StatusCode} {Reason}",
url, (int)response.StatusCode, await response.Content.ReadAsStringAsync());

var yaml = await response.Content.ReadAsStringAsync();
manifest = serializer.Deserialize<SponsorableManifest>(yaml);

logger.Assert(manifest is not null,
logger.Assert(manifest is not null,
"Failed to deserialize YAML manifest from {Url}", url);

// Audience defaults to the manifest url user/org
Expand Down Expand Up @@ -84,7 +84,7 @@ public async Task<SponsorType> GetSponsorAsync()
var accounts = new HashSet<string>(sponsoring ?? []);

// User is checked for auth on first line above
if (principal.FindFirst("urn:github:login") is { Value.Length: > 0 } claim &&
if (principal.FindFirst("urn:github:login") is { Value.Length: > 0 } claim &&
accounts.Contains(claim.Value))
{
// the user is directly sponsoring
Expand All @@ -102,7 +102,7 @@ public async Task<SponsorType> GetSponsorAsync()
// private and verified, and then use them to access the be considered an org sponsor.

var contribs = await sponsor.QueryAsync<string[]>(GraphQueries.UserContributions);
if (contribs is not null &&
if (contribs is not null &&
contribs.Contains(manifest.Audience))
{
return SponsorType.Contributor;
Expand Down
Loading

0 comments on commit 87ffe21

Please sign in to comment.