feat(auth): misc fediverse auth improvements

- remove automatic app validation
- add force refresh option to GetFediverseUrlAsync
- pass state to mastodon authorization URI
This commit is contained in:
sam 2024-11-24 15:37:36 +01:00
parent 142ff36d3a
commit 4e9c4af4a5
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
9 changed files with 143 additions and 180 deletions

View file

@ -22,12 +22,15 @@ public class FediverseAuthController(
[HttpGet]
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFediverseUrlAsync([FromQuery] string instance)
public async Task<IActionResult> 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<IActionResult> 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<FediverseTicketData>(
$"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);

View file

@ -0,0 +1,3 @@
namespace Foxnouns.Backend.Controllers;
public class SidController { }

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241123210306_RemoveFediverseApplicationTokens")]
public partial class RemoveFediverseApplicationTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications");
migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "access_token",
table: "fediverse_applications",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<Instant>(
name: "token_valid_until",
table: "fediverse_applications",
type: "timestamp with time zone",
nullable: true
);
}
}
}

View file

@ -107,10 +107,6 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("AccessToken")
.HasColumnType("text")
.HasColumnName("access_token");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
@ -130,10 +126,6 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("integer")
.HasColumnName("instance_type");
b.Property<Instant?>("TokenValidUntil")
.HasColumnType("timestamp with time zone")
.HasColumnName("token_valid_until");
b.HasKey("Id")
.HasName("pk_fediverse_applications");

View file

@ -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

View file

@ -20,6 +20,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/>
<PackageReference Include="Minio" Version="6.0.3"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="NodaTime" Version="3.1.11"/>
@ -35,6 +36,7 @@
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="System.Text.Json" Version="9.0.0"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
</ItemGroup>

View file

@ -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<FediverseUser> GetMastodonUserAsync(FediverseApplication app, string code)
private async Task<FediverseUser> 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<string> GenerateMastodonAuthUrlAsync(FediverseApplication app)
private async Task<string> 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<string> GetMastodonAppTokenAsync(
string instance,
string clientId,
string clientSecret
)
{
var resp = await _client.PostAsync(
MastodonTokenUri(instance),
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "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<MastodonTokenResponse>())?.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,

View file

@ -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<FediverseAuthService>();
_config = config;
_db = db;
_keyCacheService = keyCacheService;
_snowflakeGenerator = snowflakeGenerator;
_clock = clock;
_logger = logger.ForContext<FediverseAuthService>();
_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<string> GenerateAuthUrlAsync(string instance)
public async Task<string> 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<string> GenerateAuthUrlAsync(FediverseApplication app) =>
private async Task<string> 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<FediverseUser> 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),
};

View file

@ -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",