2024-11-23 20:40:09 +01:00
|
|
|
using System.Diagnostics.CodeAnalysis;
|
2024-10-06 15:34:31 +02:00
|
|
|
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;
|
|
|
|
|
2024-11-03 02:07:07 +01:00
|
|
|
namespace Foxnouns.Backend.Services.Auth;
|
2024-10-06 15:34:31 +02:00
|
|
|
|
|
|
|
public partial class FediverseAuthService
|
|
|
|
{
|
|
|
|
private string MastodonRedirectUri(string instance) =>
|
2024-11-03 02:07:07 +01:00
|
|
|
$"{_config.BaseUrl}/auth/callback/mastodon/{instance}";
|
2024-10-06 15:34:31 +02:00
|
|
|
|
|
|
|
private async Task<FediverseApplication> CreateMastodonApplicationAsync(
|
|
|
|
string instance,
|
|
|
|
Snowflake? existingAppId = null
|
|
|
|
)
|
|
|
|
{
|
2024-11-23 20:40:09 +01:00
|
|
|
var resp = await _client.PostAsJsonAsync(
|
2024-10-06 15:34:31 +02:00
|
|
|
$"https://{instance}/api/v1/apps",
|
2024-11-23 20:40:09 +01:00
|
|
|
new CreateMastodonApplicationRequest(
|
|
|
|
ClientName: $"pronouns.cc (+{_config.BaseUrl})",
|
|
|
|
RedirectUris: MastodonRedirectUri(instance),
|
|
|
|
Scopes: "read read:accounts",
|
|
|
|
Website: _config.BaseUrl
|
2024-10-06 15:34:31 +02:00
|
|
|
)
|
|
|
|
);
|
|
|
|
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";
|
|
|
|
|
2024-11-23 20:40:09 +01:00
|
|
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
|
2024-10-06 15:34:31 +02:00
|
|
|
private record PartialMastodonApplication(
|
|
|
|
[property: J("name")] string Name,
|
|
|
|
[property: J("client_id")] string ClientId,
|
|
|
|
[property: J("client_secret")] string ClientSecret
|
|
|
|
);
|
2024-11-23 20:40:09 +01:00
|
|
|
|
|
|
|
private record CreateMastodonApplicationRequest(
|
|
|
|
[property: J("client_name")] string ClientName,
|
|
|
|
[property: J("redirect_uris")] string RedirectUris,
|
|
|
|
[property: J("scopes")] string Scopes,
|
|
|
|
[property: J("website")] string Website
|
|
|
|
);
|
2024-10-06 15:34:31 +02:00
|
|
|
}
|