diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs deleted file mode 100644 index fdd10b7..0000000 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Foxnouns.Backend.Services; -using Microsoft.AspNetCore.Mvc; - -namespace Foxnouns.Backend.Controllers.Authentication; - -[Route("/api/internal/auth/fediverse")] -public class FediverseAuthController(FediverseAuthService fediverseAuthService) : ApiControllerBase -{ - [HttpGet] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task GetFediverseUrlAsync([FromQuery] string instance) - { - var url = await fediverseAuthService.GenerateAuthUrlAsync(instance); - return Ok(new FediverseUrlResponse(url)); - } - - public async Task FediverseCallbackAsync([FromBody] CallbackRequest req) - { - throw new NotImplementedException(); - } - - public record CallbackRequest(string Instance, string Code); - - private record FediverseUrlResponse(string Url); -} diff --git a/Foxnouns.Backend/Database/Migrations/20241006125003_AddFediverseAccessTokens.cs b/Foxnouns.Backend/Database/Migrations/20241006125003_AddFediverseAccessTokens.cs deleted file mode 100644 index 37023f0..0000000 --- a/Foxnouns.Backend/Database/Migrations/20241006125003_AddFediverseAccessTokens.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using NodaTime; - -#nullable disable - -namespace Foxnouns.Backend.Database.Migrations -{ - /// - [DbContext(typeof(DatabaseContext))] - [Migration("20241006125003_AddFediverseAccessTokens")] - public partial class AddFediverseAccessTokens : Migration - { - /// - protected override void Up(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 - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications"); - - migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications"); - } - } -} 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..6dc813d 100644 --- a/Foxnouns.Backend/Database/Models/FediverseApplication.cs +++ b/Foxnouns.Backend/Database/Models/FediverseApplication.cs @@ -1,5 +1,3 @@ -using NodaTime; - namespace Foxnouns.Backend.Database.Models; public class FediverseApplication : BaseModel @@ -8,10 +6,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/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index 1b92a7e..fdd0b5d 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -14,15 +14,6 @@ public class FoxnounsError(string message, Exception? inner = null) : Exception( public class UnknownEntityError(Type entityType, Exception? inner = null) : DatabaseError($"Entity of type {entityType.Name} not found", inner); - - public class RemoteAuthError(string message, string? errorBody = null, Exception? inner = null) - : FoxnounsError(message, inner) - { - public string? ErrorBody => errorBody; - - public override string ToString() => - $"{Message}: {ErrorBody} {(Inner != null ? $"({Inner})" : "")}"; - } } public class ApiError( diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index b2e519d..3e6926c 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -101,7 +101,6 @@ public static class WebApplicationExtensions .AddScoped() .AddScoped() .AddScoped() - .AddScoped() .AddScoped() // Background services .AddHostedService() diff --git a/Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs deleted file mode 100644 index c8d9dd5..0000000 --- a/Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System.Net; -using System.Web; -using Foxnouns.Backend.Database; -using Foxnouns.Backend.Database.Models; -using Minio.DataModel.ILM; -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 CreateMastodonApplicationAsync( - string instance, - Snowflake? existingAppId = null - ) - { - var resp = await _client.PostAsync( - $"https://{instance}/api/v1/apps", - new FormUrlEncodedContent( - new Dictionary - { - { "client_name", $"pronouns.cc (+{_config.BaseUrl})" }, - { "redirect_uris", MastodonRedirectUri(instance) }, - { "scope", "read:accounts" }, - { "website", _config.BaseUrl }, - } - ) - ); - resp.EnsureSuccessStatusCode(); - - var mastodonApp = await resp.Content.ReadFromJsonAsync(); - 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 GetMastodonUserAsync(FediverseApplication app, string code) - { - var tokenResp = await _client.PostAsync( - MastodonTokenUri(app.Domain), - new FormUrlEncodedContent( - new Dictionary - { - { "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() - )?.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(); - 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 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 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; - } - - 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 - ); -} diff --git a/Foxnouns.Backend/Services/FediverseAuthService.cs b/Foxnouns.Backend/Services/FediverseAuthService.cs deleted file mode 100644 index ff39e88..0000000 --- a/Foxnouns.Backend/Services/FediverseAuthService.cs +++ /dev/null @@ -1,162 +0,0 @@ -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; - -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 Config _config; - private readonly ISnowflakeGenerator _snowflakeGenerator; - private readonly IClock _clock; - - public FediverseAuthService( - ILogger logger, - Config config, - DatabaseContext db, - ISnowflakeGenerator snowflakeGenerator, - IClock clock - ) - { - _config = config; - _db = db; - _snowflakeGenerator = snowflakeGenerator; - _clock = clock; - _logger = logger.ForContext(); - _client = new HttpClient(); - _client.DefaultRequestHeaders.Remove("User-Agent"); - _client.DefaultRequestHeaders.Remove("Accept"); - _client.DefaultRequestHeaders.Add("User-Agent", $"pronouns.cc/{BuildInfo.Version}"); - _client.DefaultRequestHeaders.Add("Accept", "application/json"); - } - - public async Task GenerateAuthUrlAsync(string instance) - { - var app = await GetApplicationAsync(instance); - return await GenerateAuthUrlAsync(app); - } - - public async Task GetRemoteFediverseUserAsync(string instance, string code) - { - var app = await GetApplicationAsync(instance); - return await GetRemoteUserAsync(app, code); - } - - // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, - // and having both mastodon and misskey use "username" in the self user response - public record FediverseUser( - [property: J("id")] string Id, - [property: J("username")] string Username - ); - - private async Task GetApplicationAsync(string instance) - { - var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance); - if (app != null) - return app; - - _logger.Debug("No application for fediverse instance {Instance}, creating it", instance); - - var softwareName = await GetSoftwareNameAsync(instance); - - if (IsMastodonCompatible(softwareName)) - { - return await CreateMastodonApplicationAsync(instance); - } - - throw new NotImplementedException(); - } - - private async Task GetSoftwareNameAsync(string instance) - { - _logger.Debug("Requesting software name for fediverse instance {Instance}", instance); - - var wellKnownResp = await _client.GetAsync( - new Uri($"https://{instance}/.well-known/nodeinfo") - ); - wellKnownResp.EnsureSuccessStatusCode(); - - var wellKnown = await wellKnownResp.Content.ReadFromJsonAsync(); - var nodeInfoUrl = wellKnown?.Links.FirstOrDefault(l => l.Rel == NodeInfoRel)?.Href; - if (nodeInfoUrl == null) - { - throw new FoxnounsError( - $".well-known/nodeinfo response for instance {instance} was invalid, no nodeinfo link" - ); - } - - var nodeInfoResp = await _client.GetAsync(nodeInfoUrl); - nodeInfoResp.EnsureSuccessStatusCode(); - - var nodeInfo = await nodeInfoResp.Content.ReadFromJsonAsync(); - return nodeInfo?.Software.Name - ?? throw new FoxnounsError( - $"Nodeinfo response for instance {instance} was invalid, no software name" - ); - } - - private async Task GenerateAuthUrlAsync(FediverseApplication app) => - app.InstanceType switch - { - FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(app), - FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), - _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), - }; - - private async Task GetRemoteUserAsync(FediverseApplication app, string code) => - app.InstanceType switch - { - FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code), - FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), - _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), - }; - - private static readonly string[] MastodonSoftwareNames = - [ - "mastodon", - "hometown", - "akkoma", - "pleroma", - "iceshrimp.net", - "iceshrimp", - "gotosocial", - "pixelfed", - ]; - - private static readonly string[] MisskeySoftwareNames = - [ - "misskey", - "foundkey", - "calckey", - "firefish", - "sharkey", - ]; - - private static bool IsMastodonCompatible(string softwareName) => - MastodonSoftwareNames.Any(n => - string.Equals(softwareName, n, StringComparison.InvariantCultureIgnoreCase) - ); - - private static bool IsMisskeyCompatible(string softwareName) => - MisskeySoftwareNames.Any(n => - string.Equals(softwareName, n, StringComparison.InvariantCultureIgnoreCase) - ); - - private record WellKnownResponse([property: J("links")] WellKnownLink[] Links); - - private record WellKnownLink( - [property: J("rel")] string Rel, - [property: J("href")] string Href - ); - - private record PartialNodeInfo([property: J("software")] NodeInfoSoftware Software); - - private record NodeInfoSoftware([property: J("name")] string Name); -} diff --git a/Foxnouns.Frontend/app/lib/request.server.ts b/Foxnouns.Frontend/app/lib/request.server.ts index 8dab154..7da3e84 100644 --- a/Foxnouns.Frontend/app/lib/request.server.ts +++ b/Foxnouns.Frontend/app/lib/request.server.ts @@ -1,5 +1,5 @@ import { parse as parseCookie, serialize as serializeCookie } from "cookie"; -import { API_BASE, INTERNAL_API_BASE } from "~/env.server"; +import { INTERNAL_API_BASE } from "~/env.server"; import { ApiError, ErrorCode } from "./api/error"; import { tokenCookieName } from "~/lib/utils"; @@ -11,17 +11,12 @@ export type RequestParams = { isInternal?: boolean; }; -export type RequestMethod = "GET" | "POST" | "PATCH" | "DELETE"; - export async function baseRequest( - method: RequestMethod, + method: string, path: string, params: RequestParams = {}, ): Promise { - // Internal requests, unauthenticated requests, and GET requests bypass the rate limiting proxy. - // All other requests go through the proxy, and are rate limited. - let base = params.isInternal || !params.token || method === "GET" ? INTERNAL_API_BASE : API_BASE; - base += params.isInternal ? "/internal" : "/v2"; + const base = params.isInternal ? INTERNAL_API_BASE + "/internal" : INTERNAL_API_BASE + "/v2"; const url = `${base}${path}`; const resp = await fetch(url, { @@ -48,12 +43,12 @@ export async function baseRequest( return resp; } -export async function fastRequest(method: RequestMethod, path: string, params: RequestParams = {}) { +export async function fastRequest(method: string, path: string, params: RequestParams = {}) { await baseRequest(method, path, params); } export default async function serverRequest( - method: RequestMethod, + method: string, path: string, params: RequestParams = {}, ) { diff --git a/Foxnouns.Frontend/app/routes/settings._index/route.tsx b/Foxnouns.Frontend/app/routes/settings._index/route.tsx index 2829098..88655fc 100644 --- a/Foxnouns.Frontend/app/routes/settings._index/route.tsx +++ b/Foxnouns.Frontend/app/routes/settings._index/route.tsx @@ -5,6 +5,7 @@ import { Link, Outlet, useActionData, + useFetcher, useRouteLoaderData, } from "@remix-run/react"; import { loader as settingsLoader } from "../settings/route";