diff --git a/Foxchat.Core/Federation/RequestSigningService.Client.cs b/Foxchat.Core/Federation/RequestSigningService.Client.cs index a3aa673..36de79e 100644 --- a/Foxchat.Core/Federation/RequestSigningService.Client.cs +++ b/Foxchat.Core/Federation/RequestSigningService.Client.cs @@ -30,12 +30,12 @@ public partial class RequestSigningService if (!resp.IsSuccessStatusCode) { var error = await resp.Content.ReadAsStringAsync(); - throw new FoxchatError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject(error)); + throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject(error)); } var bodyString = await resp.Content.ReadAsStringAsync(); return DeserializeObject(bodyString) - ?? throw new FoxchatError.OutgoingFederationError($"Request to {domain}{requestPath} returned invalid response body"); + ?? 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) diff --git a/Foxchat.Core/Federation/RequestSigningService.cs b/Foxchat.Core/Federation/RequestSigningService.cs index 6c1e8ed..07b0536 100644 --- a/Foxchat.Core/Federation/RequestSigningService.cs +++ b/Foxchat.Core/Federation/RequestSigningService.cs @@ -5,16 +5,15 @@ using Foxchat.Core.Database; using Foxchat.Core.Utils; using NodaTime; using NodaTime.Text; -using Serilog; namespace Foxchat.Core.Federation; -public partial class RequestSigningService(ILogger logger, IClock clock, IDatabaseContext context, CoreConfig config) +public partial class RequestSigningService(ILogger logger, IClock clock, IDatabaseContext db, CoreConfig config) { private readonly ILogger _logger = logger.ForContext(); private readonly IClock _clock = clock; private readonly CoreConfig _config = config; - private readonly RSA _rsa = context.GetInstanceKeysAsync().GetAwaiter().GetResult(); + private readonly RSA _rsa = db.GetInstanceKeysAsync().GetAwaiter().GetResult(); private readonly HttpClient _httpClient = new(); public string GenerateSignature(SignatureData data) @@ -41,11 +40,11 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba var time = ParseTime(dateHeader); if ((now + Duration.FromMinutes(1)) < time) { - throw new FoxchatError.IncomingFederationError("Request was made in the future"); + throw new ApiError.IncomingFederationError("Request was made in the future"); } else if ((now - Duration.FromMinutes(1)) > time) { - throw new FoxchatError.IncomingFederationError("Request was made too long ago"); + throw new ApiError.IncomingFederationError("Request was made too long ago"); } var plaintext = GeneratePlaintext(new SignatureData(time, host, requestPath, contentLength, userId)); @@ -54,7 +53,7 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba if (!CryptoUtils.TryFromBase64String(encodedSignature, out var signature)) { - throw new FoxchatError.IncomingFederationError("Invalid base64 signature"); + throw new ApiError.IncomingFederationError("Invalid base64 signature"); } var deformatter = new RSAPKCS1SignatureDeformatter(rsa); diff --git a/Foxchat.Core/FoxchatError.cs b/Foxchat.Core/FoxchatError.cs index d395b7c..7fcd201 100644 --- a/Foxchat.Core/FoxchatError.cs +++ b/Foxchat.Core/FoxchatError.cs @@ -4,18 +4,8 @@ namespace Foxchat.Core; public class FoxchatError(string message) : Exception(message) { - public class BadRequest(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); - public class IncomingFederationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); - - public class OutgoingFederationError( - string message, Models.ApiError? innerError = null - ) : ApiError(message, statusCode: HttpStatusCode.InternalServerError) - { - public Models.ApiError? InnerError => innerError; - } - public class DatabaseError(string message) : FoxchatError(message); - public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); + public class UnknownEntityError(Type entityType) : FoxchatError($"Entity of type {entityType.Name} not found"); } public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxchatError(message) @@ -28,4 +18,16 @@ public class ApiError(string message, HttpStatusCode? statusCode = null) : Foxch { public readonly string[] Scopes = scopes?.ToArray() ?? []; } + + public class BadRequest(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); + public class IncomingFederationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); + + public class OutgoingFederationError( + string message, Models.ApiError? innerError = null + ) : ApiError(message, statusCode: HttpStatusCode.InternalServerError) + { + public Models.ApiError? InnerError => innerError; + } + + public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); } diff --git a/Foxchat.Core/Models/Hello.cs b/Foxchat.Core/Models/Hello.cs deleted file mode 100644 index 5d175ef..0000000 --- a/Foxchat.Core/Models/Hello.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Foxchat.Core.Models; - -public record HelloRequest(string Host); -public record HelloResponse(string PublicKey, string Host); -public record NodeInfo(string Software, string PublicKey); -public record NodeSoftware(string Name, string? Version); diff --git a/Foxchat.Core/Models/Http/Apps.cs b/Foxchat.Core/Models/Http/Apps.cs new file mode 100644 index 0000000..8b670f4 --- /dev/null +++ b/Foxchat.Core/Models/Http/Apps.cs @@ -0,0 +1,8 @@ +namespace Foxchat.Core.Models.Http; + +public static class Apps +{ + public record CreateRequest(string Name, string[] Scopes, string[] RedirectUris); + public record CreateResponse(Ulid Id, string ClientId, string ClientSecret, string Name, string[] Scopes, string[] RedirectUris); + public record GetSelfResponse(Ulid Id, string ClientId, string? ClientSecret, string Name, string[] Scopes, string[] RedirectUris); +} diff --git a/Foxchat.Core/Models/Http/Hello.cs b/Foxchat.Core/Models/Http/Hello.cs new file mode 100644 index 0000000..69e0b74 --- /dev/null +++ b/Foxchat.Core/Models/Http/Hello.cs @@ -0,0 +1,9 @@ +namespace Foxchat.Core.Models.Http; + +public static class Hello +{ + public record HelloRequest(string Host); + public record HelloResponse(string PublicKey, string Host); + public record NodeInfo(string Software, string PublicKey); + public record NodeSoftware(string Name, string? Version); +} diff --git a/Foxchat.Core/Utils/ConvertUtils.cs b/Foxchat.Core/Utils/CryptoUtils.cs similarity index 100% rename from Foxchat.Core/Utils/ConvertUtils.cs rename to Foxchat.Core/Utils/CryptoUtils.cs diff --git a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs b/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs index 4b815d0..8b89399 100644 --- a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs +++ b/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs @@ -63,7 +63,7 @@ public static class HttpContextExtensions public static void SetToken(this HttpContext ctx, Token token) => ctx.Items.Add(Key, token); public static Account? GetAccount(this HttpContext ctx) => ctx.GetToken()?.Account; public static Account GetAccountOrThrow(this HttpContext ctx) => - ctx.GetAccount() ?? throw new FoxchatError.AuthenticationError("No account in HttpContext"); + ctx.GetAccount() ?? throw new ApiError.AuthenticationError("No account in HttpContext"); public static Token? GetToken(this HttpContext ctx) { diff --git a/Foxchat.Identity/Controllers/NodeController.cs b/Foxchat.Identity/Controllers/NodeController.cs index b94fa71..4c47503 100644 --- a/Foxchat.Identity/Controllers/NodeController.cs +++ b/Foxchat.Identity/Controllers/NodeController.cs @@ -1,4 +1,4 @@ -using Foxchat.Core.Models; +using Foxchat.Core.Models.Http; using Foxchat.Identity.Database; using Foxchat.Identity.Services; using Microsoft.AspNetCore.Mvc; @@ -7,15 +7,15 @@ namespace Foxchat.Identity.Controllers; [ApiController] [Route("/_fox/ident/node")] -public class NodeController(IdentityContext context, ChatInstanceResolverService chatInstanceResolverService) : ControllerBase +public class NodeController(IdentityContext db, ChatInstanceResolverService chatInstanceResolverService) : ControllerBase { public const string SOFTWARE_NAME = "Foxchat.NET.Identity"; [HttpGet] public async Task GetNode() { - var instance = await context.GetInstanceAsync(); - return Ok(new NodeInfo(SOFTWARE_NAME, instance.PublicKey)); + var instance = await db.GetInstanceAsync(); + return Ok(new Hello.NodeInfo(SOFTWARE_NAME, instance.PublicKey)); } [HttpGet("{domain}")] diff --git a/Foxchat.Identity/Controllers/Oauth/AppsController.cs b/Foxchat.Identity/Controllers/Oauth/AppsController.cs new file mode 100644 index 0000000..113a3b7 --- /dev/null +++ b/Foxchat.Identity/Controllers/Oauth/AppsController.cs @@ -0,0 +1,45 @@ +using Foxchat.Core; +using Foxchat.Core.Models.Http; +using Foxchat.Identity.Authorization; +using Foxchat.Identity.Database; +using Foxchat.Identity.Database.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Foxchat.Identity.Controllers.Oauth; + +[ApiController] +[Authenticate] +[Route("/_fox/ident/oauth/apps")] +public class AppsController(ILogger logger, IdentityContext db) : ControllerBase +{ + [HttpPost] + public async Task CreateApplication([FromBody] Apps.CreateRequest req) + { + var app = Application.Create(req.Name, req.Scopes, req.RedirectUris); + await db.AddAsync(app); + await db.SaveChangesAsync(); + + logger.Information("Created new application {Name} with ID {Id} and client ID {ClientId}", app.Name, app.Id, app.ClientId); + + return Ok(new Apps.CreateResponse( + app.Id, app.ClientId, app.ClientSecret, app.Name, app.Scopes, app.RedirectUris + )); + } + + [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; + + return Ok(new Apps.GetSelfResponse( + app.Id, + app.ClientId, + withSecret ? app.ClientSecret : null, + app.Name, + app.Scopes, + app.RedirectUris + )); + } +} diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs new file mode 100644 index 0000000..9a8806a --- /dev/null +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -0,0 +1,73 @@ +using Foxchat.Core; +using Foxchat.Identity.Database; +using Foxchat.Identity.Database.Models; +using Microsoft.AspNetCore.Mvc; +using NodaTime; + +namespace Foxchat.Identity.Controllers.Oauth; + +[ApiController] +[Route("/_fox/ident/oauth/token")] +public class TokenController(ILogger logger, IdentityContext db, IClock clock) : ControllerBase +{ + [HttpPost] + public async Task PostToken([FromBody] PostTokenRequest req) + { + var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); + + var scopes = req.Scope.Split(' '); + if (app.Scopes.Except(scopes).Any()) + { + throw new ApiError.BadRequest("Invalid or unauthorized scopes"); + } + + switch (req.GrantType) + { + case "client_credentials": + return await HandleClientCredentialsAsync(app, scopes); + case "authorization_code": + break; + default: + throw new ApiError.BadRequest("Unknown grant_type"); + } + + throw new NotImplementedException(); + } + + private async Task HandleClientCredentialsAsync(Application app, string[] scopes) + { + // TODO: make this configurable + var expiry = clock.GetCurrentInstant() + Duration.FromDays(365); + var (token, hash) = Token.Generate(); + var tokenObj = new Token + { + Hash = hash, + Scopes = scopes, + Expires = expiry, + ApplicationId = app.Id + }; + + await db.AddAsync(tokenObj); + await db.SaveChangesAsync(); + + logger.Debug("Created token with scopes {Scopes} for application {ApplicationId}", scopes, app.Id); + + return Ok(new PostTokenResponse(token, scopes, expiry)); + } + + public record PostTokenRequest( + string GrantType, + string ClientId, + string ClientSecret, + string Scope, + // Optional parameters + string? Code, + string? RedirectUri + ); + + public record PostTokenResponse( + string Token, + string[] Scopes, + Instant Expires + ); +} diff --git a/Foxchat.Identity/Database/Models/Application.cs b/Foxchat.Identity/Database/Models/Application.cs index 1976725..f38950c 100644 --- a/Foxchat.Identity/Database/Models/Application.cs +++ b/Foxchat.Identity/Database/Models/Application.cs @@ -1,5 +1,8 @@ using System.Security.Cryptography; +using Foxchat.Core; +using Foxchat.Identity.Utils; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; namespace Foxchat.Identity.Database.Models; @@ -9,38 +12,47 @@ public class Application : BaseModel public required string ClientSecret { get; init; } public required string Name { get; init; } public required string[] Scopes { get; init; } + public required string[] RedirectUris { get; set; } - public static Application Create(string name, string[] scopes) + public static Application Create(string name, string[] scopes, string[] redirectUrls) { - var clientId = RandomNumberGenerator.GetHexString(16, true); + var clientId = RandomNumberGenerator.GetHexString(32, true); var clientSecretBytes = RandomNumberGenerator.GetBytes(48); var clientSecret = WebEncoders.Base64UrlEncode(clientSecretBytes); - if (!scopes.All(s => Scope.ValidScopes.Contains(s))) + if (scopes.Any(s => !OauthUtils.Scopes.Contains(s))) { throw new ArgumentException("Invalid scopes passed to Application.Create", nameof(scopes)); } + if (redirectUrls.Any(s => !OauthUtils.ValidateRedirectUri(s))) + { + throw new ArgumentException("Invalid redirect URLs passed to Application.Create", nameof(redirectUrls)); + } + return new Application { ClientId = clientId, ClientSecret = clientSecret, Name = name, Scopes = scopes, + RedirectUris = redirectUrls }; } } -public static class Scope +public static class ContextApplicationExtensions { - /// - /// OAuth scope for identifying a user and nothing else. - /// - public const string Identity = "identity"; - /// - /// OAuth scope for a full chat client. This grants *full access* to an account. - /// - public const string ChatClient = "chat_client"; + public static async Task GetApplicationAsync(this IdentityContext db, string clientId) + { + return await db.Applications.FirstOrDefaultAsync(a => a.ClientId == clientId) + ?? throw new ApiError.Unauthorized("Invalid client ID or client secret"); + } - public static readonly string[] ValidScopes = [Identity, ChatClient]; + public static async Task GetApplicationAsync(this IdentityContext db, string clientId, string clientSecret) + { + var app = await db.GetApplicationAsync(clientId); + if (app.ClientSecret != clientSecret) throw new ApiError.Unauthorized("Invalid client ID or client secret"); + return app; + } } diff --git a/Foxchat.Identity/Database/Models/Token.cs b/Foxchat.Identity/Database/Models/Token.cs index bf35136..1ecbfb3 100644 --- a/Foxchat.Identity/Database/Models/Token.cs +++ b/Foxchat.Identity/Database/Models/Token.cs @@ -10,8 +10,9 @@ public class Token : BaseModel public string[] Scopes { get; set; } = []; public Instant Expires { get; set; } - public Ulid AccountId { get; set; } - public Account Account { get; set; } = null!; + // Tokens can be granted directly to applications with `client_credentials` + public Ulid? AccountId { get; set; } + public Account? Account { get; set; } public Ulid ApplicationId { get; set; } public Application Application { get; set; } = null!; diff --git a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs index 71546fd..510a7c4 100644 --- a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs +++ b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs @@ -4,6 +4,13 @@ namespace Foxchat.Identity.Extensions; public static class WebApplicationExtensions { + public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) + { + return services + .AddScoped() + .AddScoped(); + } + public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) { return app diff --git a/Foxchat.Identity/Migrations/20240520141853_AddRedirectUris.Designer.cs b/Foxchat.Identity/Migrations/20240520141853_AddRedirectUris.Designer.cs new file mode 100644 index 0000000..f928b4a --- /dev/null +++ b/Foxchat.Identity/Migrations/20240520141853_AddRedirectUris.Designer.cs @@ -0,0 +1,323 @@ +// +using System; +using Foxchat.Identity.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Identity.Migrations +{ + [DbContext(typeof(IdentityContext))] + [Migration("20240520141853_AddRedirectUris")] + partial class AddRedirectUris + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AccountChatInstance", b => + { + b.Property("AccountsId") + .HasColumnType("uuid") + .HasColumnName("accounts_id"); + + b.Property("ChatInstancesId") + .HasColumnType("uuid") + .HasColumnName("chat_instances_id"); + + b.HasKey("AccountsId", "ChatInstancesId") + .HasName("pk_account_chat_instance"); + + b.HasIndex("ChatInstancesId") + .HasDatabaseName("ix_account_chat_instance_chat_instances_id"); + + b.ToTable("account_chat_instance", (string)null); + }); + + modelBuilder.Entity("Foxchat.Core.Database.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("private_key"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.HasKey("Id") + .HasName("pk_instance"); + + b.ToTable("instance", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Account", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_accounts_username"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Application", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RedirectUris") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("redirect_uris"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("pk_applications"); + + b.HasIndex("ClientId") + .IsUnique() + .HasDatabaseName("ix_applications_client_id"); + + b.ToTable("applications", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.ChatInstance", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("base_url"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("public_key"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_chat_instances"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_chat_instances_domain"); + + b.ToTable("chat_instances", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.GuildAccount", b => + { + b.Property("ChatInstanceId") + .HasColumnType("uuid") + .HasColumnName("chat_instance_id"); + + b.Property("GuildId") + .HasColumnType("text") + .HasColumnName("guild_id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.HasKey("ChatInstanceId", "GuildId", "AccountId") + .HasName("pk_guild_accounts"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_guild_accounts_account_id"); + + b.ToTable("guild_accounts", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Token", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("ApplicationId") + .HasColumnType("uuid") + .HasColumnName("application_id"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("pk_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_tokens_account_id"); + + b.HasIndex("ApplicationId") + .HasDatabaseName("ix_tokens_application_id"); + + b.ToTable("tokens", (string)null); + }); + + modelBuilder.Entity("AccountChatInstance", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", null) + .WithMany() + .HasForeignKey("AccountsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_chat_instance_accounts_accounts_id"); + + b.HasOne("Foxchat.Identity.Database.Models.ChatInstance", null) + .WithMany() + .HasForeignKey("ChatInstancesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_chat_instance_chat_instances_chat_instances_id"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.GuildAccount", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_accounts_accounts_account_id"); + + b.HasOne("Foxchat.Identity.Database.Models.ChatInstance", "ChatInstance") + .WithMany() + .HasForeignKey("ChatInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_accounts_chat_instances_chat_instance_id"); + + b.Navigation("Account"); + + b.Navigation("ChatInstance"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Token", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", "Account") + .WithMany("Tokens") + .HasForeignKey("AccountId") + .HasConstraintName("fk_tokens_accounts_account_id"); + + b.HasOne("Foxchat.Identity.Database.Models.Application", "Application") + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_applications_application_id"); + + b.Navigation("Account"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Account", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxchat.Identity/Migrations/20240520141853_AddRedirectUris.cs b/Foxchat.Identity/Migrations/20240520141853_AddRedirectUris.cs new file mode 100644 index 0000000..5b31dde --- /dev/null +++ b/Foxchat.Identity/Migrations/20240520141853_AddRedirectUris.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Foxchat.Identity.Migrations +{ + /// + public partial class AddRedirectUris : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_tokens_accounts_account_id", + table: "tokens"); + + migrationBuilder.AlterColumn( + name: "account_id", + table: "tokens", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddColumn( + name: "redirect_uris", + table: "applications", + type: "text[]", + nullable: false, + defaultValue: new string[0]); + + migrationBuilder.AddForeignKey( + name: "fk_tokens_accounts_account_id", + table: "tokens", + column: "account_id", + principalTable: "accounts", + principalColumn: "id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_tokens_accounts_account_id", + table: "tokens"); + + migrationBuilder.DropColumn( + name: "redirect_uris", + table: "applications"); + + migrationBuilder.AlterColumn( + name: "account_id", + table: "tokens", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "fk_tokens_accounts_account_id", + table: "tokens", + column: "account_id", + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/Foxchat.Identity/Migrations/IdentityContextModelSnapshot.cs b/Foxchat.Identity/Migrations/IdentityContextModelSnapshot.cs index 8467785..d775446 100644 --- a/Foxchat.Identity/Migrations/IdentityContextModelSnapshot.cs +++ b/Foxchat.Identity/Migrations/IdentityContextModelSnapshot.cs @@ -131,6 +131,11 @@ namespace Foxchat.Identity.Migrations .HasColumnType("text") .HasColumnName("name"); + b.Property("RedirectUris") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("redirect_uris"); + b.Property("Scopes") .IsRequired() .HasColumnType("text[]") @@ -214,7 +219,7 @@ namespace Foxchat.Identity.Migrations .HasColumnType("uuid") .HasColumnName("id"); - b.Property("AccountId") + b.Property("AccountId") .HasColumnType("uuid") .HasColumnName("account_id"); @@ -291,8 +296,6 @@ namespace Foxchat.Identity.Migrations b.HasOne("Foxchat.Identity.Database.Models.Account", "Account") .WithMany("Tokens") .HasForeignKey("AccountId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() .HasConstraintName("fk_tokens_accounts_account_id"); b.HasOne("Foxchat.Identity.Database.Models.Application", "Application") diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs index dfd8316..ae53e12 100644 --- a/Foxchat.Identity/Program.cs +++ b/Foxchat.Identity/Program.cs @@ -26,19 +26,18 @@ builder.Services builder.Services .AddCoreServices() .AddScoped() + .AddCustomMiddleware() .AddEndpointsApiExplorer() .AddSwaggerGen(); var app = builder.Build(); app.UseSerilogRequestLogging(); -app.UseCustomMiddleware(); app.UseRouting(); app.UseSwagger(); app.UseSwaggerUI(); app.UseCors(); -app.UseAuthentication(); -app.UseAuthorization(); +app.UseCustomMiddleware(); app.MapControllers(); using (var scope = app.Services.CreateScope()) diff --git a/Foxchat.Identity/Services/ChatInstanceResolverService.cs b/Foxchat.Identity/Services/ChatInstanceResolverService.cs index cc9177f..686bfd0 100644 --- a/Foxchat.Identity/Services/ChatInstanceResolverService.cs +++ b/Foxchat.Identity/Services/ChatInstanceResolverService.cs @@ -1,27 +1,27 @@ using Foxchat.Core.Federation; -using Foxchat.Core.Models; +using Foxchat.Core.Models.Http; using Foxchat.Identity.Database; using Foxchat.Identity.Database.Models; using Microsoft.EntityFrameworkCore; namespace Foxchat.Identity.Services; -public class ChatInstanceResolverService(ILogger logger, RequestSigningService requestSigningService, IdentityContext context, InstanceConfig config) +public class ChatInstanceResolverService(ILogger logger, RequestSigningService requestSigningService, IdentityContext db, InstanceConfig config) { private readonly ILogger _logger = logger.ForContext(); public async Task ResolveChatInstanceAsync(string domain) { - var instance = await context.ChatInstances.Where(c => c.Domain == domain).FirstOrDefaultAsync(); + var instance = await db.ChatInstances.Where(c => c.Domain == domain).FirstOrDefaultAsync(); if (instance != null) return instance; _logger.Information("Unknown chat instance {Domain}, fetching its data", domain); - var resp = await requestSigningService.RequestAsync( + var resp = await requestSigningService.RequestAsync( HttpMethod.Post, domain, "/_fox/chat/hello", userId: null, - body: new HelloRequest(config.Domain) + body: new Hello.HelloRequest(config.Domain) ); instance = new ChatInstance @@ -31,8 +31,8 @@ public class ChatInstanceResolverService(ILogger logger, RequestSigningService r PublicKey = resp.PublicKey, Status = ChatInstance.InstanceStatus.Active, }; - await context.AddAsync(instance); - await context.SaveChangesAsync(); + await db.AddAsync(instance); + await db.SaveChangesAsync(); return instance; } diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs new file mode 100644 index 0000000..16256ce --- /dev/null +++ b/Foxchat.Identity/Utils/OauthUtils.cs @@ -0,0 +1,23 @@ +namespace Foxchat.Identity.Utils; + +public static class OauthUtils +{ + public static readonly string[] Scopes = ["identify", "chat_client"]; + + private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"]; + private const string OobUri = "urn:ietf:wg:oauth:2.0:oob"; + + public static bool ValidateRedirectUri(string uri) + { + if (uri == OobUri) return true; + try + { + var scheme = new Uri(uri).Scheme; + return !ForbiddenSchemes.Contains(scheme); + } + catch + { + return false; + } + } +}