Skip to content

Commit a755e4b

Browse files
committed
Introduce standalone SponsorManifest for read/validate
This allows zero-dependencies reading and validating of an offline local jwt manifest.
1 parent 7cfb2ca commit a755e4b

6 files changed

Lines changed: 194 additions & 134 deletions

File tree

samples/dotnet/SponsorLink/ManifestStatus.cs

Lines changed: 0 additions & 25 deletions
This file was deleted.

samples/dotnet/SponsorLink/SponsorLink.cs

Lines changed: 0 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -165,82 +165,4 @@ public static bool TryRead([NotNullWhen(true)] out ClaimsPrincipal? principal, I
165165

166166
return principal != null;
167167
}
168-
169-
/// <summary>
170-
/// Validates the manifest signature and optional expiration.
171-
/// </summary>
172-
/// <param name="jwt">The JWT to validate.</param>
173-
/// <param name="jwk">The key to validate the manifest signature with.</param>
174-
/// <param name="token">Except when returning <see cref="Status.Unknown"/>, returns the security token read from the JWT, even if signature check failed.</param>
175-
/// <param name="identity">The associated claims, only when return value is not <see cref="Status.Unknown"/>.</param>
176-
/// <param name="requireExpiration">Whether to check for expiration.</param>
177-
/// <returns>The status of the validation.</returns>
178-
public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? token, out ClaimsIdentity? identity, bool validateExpiration)
179-
{
180-
token = default;
181-
identity = default;
182-
183-
SecurityKey key;
184-
try
185-
{
186-
key = JsonWebKey.Create(jwk);
187-
}
188-
catch (ArgumentException)
189-
{
190-
return ManifestStatus.Unknown;
191-
}
192-
193-
var handler = new JsonWebTokenHandler { MapInboundClaims = false };
194-
195-
if (!handler.CanReadToken(jwt))
196-
return ManifestStatus.Unknown;
197-
198-
var validation = new TokenValidationParameters
199-
{
200-
RequireExpirationTime = false,
201-
ValidateLifetime = false,
202-
ValidateAudience = false,
203-
ValidateIssuer = false,
204-
ValidateIssuerSigningKey = true,
205-
IssuerSigningKey = key,
206-
RoleClaimType = "roles",
207-
NameClaimType = "sub",
208-
};
209-
210-
var result = handler.ValidateTokenAsync(jwt, validation).Result;
211-
if (result.Exception != null)
212-
{
213-
if (result.Exception is SecurityTokenInvalidSignatureException)
214-
{
215-
var jwtToken = handler.ReadJsonWebToken(jwt);
216-
token = jwtToken;
217-
identity = new ClaimsIdentity(jwtToken.Claims);
218-
return ManifestStatus.Invalid;
219-
}
220-
else
221-
{
222-
var jwtToken = handler.ReadJsonWebToken(jwt);
223-
token = jwtToken;
224-
identity = new ClaimsIdentity(jwtToken.Claims);
225-
return ManifestStatus.Invalid;
226-
}
227-
}
228-
229-
token = result.SecurityToken;
230-
identity = new ClaimsIdentity(result.ClaimsIdentity.Claims, "JWT");
231-
232-
if (validateExpiration && token.ValidTo == DateTime.MinValue)
233-
return ManifestStatus.Invalid;
234-
235-
// The sponsorable manifest does not have an expiration time.
236-
if (validateExpiration && token.ValidTo < DateTimeOffset.UtcNow)
237-
return ManifestStatus.Expired;
238-
239-
return ManifestStatus.Valid;
240-
}
241-
242-
class JwtRolesPrincipal(ClaimsIdentity identity) : ClaimsPrincipal([identity])
243-
{
244-
public override bool IsInRole(string role) => HasClaim("roles", role) || base.IsInRole(role);
245-
}
246168
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// <autogenerated />
2+
#nullable enable
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.IO;
7+
using System.Security.Claims;
8+
using Microsoft.IdentityModel.JsonWebTokens;
9+
using Microsoft.IdentityModel.Tokens;
10+
11+
namespace Devlooped.Sponsors;
12+
13+
/// <summary>
14+
/// The resulting status from validation.
15+
/// </summary>
16+
public enum ManifestStatus
17+
{
18+
/// <summary>
19+
/// The manifest couldn't be read at all.
20+
/// </summary>
21+
Unknown,
22+
/// <summary>
23+
/// The manifest was read and is valid (not expired and properly signed).
24+
/// </summary>
25+
Valid,
26+
/// <summary>
27+
/// The manifest was read but has expired.
28+
/// </summary>
29+
Expired,
30+
/// <summary>
31+
/// The manifest was read, but its signature is invalid.
32+
/// </summary>
33+
Invalid,
34+
}
35+
36+
/// <summary>
37+
/// Represents the sponsorship status of a user.
38+
/// </summary>
39+
/// <param name="Status">The status.</param>
40+
/// <param name="Principal">The principal potentially containing roles validated from the manifest.</param>
41+
/// <param name="SecurityToken">The security token from the validated manifest.</param>
42+
public record SponsorManifest(ManifestStatus Status, ClaimsPrincipal Principal, SecurityToken? SecurityToken)
43+
{
44+
/// <summary>
45+
/// Whether the manifest <see cref="Status"/> is <see cref="ManifestStatus.Valid"/>.
46+
/// </summary>
47+
public bool IsValid => Status == ManifestStatus.Valid;
48+
}
49+
50+
static partial class SponsorLink
51+
{
52+
/// <summary>
53+
/// Reads the local manifest (if present) for the specified sponsorable account and validates it
54+
/// against the given JWK key.
55+
/// </summary>
56+
/// <param name="sponsorable">The sponsorable account to read.</param>
57+
/// <param name="jwk">The public key to validate the signature on the manifest JWT if found.</param>
58+
/// <param name="validateExpiration">Whether to validate the manifest expiration. If <see langword="false"/>,
59+
/// an expired manifest will be reported as <see cref="ManifestStatus.Valid"/>. The expiration date
60+
/// can be checked in that case via the <see cref="SponsorManifest.SecurityToken"/>.</param>
61+
/// <returns>A manifest that represents the user status.</returns>
62+
public static SponsorManifest GetManifest(string sponsorable, string jwk, bool validateExpiration = true)
63+
{
64+
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
65+
".sponsorlink", "github", sponsorable + ".jwt");
66+
67+
if (!File.Exists(path))
68+
return new SponsorManifest(ManifestStatus.Unknown, new ClaimsPrincipal(), null);
69+
70+
return ParseManifest(File.ReadAllText(path), jwk, validateExpiration);
71+
}
72+
73+
internal static SponsorManifest ParseManifest(string jwt, string jwk, bool validateExpiration)
74+
{
75+
var status = Validate(jwt, jwk, out var token, out var identity, validateExpiration);
76+
77+
if (status == ManifestStatus.Unknown || identity == null)
78+
return new SponsorManifest(status, new ClaimsPrincipal(), token);
79+
80+
return new SponsorManifest(status, new JwtRolesPrincipal(identity), token);
81+
}
82+
83+
/// <summary>
84+
/// Validates the manifest signature and optional expiration.
85+
/// </summary>
86+
/// <param name="jwt">The JWT to validate.</param>
87+
/// <param name="jwk">The key to validate the manifest signature with.</param>
88+
/// <param name="token">Except when returning <see cref="Status.Unknown"/>, returns the security token read from the JWT, even if signature check failed.</param>
89+
/// <param name="identity">The associated claims, only when return value is not <see cref="Status.Unknown"/>.</param>
90+
/// <param name="requireExpiration">Whether to check for expiration.</param>
91+
/// <returns>The status of the validation.</returns>
92+
public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? token, out ClaimsIdentity? identity, bool validateExpiration)
93+
{
94+
token = default;
95+
identity = default;
96+
97+
SecurityKey key;
98+
try
99+
{
100+
key = JsonWebKey.Create(jwk);
101+
}
102+
catch (ArgumentException)
103+
{
104+
return ManifestStatus.Unknown;
105+
}
106+
107+
var handler = new JsonWebTokenHandler { MapInboundClaims = false };
108+
109+
if (!handler.CanReadToken(jwt))
110+
return ManifestStatus.Unknown;
111+
112+
var validation = new TokenValidationParameters
113+
{
114+
RequireExpirationTime = false,
115+
ValidateLifetime = false,
116+
ValidateAudience = false,
117+
ValidateIssuer = false,
118+
ValidateIssuerSigningKey = true,
119+
IssuerSigningKey = key,
120+
RoleClaimType = "roles",
121+
NameClaimType = "sub",
122+
};
123+
124+
var result = handler.ValidateTokenAsync(jwt, validation).Result;
125+
if (!result.IsValid || result.Exception != null)
126+
{
127+
if (result.Exception is SecurityTokenInvalidSignatureException)
128+
{
129+
var jwtToken = handler.ReadJsonWebToken(jwt);
130+
token = jwtToken;
131+
identity = new ClaimsIdentity(jwtToken.Claims);
132+
return ManifestStatus.Invalid;
133+
}
134+
else
135+
{
136+
var jwtToken = handler.ReadJsonWebToken(jwt);
137+
token = jwtToken;
138+
identity = new ClaimsIdentity(jwtToken.Claims);
139+
return ManifestStatus.Invalid;
140+
}
141+
}
142+
143+
token = result.SecurityToken;
144+
identity = new ClaimsIdentity(result.ClaimsIdentity.Claims, "JWT");
145+
146+
if (validateExpiration && token.ValidTo == DateTime.MinValue)
147+
return ManifestStatus.Invalid;
148+
149+
// The sponsorable manifest does not have an expiration time.
150+
if (validateExpiration && token.ValidTo < DateTimeOffset.UtcNow)
151+
return ManifestStatus.Expired;
152+
153+
return ManifestStatus.Valid;
154+
}
155+
156+
class JwtRolesPrincipal(ClaimsIdentity identity) : ClaimsPrincipal([identity])
157+
{
158+
public override bool IsInRole(string role) => HasClaim("roles", role) || base.IsInRole(role);
159+
}
160+
}

