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

@ -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),
};