sam
d982342ab8
turns out efcore doesn't like it when we create a new options instance (which includes a new data source *and* a new logger factory) every single time we create a context. this commit extracts OnConfiguring into static methods which are called when the context is added to the service collection and when it's manually created for migrations and the importer.
245 lines
8.4 KiB
C#
245 lines
8.4 KiB
C#
using System.Net;
|
|
using System.Web;
|
|
using Foxnouns.Backend.Database;
|
|
using Foxnouns.Backend.Database.Models;
|
|
using Duration = NodaTime.Duration;
|
|
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
|
|
|
namespace Foxnouns.Backend.Services;
|
|
|
|
public partial class FediverseAuthService
|
|
{
|
|
private string MastodonRedirectUri(string instance) =>
|
|
$"{_config.BaseUrl}/auth/login/mastodon/{instance}";
|
|
|
|
private async Task<FediverseApplication> CreateMastodonApplicationAsync(
|
|
string instance,
|
|
Snowflake? existingAppId = null
|
|
)
|
|
{
|
|
var resp = await _client.PostAsync(
|
|
$"https://{instance}/api/v1/apps",
|
|
new FormUrlEncodedContent(
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "client_name", $"pronouns.cc (+{_config.BaseUrl})" },
|
|
{ "redirect_uris", MastodonRedirectUri(instance) },
|
|
{ "scope", "read:accounts" },
|
|
{ "website", _config.BaseUrl },
|
|
}
|
|
)
|
|
);
|
|
resp.EnsureSuccessStatusCode();
|
|
|
|
var mastodonApp = await resp.Content.ReadFromJsonAsync<PartialMastodonApplication>();
|
|
if (mastodonApp == null)
|
|
throw new FoxnounsError(
|
|
$"Application created on Mastodon-compatible instance {instance} was null"
|
|
);
|
|
|
|
var token = await GetMastodonAppTokenAsync(
|
|
instance,
|
|
mastodonApp.ClientId,
|
|
mastodonApp.ClientSecret
|
|
);
|
|
|
|
FediverseApplication app;
|
|
|
|
if (existingAppId == null)
|
|
{
|
|
app = new FediverseApplication
|
|
{
|
|
Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(),
|
|
ClientId = mastodonApp.ClientId,
|
|
ClientSecret = mastodonApp.ClientSecret,
|
|
Domain = instance,
|
|
InstanceType = FediverseInstanceType.MastodonApi,
|
|
AccessToken = token,
|
|
TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60),
|
|
};
|
|
|
|
_db.Add(app);
|
|
}
|
|
else
|
|
{
|
|
app =
|
|
await _db.FediverseApplications.FindAsync(existingAppId)
|
|
?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null");
|
|
|
|
app.ClientId = mastodonApp.ClientId;
|
|
app.ClientSecret = mastodonApp.ClientSecret;
|
|
app.InstanceType = FediverseInstanceType.MastodonApi;
|
|
app.AccessToken = null;
|
|
app.TokenValidUntil = null;
|
|
}
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
return app;
|
|
}
|
|
|
|
private async Task<FediverseUser> GetMastodonUserAsync(FediverseApplication app, string code)
|
|
{
|
|
var tokenResp = await _client.PostAsync(
|
|
MastodonTokenUri(app.Domain),
|
|
new FormUrlEncodedContent(
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "grant_type", "authorization_code" },
|
|
{ "code", code },
|
|
{ "scope", "read:accounts" },
|
|
{ "client_id", app.ClientId },
|
|
{ "client_secret", app.ClientSecret },
|
|
{ "redirect_uri", MastodonRedirectUri(app.Domain) },
|
|
}
|
|
)
|
|
);
|
|
if (tokenResp.StatusCode == HttpStatusCode.Unauthorized)
|
|
{
|
|
throw new FoxnounsError($"Application for instance {app.Domain} was invalid");
|
|
}
|
|
|
|
tokenResp.EnsureSuccessStatusCode();
|
|
var token = (
|
|
await tokenResp.Content.ReadFromJsonAsync<MastodonTokenResponse>()
|
|
)?.AccessToken;
|
|
if (token == null)
|
|
{
|
|
throw new FoxnounsError($"Token response from instance {app.Domain} was invalid");
|
|
}
|
|
|
|
var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentUserUri(app.Domain));
|
|
req.Headers.Add("Authorization", $"Bearer {token}");
|
|
|
|
var currentUserResp = await _client.SendAsync(req);
|
|
currentUserResp.EnsureSuccessStatusCode();
|
|
var user = await currentUserResp.Content.ReadFromJsonAsync<FediverseUser>();
|
|
if (user == null)
|
|
{
|
|
throw new FoxnounsError($"User response from instance {app.Domain} was invalid");
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
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)
|
|
{
|
|
try
|
|
{
|
|
await ValidateMastodonAppAsync(app);
|
|
}
|
|
catch (FoxnounsError.RemoteAuthError e)
|
|
{
|
|
_logger.Error(
|
|
e,
|
|
"Error validating app token for {AppId} on {Instance}",
|
|
app.Id,
|
|
app.Domain
|
|
);
|
|
|
|
app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static string MastodonTokenUri(string instance) => $"https://{instance}/oauth/token";
|
|
|
|
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";
|
|
|
|
private record PartialMastodonApplication(
|
|
[property: J("name")] string Name,
|
|
[property: J("client_id")] string ClientId,
|
|
[property: J("client_secret")] string ClientSecret
|
|
);
|
|
}
|