diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index 8dca588..7cb52c8 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -22,12 +22,15 @@ public class FediverseAuthController( [HttpGet] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task GetFediverseUrlAsync([FromQuery] string instance) + public async Task GetFediverseUrlAsync( + [FromQuery] string instance, + [FromQuery] bool forceRefresh = false + ) { if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.')) throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); - var url = await fediverseAuthService.GenerateAuthUrlAsync(instance); + var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); return Ok(new FediverseUrlResponse(url)); } @@ -36,7 +39,11 @@ public class FediverseAuthController( public async Task FediverseCallbackAsync([FromBody] CallbackRequest req) { var app = await fediverseAuthService.GetApplicationAsync(req.Instance); - var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); + var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync( + app, + req.Code, + req.State + ); var user = await authService.AuthenticateUserAsync( AuthType.Fediverse, @@ -72,12 +79,16 @@ public class FediverseAuthController( ) { var ticketData = await keyCacheService.GetKeyAsync( - $"fediverse:{req.Ticket}" + $"fediverse:{req.Ticket}", + delete: true ); if (ticketData == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); var app = await db.FediverseApplications.FindAsync(ticketData.ApplicationId); + if (app == null) + throw new FoxnounsError("Null application found for ticket"); + if ( await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Fediverse @@ -107,7 +118,7 @@ public class FediverseAuthController( return Ok(await authService.GenerateUserTokenAsync(user)); } - public record CallbackRequest(string Instance, string Code); + public record CallbackRequest(string Instance, string Code, string State); private record FediverseUrlResponse(string Url); diff --git a/Foxnouns.Backend/Controllers/SidController.cs b/Foxnouns.Backend/Controllers/SidController.cs new file mode 100644 index 0000000..26c66ef --- /dev/null +++ b/Foxnouns.Backend/Controllers/SidController.cs @@ -0,0 +1,3 @@ +namespace Foxnouns.Backend.Controllers; + +public class SidController { } diff --git a/Foxnouns.Backend/Database/Migrations/20241123210306_RemoveFediverseApplicationTokens.cs b/Foxnouns.Backend/Database/Migrations/20241123210306_RemoveFediverseApplicationTokens.cs new file mode 100644 index 0000000..fbc8d3d --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20241123210306_RemoveFediverseApplicationTokens.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241123210306_RemoveFediverseApplicationTokens")] + public partial class RemoveFediverseApplicationTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications"); + + migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "access_token", + table: "fediverse_applications", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "token_valid_until", + table: "fediverse_applications", + type: "timestamp with time zone", + nullable: true + ); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 97316ac..e1e05c2 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -107,10 +107,6 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("bigint") .HasColumnName("id"); - b.Property("AccessToken") - .HasColumnType("text") - .HasColumnName("access_token"); - b.Property("ClientId") .IsRequired() .HasColumnType("text") @@ -130,10 +126,6 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("integer") .HasColumnName("instance_type"); - b.Property("TokenValidUntil") - .HasColumnType("timestamp with time zone") - .HasColumnName("token_valid_until"); - b.HasKey("Id") .HasName("pk_fediverse_applications"); diff --git a/Foxnouns.Backend/Database/Models/FediverseApplication.cs b/Foxnouns.Backend/Database/Models/FediverseApplication.cs index fa7b6a6..882a377 100644 --- a/Foxnouns.Backend/Database/Models/FediverseApplication.cs +++ b/Foxnouns.Backend/Database/Models/FediverseApplication.cs @@ -8,10 +8,6 @@ public class FediverseApplication : BaseModel public required string ClientId { get; set; } public required string ClientSecret { get; set; } public required FediverseInstanceType InstanceType { get; set; } - - // These are for ensuring the application is still valid. - public string? AccessToken { get; set; } - public Instant? TokenValidUntil { get; set; } } public enum FediverseInstanceType diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index a9e7b74..dbc46d3 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -20,6 +20,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + @@ -35,6 +36,7 @@ + diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs index 665e07f..97e411a 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs @@ -3,7 +3,7 @@ using System.Net; using System.Web; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; -using Duration = NodaTime.Duration; +using Foxnouns.Backend.Extensions; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; namespace Foxnouns.Backend.Services.Auth; @@ -35,12 +35,6 @@ public partial class FediverseAuthService $"Application created on Mastodon-compatible instance {instance} was null" ); - var token = await GetMastodonAppTokenAsync( - instance, - mastodonApp.ClientId, - mastodonApp.ClientSecret - ); - FediverseApplication app; if (existingAppId == null) @@ -52,8 +46,6 @@ public partial class FediverseAuthService ClientSecret = mastodonApp.ClientSecret, Domain = instance, InstanceType = FediverseInstanceType.MastodonApi, - AccessToken = token, - TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60), }; _db.Add(app); @@ -67,8 +59,6 @@ public partial class FediverseAuthService app.ClientId = mastodonApp.ClientId; app.ClientSecret = mastodonApp.ClientSecret; app.InstanceType = FediverseInstanceType.MastodonApi; - app.AccessToken = null; - app.TokenValidUntil = null; } await _db.SaveChangesAsync(); @@ -76,8 +66,14 @@ public partial class FediverseAuthService return app; } - private async Task GetMastodonUserAsync(FediverseApplication app, string code) + private async Task GetMastodonUserAsync( + FediverseApplication app, + string code, + string state + ) { + await _keyCacheService.ValidateAuthStateAsync(state); + var tokenResp = await _client.PostAsync( MastodonTokenUri(app.Domain), new FormUrlEncodedContent( @@ -122,109 +118,27 @@ public partial class FediverseAuthService private record MastodonTokenResponse([property: J("access_token")] string AccessToken); - // TODO: Mastodon's OAuth documentation doesn't specify a "state" parameter. that feels... wrong - // https://docs.joinmastodon.org/methods/oauth/ - private async Task GenerateMastodonAuthUrlAsync(FediverseApplication app) + private async Task GenerateMastodonAuthUrlAsync( + FediverseApplication app, + bool forceRefresh + ) { - try + if (forceRefresh) { - await ValidateMastodonAppAsync(app); - } - catch (FoxnounsError.RemoteAuthError e) - { - _logger.Error( - e, - "Error validating app token for {AppId} on {Instance}", - app.Id, - app.Domain + _logger.Information( + "An app credentials refresh was requested for {ApplicationId}, creating a new application", + app.Id ); - app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id); } + var state = HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); + return $"https://{app.Domain}/oauth/authorize?response_type=code" + $"&client_id={app.ClientId}" + $"&scope={HttpUtility.UrlEncode("read:accounts")}" - + $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}"; - } - - private async Task ValidateMastodonAppAsync(FediverseApplication app) - { - // If we don't have an access token stored, or it's too old, get one - // When doing this we don't need to fetch the application info - if (app.AccessToken == null || app.TokenValidUntil < _clock.GetCurrentInstant()) - { - _logger.Debug( - "Application {AppId} on instance {Instance} has no valid token, fetching it", - app.Id, - app.Domain - ); - - app.AccessToken = await GetMastodonAppTokenAsync( - app.Domain, - app.ClientId, - app.ClientSecret - ); - app.TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60); - - _db.Update(app); - await _db.SaveChangesAsync(); - return; - } - - _logger.Debug( - "Checking whether application {AppId} on instance {Instance} is still valid", - app.Id, - app.Domain - ); - - var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentAppUri(app.Domain)); - req.Headers.Add("Authorization", $"Bearer {app.AccessToken}"); - - var resp = await _client.SendAsync(req); - if (!resp.IsSuccessStatusCode) - { - var error = await resp.Content.ReadAsStringAsync(); - throw new FoxnounsError.RemoteAuthError( - "Verifying app credentials returned an error", - error - ); - } - } - - private async Task GetMastodonAppTokenAsync( - string instance, - string clientId, - string clientSecret - ) - { - var resp = await _client.PostAsync( - MastodonTokenUri(instance), - new FormUrlEncodedContent( - new Dictionary - { - { "grant_type", "client_credentials" }, - { "client_id", clientId }, - { "client_secret", clientSecret }, - } - ) - ); - if (!resp.IsSuccessStatusCode) - { - var error = await resp.Content.ReadAsStringAsync(); - throw new FoxnounsError.RemoteAuthError( - "Requesting app token returned an error", - error - ); - } - - var token = (await resp.Content.ReadFromJsonAsync())?.AccessToken; - if (token == null) - { - throw new FoxnounsError($"Token response from instance {instance} was invalid"); - } - - return token; + + $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}" + + $"&state={state}"; } private static string MastodonTokenUri(string instance) => $"https://{instance}/oauth/token"; @@ -232,9 +146,6 @@ public partial class FediverseAuthService private static string MastodonCurrentUserUri(string instance) => $"https://{instance}/api/v1/accounts/verify_credentials"; - private static string MastodonCurrentAppUri(string instance) => - $"https://{instance}/api/v1/apps/verify_credentials"; - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] private record PartialMastodonApplication( [property: J("name")] string Name, diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index fc54017..f78fbde 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -1,7 +1,6 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Microsoft.EntityFrameworkCore; -using NodaTime; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; namespace Foxnouns.Backend.Services.Auth; @@ -10,26 +9,27 @@ public partial class FediverseAuthService { private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0"; - private readonly ILogger _logger; private readonly HttpClient _client; - private readonly DatabaseContext _db; + private readonly ILogger _logger; private readonly Config _config; + private readonly DatabaseContext _db; + private readonly KeyCacheService _keyCacheService; private readonly ISnowflakeGenerator _snowflakeGenerator; - private readonly IClock _clock; public FediverseAuthService( ILogger logger, Config config, DatabaseContext db, - ISnowflakeGenerator snowflakeGenerator, - IClock clock + KeyCacheService keyCacheService, + ISnowflakeGenerator snowflakeGenerator ) { + _logger = logger.ForContext(); _config = config; _db = db; + _keyCacheService = keyCacheService; _snowflakeGenerator = snowflakeGenerator; - _clock = clock; - _logger = logger.ForContext(); + _client = new HttpClient(); _client.DefaultRequestHeaders.Remove("User-Agent"); _client.DefaultRequestHeaders.Remove("Accept"); @@ -37,10 +37,10 @@ public partial class FediverseAuthService _client.DefaultRequestHeaders.Add("Accept", "application/json"); } - public async Task GenerateAuthUrlAsync(string instance) + public async Task GenerateAuthUrlAsync(string instance, bool forceRefresh) { var app = await GetApplicationAsync(instance); - return await GenerateAuthUrlAsync(app); + return await GenerateAuthUrlAsync(app, forceRefresh); } // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, @@ -96,21 +96,25 @@ public partial class FediverseAuthService ); } - private async Task GenerateAuthUrlAsync(FediverseApplication app) => + private async Task GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) => app.InstanceType switch { - FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(app), + FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync( + app, + forceRefresh + ), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), }; public async Task GetRemoteFediverseUserAsync( FediverseApplication app, - string code + string code, + string state ) => app.InstanceType switch { - FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code), + FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), }; diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index 90ba53c..02ca7ca 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -96,6 +96,19 @@ "Mono.TextTemplating": "2.2.1" } }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "zbnPX/JQ0pETRSUG9fNPBvpIq42Aufvs15gGYyNIMhCun9yhmWihz0WgsI7bSDPjxWTKBf8oX/zv6v2uZ3W9OQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, "Minio": { "type": "Direct", "requested": "[6.0.3, )", @@ -246,6 +259,16 @@ "Swashbuckle.AspNetCore.SwaggerUI": "6.6.2" } }, + "System.Text.Json": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A==", + "dependencies": { + "System.IO.Pipelines": "9.0.0", + "System.Text.Encodings.Web": "9.0.0" + } + }, "System.Text.RegularExpressions": { "type": "Direct", "requested": "[4.3.1, )", @@ -412,22 +435,10 @@ }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "resolved": "9.0.0", + "contentHash": "FPWZAa9c0H4dvOj351iR1jkUIs4u9ykL4Bm592yhjDyO5lCoWd+TMAHx2EMbarzUvCvgjWjJIoC6//Q9kH6YhA==", "dependencies": { - "Microsoft.Extensions.Primitives": "8.0.0" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "7pqivmrZDzo1ADPkRwjy+8jtRKWRCPag9qPI+p7sgu7Q4QreWhcvbiWXsbhP+yY8XSiDvZpu2/LWdBv7PnmOpQ==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "8.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Configuration": { @@ -465,8 +476,8 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "fGLiCRLMYd00JYpClraLjJTNKLmMJPnqxMaiRzEBIIvevlzxz33mXy39Lkd48hu1G+N21S7QpaO5ZzKsI6FRuA==" + "resolved": "9.0.0", + "contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -542,10 +553,11 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "resolved": "9.0.0", + "contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "System.Diagnostics.DiagnosticSource": "9.0.0" } }, "Microsoft.Extensions.Logging.Configuration": { @@ -570,11 +582,11 @@ }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", + "resolved": "9.0.0", + "contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { @@ -591,8 +603,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + "resolved": "9.0.0", + "contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg==" }, "Microsoft.NETCore.Platforms": { "type": "Transitive", @@ -983,8 +995,8 @@ }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" + "resolved": "9.0.0", + "contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw==" }, "System.Diagnostics.Tracing": { "type": "Transitive", @@ -1072,8 +1084,8 @@ }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "6.0.3", - "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + "resolved": "9.0.0", + "contentHash": "eA3cinogwaNB4jdjQHOP3Z3EuyiDII7MT35jgtnsA4vkn0LUrrSHsU0nzHTzFzmaFYeKV7MYyMxOocFzsBHpTw==" }, "System.Linq": { "type": "Transitive", @@ -1484,16 +1496,8 @@ }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" - }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "8.0.4", - "contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==", - "dependencies": { - "System.Text.Encodings.Web": "8.0.0" - } + "resolved": "9.0.0", + "contentHash": "e2hMgAErLbKyUUwt18qSBf9T5Y+SFAL3ZedM8fLupkVj8Rj2PZ9oxQ37XX2LF8fTO1wNIxvKpihD7Of7D/NxZw==" }, "System.Threading": { "type": "Transitive",