samples/dotnet/Tests/SponsorLinkTests.cs renamed to samples/dotnet/Tests/SponsorManifestTests.cs

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
namespace Devlooped.Tests;
1010

11-
public class SponsorLinkTests
11+
public class SponsorManifestTests
1212
{
1313
// We need to convert to jwk string since the analyzer project has merged the JWT assembly and types.
1414
public static string ToJwk(SecurityKey key)
@@ -19,64 +19,67 @@ public static string ToJwk(SecurityKey key)
1919
[Fact]
2020
public void ValidateSponsorable()
2121
{
22-
var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234");
23-
var jwt = manifest.ToJwt();
24-
var jwk = ToJwk(manifest.SecurityKey);
22+
var sponsorable = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234");
23+
var jwt = sponsorable.ToJwt();
24+
var jwk = ToJwk(sponsorable.SecurityKey);
2525

2626
// NOTE: sponsorable manifest doesn't have expiration date.
27-
var status = SponsorLink.Validate(jwt, jwk, out var token, out var principal, false);
27+
var manifest = SponsorLink.ParseManifest(jwt, jwk, false);
2828

29-
Assert.Equal(ManifestStatus.Valid, status);
29+
Assert.True(manifest.IsValid);
30+
Assert.Equal(ManifestStatus.Valid, manifest.Status);
3031
}
3132

3233
[Fact]
3334
public void ValidateWrongKey()
3435
{
35-
var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234");
36-
var jwt = manifest.ToJwt();
36+
var sponsorable = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234");
37+
var jwt = sponsorable.ToJwt();
3738
var jwk = ToJwk(new RsaSecurityKey(RSA.Create()));
3839

39-
var status = SponsorLink.Validate(jwt, jwk, out var token, out var principal, false);
40+
var manifest = SponsorLink.ParseManifest(jwt, jwk, false);
4041

41-
Assert.Equal(ManifestStatus.Invalid, status);
42+
Assert.Equal(ManifestStatus.Invalid, manifest.Status);
4243

4344
// We should still be a able to read the data, knowing it may have been tampered with.
44-
Assert.NotNull(principal);
45-
Assert.NotNull(token);
45+
Assert.NotNull(manifest.Principal);
46+
Assert.NotNull(manifest.SecurityToken);
4647
}
4748

4849
[Fact]
4950
public void ValidateExpiredSponsor()
5051
{
51-
var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234");
52-
var jwk = ToJwk(manifest.SecurityKey);
53-
var sponsor = manifest.Sign([], expiration: TimeSpan.Zero);
52+
var sponsorable = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234");
53+
var jwk = ToJwk(sponsorable.SecurityKey);
54+
var sponsor = sponsorable.Sign([], expiration: TimeSpan.Zero);
5455

5556
// Will be expired after this.
5657
Thread.Sleep(1000);
5758

58-
var status = SponsorLink.Validate(sponsor, jwk, out var token, out var principal, true);
59+
var manifest = SponsorLink.ParseManifest(sponsor, jwk, true);
5960

60-
Assert.Equal(ManifestStatus.Expired, status);
61+
Assert.Equal(ManifestStatus.Expired, manifest.Status);
6162

6263
// We should still be a able to read the data, even if expired (but not tampered with).
63-
Assert.NotNull(principal);
64-
Assert.NotNull(token);
64+
Assert.NotNull(manifest.Principal);
65+
Assert.NotNull(manifest.SecurityToken);
6566
}
6667

6768
[Fact]
6869
public void ValidateUnknownFormat()
6970
{
70-
var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234");
71-
var jwk = ToJwk(manifest.SecurityKey);
71+
var sponsorable = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234");
72+
var jwk = ToJwk(sponsorable.SecurityKey);
7273

73-
var status = SponsorLink.Validate("asdfasdf", jwk, out var token, out var principal, false);
74+
var manifest = SponsorLink.ParseManifest("asdfasdf", jwk, false);
7475

75-
Assert.Equal(ManifestStatus.Unknown, status);
76+
Assert.Equal(ManifestStatus.Unknown, manifest.Status);
7677

7778
// Nothing could be read at all.
78-
Assert.Null(principal);
79-
Assert.Null(token);
79+
Assert.False(manifest.IsValid);
80+
Assert.NotNull(manifest.Principal);
81+
Assert.Null(manifest.Principal.Identity);
82+
Assert.Null(manifest.SecurityToken);
8083
}
8184

8285
[Fact]
@@ -111,16 +114,16 @@ public void ValidateCachedManifest()
111114

112115
var jwt = File.ReadAllText(path);
113116

114-
var status = SponsorLink.Validate(jwt,
117+
var manifest = SponsorLink.ParseManifest(jwt,
115118
"""
116119
{
117120
"e": "AQAB",
118121
"kty": "RSA",
119122
"n": "5inhv8QymaDBOihNi1eY-6-hcIB5qSONFZxbxxXAyOtxAdjFCPM-94gIZqM9CDrX3pyg1lTJfml_a_FZSU9dB1ii5mSX_mNHBFXn1_l_gi1ErdbkIF5YbW6oxWFxf3G5mwVXwnPfxHTyQdmWQ3YJR-A3EB4kaFwLqA6Ha5lb2ObGpMTQJNakD4oTAGDhqHMGhu6PupGq5ie4qZcQ7N8ANw8xH7nicTkbqEhQABHWOTmLBWq5f5F6RYGF8P7cl0IWl_w4YcIZkGm2vX2fi26F9F60cU1v13GZEVDTXpJ9kzvYeM9sYk6fWaoyY2jhE51qbv0B0u6hScZiLREtm3n7ClJbIGXhkUppFS2JlNaX3rgQ6t-4LK8gUTyLt3zDs2H8OZyCwlCpfmGmdsUMkm1xX6t2r-95U3zywynxoWZfjBCJf41leM9OMKYwNWZ6LQMyo83HWw1PBIrX4ZLClFwqBcSYsXDyT8_ZLd1cdYmPfmtllIXxZhLClwT5qbCWv73V"
120123
}
121124
"""
122-
, out var token, out var principal, false);
125+
, false);
123126

124-
Assert.Equal(ManifestStatus.Valid, status);
127+
Assert.Equal(ManifestStatus.Valid, manifest.Status);
125128
}
126129
}

src/Core/Records.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public record OpenSource(ConcurrentDictionary<string, HashSet<string>> Authors,
4343
OpenSourceTotals? totals;
4444

4545
public OpenSource() : this(new(FnvHashComparer.Default), new(FnvHashComparer.Default), new(FnvHashComparer.Default))
46-
{
46+
{
4747
}
4848

4949
public OpenSourceSummary Summary => summary ??= new(Totals);

0 commit comments

Comments
 (0)