diff --git a/Foxchat.Core/Federation/RequestSigningService.Client.cs b/Foxchat.Core/Federation/RequestSigningService.Client.cs index 36de79e..a38e89b 100644 --- a/Foxchat.Core/Federation/RequestSigningService.Client.cs +++ b/Foxchat.Core/Federation/RequestSigningService.Client.cs @@ -1,5 +1,4 @@ using System.Net.Http.Headers; -using Foxchat.Core.Models; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -15,14 +14,6 @@ public partial class RequestSigningService public const string SIGNATURE_HEADER = "X-Foxchat-Signature"; public const string USER_HEADER = "X-Foxchat-User"; - private static readonly JsonSerializerSettings _jsonSerializerSettings = new() - { - ContractResolver = new DefaultContractResolver - { - NamingStrategy = new SnakeCaseNamingStrategy() - } - }; - public async Task RequestAsync(HttpMethod method, string domain, string requestPath, string? userId = null, object? body = null) { var request = BuildHttpRequest(method, domain, requestPath, userId, body); @@ -30,17 +21,17 @@ public partial class RequestSigningService if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(); - throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject(error)); + throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", JsonConvert.DeserializeObject(error)); } var bodyString = await resp.Content.ReadAsStringAsync(); - return DeserializeObject(bodyString) + return JsonConvert.DeserializeObject(bodyString) ?? throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned invalid response body"); } private HttpRequestMessage BuildHttpRequest(HttpMethod method, string domain, string requestPath, string? userId = null, object? bodyData = null) { - var body = bodyData != null ? SerializeObject(bodyData) : null; + var body = bodyData != null ? JsonConvert.SerializeObject(bodyData) : null; var now = _clock.GetCurrentInstant(); var url = $"https://{domain}{requestPath}"; @@ -59,7 +50,4 @@ public partial class RequestSigningService return request; } - - public static string SerializeObject(object data) => JsonConvert.SerializeObject(data, _jsonSerializerSettings); - public static T? DeserializeObject(string data) => JsonConvert.DeserializeObject(data, _jsonSerializerSettings); } diff --git a/Foxchat.Core/FoxchatError.cs b/Foxchat.Core/FoxchatError.cs index 7fcd201..6c5324b 100644 --- a/Foxchat.Core/FoxchatError.cs +++ b/Foxchat.Core/FoxchatError.cs @@ -2,10 +2,12 @@ using System.Net; namespace Foxchat.Core; -public class FoxchatError(string message) : Exception(message) +public class FoxchatError(string message, Exception? inner = null) : Exception(message) { - public class DatabaseError(string message) : FoxchatError(message); - public class UnknownEntityError(Type entityType) : FoxchatError($"Entity of type {entityType.Name} not found"); + public Exception? Inner => inner; + + public class DatabaseError(string message, Exception? inner = null) : FoxchatError(message, inner); + public class UnknownEntityError(Type entityType, Exception? inner = null) : FoxchatError($"Entity of type {entityType.Name} not found", inner); } public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxchatError(message) @@ -23,10 +25,10 @@ public class ApiError(string message, HttpStatusCode? statusCode = null) : Foxch public class IncomingFederationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); public class OutgoingFederationError( - string message, Models.ApiError? innerError = null + string message, Models.Http.ApiError? innerError = null ) : ApiError(message, statusCode: HttpStatusCode.InternalServerError) { - public Models.ApiError? InnerError => innerError; + public Models.Http.ApiError? InnerError => innerError; } public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); diff --git a/Foxchat.Core/Models/ApiError.cs b/Foxchat.Core/Models/ApiError.cs deleted file mode 100644 index a9ac71d..0000000 --- a/Foxchat.Core/Models/ApiError.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Foxchat.Core.Models; - -public record ApiError(int Status, ErrorCode Code, string Message); - -public enum ErrorCode -{ - INTERNAL_SERVER_ERROR, - OBJECT_NOT_FOUND, - INVALID_SERVER, - INVALID_HEADER, - INVALID_DATE, - INVALID_SIGNATURE, - MISSING_SIGNATURE, - GUILD_NOT_FOUND, - UNAUTHORIZED, - INVALID_REST_EVENT, -} diff --git a/Foxchat.Core/Models/Http/ApiError.cs b/Foxchat.Core/Models/Http/ApiError.cs new file mode 100644 index 0000000..7d5df22 --- /dev/null +++ b/Foxchat.Core/Models/Http/ApiError.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Foxchat.Core.Models.Http; + +public record ApiError +{ + public required int Status { get; init; } + [JsonConverter(typeof(StringEnumConverter))] + public required ErrorCode Code { get; init; } + public required string Message { get; init; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public ApiError? OriginalError { get; init; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string[]? Scopes { get; init; } +} + +public enum ErrorCode +{ + InternalServerError, + Unauthorized, + Forbidden, + BadRequest, + OutgoingFederationError, + AuthenticationError, + // TODO: more specific API error codes + GenericApiError, +} diff --git a/Foxchat.Identity/Controllers/Oauth/AppsController.cs b/Foxchat.Identity/Controllers/Oauth/AppsController.cs index 113a3b7..4b6eed4 100644 --- a/Foxchat.Identity/Controllers/Oauth/AppsController.cs +++ b/Foxchat.Identity/Controllers/Oauth/AppsController.cs @@ -1,8 +1,9 @@ using Foxchat.Core; using Foxchat.Core.Models.Http; -using Foxchat.Identity.Authorization; +using Foxchat.Identity.Middleware; using Foxchat.Identity.Database; using Foxchat.Identity.Database.Models; +using Foxchat.Identity.Utils; using Microsoft.AspNetCore.Mvc; namespace Foxchat.Identity.Controllers.Oauth; @@ -29,9 +30,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase [HttpGet] public IActionResult GetSelfApp([FromQuery(Name = "with_secret")] bool withSecret) { - var token = HttpContext.GetToken(); - if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token."); - var app = token.Application; + var app = HttpContext.GetApplicationOrThrow(); return Ok(new Apps.GetSelfResponse( app.Id, diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs new file mode 100644 index 0000000..7d867a1 --- /dev/null +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -0,0 +1,22 @@ +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Database; +using Foxchat.Identity.Utils; +using Microsoft.AspNetCore.Mvc; + +namespace Foxchat.Identity.Controllers.Oauth; + +[ApiController] +[Authenticate] +[Route("/_fox/ident/oauth/password")] +public class PasswordAuthController(ILogger logger, IdentityContext db) : ControllerBase +{ + [HttpPost("register")] + public async Task Register() + { + var app = HttpContext.GetApplicationOrThrow(); + + throw new NotImplementedException(); + } + + public record RegisterRequest(); +} \ No newline at end of file diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs index 9a8806a..1f84414 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -16,7 +16,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); var scopes = req.Scope.Split(' '); - if (app.Scopes.Except(scopes).Any()) + if (scopes.Except(app.Scopes).Any()) { throw new ApiError.BadRequest("Invalid or unauthorized scopes"); } diff --git a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs index 510a7c4..7597b43 100644 --- a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs +++ b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs @@ -1,4 +1,4 @@ -using Foxchat.Identity.Authorization; +using Foxchat.Identity.Middleware; namespace Foxchat.Identity.Extensions; @@ -7,6 +7,7 @@ public static class WebApplicationExtensions public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) { return services + .AddScoped() .AddScoped() .AddScoped(); } @@ -14,6 +15,7 @@ public static class WebApplicationExtensions public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) { return app + .UseMiddleware() .UseMiddleware() .UseMiddleware(); } diff --git a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs b/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs similarity index 98% rename from Foxchat.Identity/Authorization/AuthenticationMiddleware.cs rename to Foxchat.Identity/Middleware/AuthenticationMiddleware.cs index 8b89399..663515d 100644 --- a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs +++ b/Foxchat.Identity/Middleware/AuthenticationMiddleware.cs @@ -6,7 +6,7 @@ using Foxchat.Identity.Database.Models; using Microsoft.EntityFrameworkCore; using NodaTime; -namespace Foxchat.Identity.Authorization; +namespace Foxchat.Identity.Middleware; public class AuthenticationMiddleware( IdentityContext db, diff --git a/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/AuthorizationMiddleware.cs similarity index 94% rename from Foxchat.Identity/Authorization/AuthorizationMiddleware.cs rename to Foxchat.Identity/Middleware/AuthorizationMiddleware.cs index 2bb8203..46a2fe6 100644 --- a/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs +++ b/Foxchat.Identity/Middleware/AuthorizationMiddleware.cs @@ -1,9 +1,8 @@ -using System.Net; using Foxchat.Core; using Foxchat.Identity.Database; using NodaTime; -namespace Foxchat.Identity.Authorization; +namespace Foxchat.Identity.Middleware; public class AuthorizationMiddleware( IdentityContext db, diff --git a/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs b/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs new file mode 100644 index 0000000..e56a660 --- /dev/null +++ b/Foxchat.Identity/Middleware/ErrorHandlerMiddleware.cs @@ -0,0 +1,87 @@ + +using System.Net; +using Foxchat.Core; +using Foxchat.Core.Models.Http; +using Newtonsoft.Json; +using ApiError = Foxchat.Core.ApiError; +using HttpApiError = Foxchat.Core.Models.Http.ApiError; + +namespace Foxchat.Identity.Middleware; + +public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware +{ + public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) + { + try + { + await next(ctx); + } + catch (Exception e) + { + var type = e.TargetSite?.DeclaringType ?? typeof(ErrorHandlerMiddleware); + var typeName = e.TargetSite?.DeclaringType?.FullName ?? ""; + var logger = baseLogger.ForContext(type); + + if (ctx.Response.HasStarted) + { + logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName, ctx.Request.Path); + } + + if (e is ApiError ae) + { + ctx.Response.StatusCode = (int)ae.StatusCode; + ctx.Response.Headers.RequestId = ctx.TraceIdentifier; + ctx.Response.ContentType = "application/json; charset=utf-8"; + if (ae is ApiError.OutgoingFederationError ofe) + { + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError + { + Status = (int)ofe.StatusCode, + Code = ErrorCode.OutgoingFederationError, + Message = ofe.Message, + OriginalError = ofe.InnerError + })); + return; + } + else if (ae is ApiError.Forbidden fe) + { + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError + { + Status = (int)fe.StatusCode, + Code = ErrorCode.Forbidden, + Message = fe.Message, + Scopes = fe.Scopes.Length > 0 ? fe.Scopes : null + })); + return; + } + + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError + { + Status = (int)ae.StatusCode, + Code = ErrorCode.GenericApiError, + Message = ae.Message, + })); + return; + } + + if (e is FoxchatError fce) + { + logger.Error(fce.Inner ?? fce, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); + } + else + { + logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); + } + + ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + ctx.Response.Headers.RequestId = ctx.TraceIdentifier; + ctx.Response.ContentType = "application/json; charset=utf-8"; + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError + { + Status = (int)HttpStatusCode.InternalServerError, + Code = ErrorCode.InternalServerError, + Message = "Internal server error", + })); + } + } +} \ No newline at end of file diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs index ae53e12..782ca94 100644 --- a/Foxchat.Identity/Program.cs +++ b/Foxchat.Identity/Program.cs @@ -5,6 +5,7 @@ using Foxchat.Identity; using Foxchat.Identity.Database; using Foxchat.Identity.Services; using Foxchat.Identity.Extensions; +using Newtonsoft.Json; var builder = WebApplication.CreateBuilder(args); @@ -15,6 +16,15 @@ builder.AddSerilog(config.LogEventLevel); await BuildInfo.ReadBuildInfo(); Log.Information("Starting Foxchat.Identity {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); +// Set the default converter to snake case as we use it in a couple places. +JsonConvert.DefaultSettings = () => new JsonSerializerSettings +{ + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + } +}; + builder.Services .AddControllers() .AddNewtonsoftJson(options => diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs index 16256ce..ae37b1a 100644 --- a/Foxchat.Identity/Utils/OauthUtils.cs +++ b/Foxchat.Identity/Utils/OauthUtils.cs @@ -1,3 +1,7 @@ +using Foxchat.Core; +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Database.Models; + namespace Foxchat.Identity.Utils; public static class OauthUtils @@ -20,4 +24,11 @@ public static class OauthUtils return false; } } + + public static Application GetApplicationOrThrow(this HttpContext context) + { + var token = context.GetToken(); + if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token."); + return token.Application; + } }