add basic suppport for client_credentials oauth grant

This commit is contained in:
sam 2024-05-20 17:00:21 +02:00
parent 049f4a56de
commit 8995213d26
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
20 changed files with 627 additions and 58 deletions

View file

@ -30,12 +30,12 @@ public partial class RequestSigningService
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
var error = await resp.Content.ReadAsStringAsync(); var error = await resp.Content.ReadAsStringAsync();
throw new FoxchatError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject<Models.ApiError>(error)); throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject<Models.ApiError>(error));
} }
var bodyString = await resp.Content.ReadAsStringAsync(); var bodyString = await resp.Content.ReadAsStringAsync();
return DeserializeObject<T>(bodyString) return DeserializeObject<T>(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) private HttpRequestMessage BuildHttpRequest(HttpMethod method, string domain, string requestPath, string? userId = null, object? bodyData = null)

View file

@ -5,16 +5,15 @@ using Foxchat.Core.Database;
using Foxchat.Core.Utils; using Foxchat.Core.Utils;
using NodaTime; using NodaTime;
using NodaTime.Text; using NodaTime.Text;
using Serilog;
namespace Foxchat.Core.Federation; 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<RequestSigningService>(); private readonly ILogger _logger = logger.ForContext<RequestSigningService>();
private readonly IClock _clock = clock; private readonly IClock _clock = clock;
private readonly CoreConfig _config = config; 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(); private readonly HttpClient _httpClient = new();
public string GenerateSignature(SignatureData data) public string GenerateSignature(SignatureData data)
@ -41,11 +40,11 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba
var time = ParseTime(dateHeader); var time = ParseTime(dateHeader);
if ((now + Duration.FromMinutes(1)) < time) 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) 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)); 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)) 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); var deformatter = new RSAPKCS1SignatureDeformatter(rsa);

View file

@ -4,18 +4,8 @@ namespace Foxchat.Core;
public class FoxchatError(string message) : Exception(message) 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 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) 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 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);
} }

View file

@ -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);

View file

@ -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);
}

View file

@ -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);
}

View file

@ -63,7 +63,7 @@ public static class HttpContextExtensions
public static void SetToken(this HttpContext ctx, Token token) => ctx.Items.Add(Key, token); 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? GetAccount(this HttpContext ctx) => ctx.GetToken()?.Account;
public static Account GetAccountOrThrow(this HttpContext ctx) => 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) public static Token? GetToken(this HttpContext ctx)
{ {

View file

@ -1,4 +1,4 @@
using Foxchat.Core.Models; using Foxchat.Core.Models.Http;
using Foxchat.Identity.Database; using Foxchat.Identity.Database;
using Foxchat.Identity.Services; using Foxchat.Identity.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -7,15 +7,15 @@ namespace Foxchat.Identity.Controllers;
[ApiController] [ApiController]
[Route("/_fox/ident/node")] [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"; public const string SOFTWARE_NAME = "Foxchat.NET.Identity";
[HttpGet] [HttpGet]
public async Task<IActionResult> GetNode() public async Task<IActionResult> GetNode()
{ {
var instance = await context.GetInstanceAsync(); var instance = await db.GetInstanceAsync();
return Ok(new NodeInfo(SOFTWARE_NAME, instance.PublicKey)); return Ok(new Hello.NodeInfo(SOFTWARE_NAME, instance.PublicKey));
} }
[HttpGet("{domain}")] [HttpGet("{domain}")]

View file

@ -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<IActionResult> 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
));
}
}

View file

@ -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<IActionResult> 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<IActionResult> 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
);
}

View file

@ -1,5 +1,8 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using Foxchat.Core;
using Foxchat.Identity.Utils;
using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
namespace Foxchat.Identity.Database.Models; namespace Foxchat.Identity.Database.Models;
@ -9,38 +12,47 @@ public class Application : BaseModel
public required string ClientSecret { get; init; } public required string ClientSecret { get; init; }
public required string Name { get; init; } public required string Name { get; init; }
public required string[] Scopes { 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 clientSecretBytes = RandomNumberGenerator.GetBytes(48);
var clientSecret = WebEncoders.Base64UrlEncode(clientSecretBytes); 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)); 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 return new Application
{ {
ClientId = clientId, ClientId = clientId,
ClientSecret = clientSecret, ClientSecret = clientSecret,
Name = name, Name = name,
Scopes = scopes, Scopes = scopes,
RedirectUris = redirectUrls
}; };
} }
} }
public static class Scope public static class ContextApplicationExtensions
{ {
/// <summary> public static async Task<Application> GetApplicationAsync(this IdentityContext db, string clientId)
/// OAuth scope for identifying a user and nothing else. {
/// </summary> return await db.Applications.FirstOrDefaultAsync(a => a.ClientId == clientId)
public const string Identity = "identity"; ?? throw new ApiError.Unauthorized("Invalid client ID or client secret");
/// <summary> }
/// OAuth scope for a full chat client. This grants *full access* to an account.
/// </summary> public static async Task<Application> GetApplicationAsync(this IdentityContext db, string clientId, string clientSecret)
public const string ChatClient = "chat_client"; {
var app = await db.GetApplicationAsync(clientId);
public static readonly string[] ValidScopes = [Identity, ChatClient]; if (app.ClientSecret != clientSecret) throw new ApiError.Unauthorized("Invalid client ID or client secret");
return app;
}
} }

View file

@ -10,8 +10,9 @@ public class Token : BaseModel
public string[] Scopes { get; set; } = []; public string[] Scopes { get; set; } = [];
public Instant Expires { get; set; } public Instant Expires { get; set; }
public Ulid AccountId { get; set; } // Tokens can be granted directly to applications with `client_credentials`
public Account Account { get; set; } = null!; public Ulid? AccountId { get; set; }
public Account? Account { get; set; }
public Ulid ApplicationId { get; set; } public Ulid ApplicationId { get; set; }
public Application Application { get; set; } = null!; public Application Application { get; set; } = null!;

View file

@ -4,6 +4,13 @@ namespace Foxchat.Identity.Extensions;
public static class WebApplicationExtensions public static class WebApplicationExtensions
{ {
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services)
{
return services
.AddScoped<AuthenticationMiddleware>()
.AddScoped<AuthorizationMiddleware>();
}
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app)
{ {
return app return app

View file

@ -0,0 +1,323 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("AccountsId")
.HasColumnType("uuid")
.HasColumnName("accounts_id");
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("PrivateKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("private_key");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text")
.HasColumnName("email");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("BaseUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("base_url");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<string>("PublicKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("public_key");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<int>("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<Guid>("ChatInstanceId")
.HasColumnType("uuid")
.HasColumnName("chat_instance_id");
b.Property<string>("GuildId")
.HasColumnType("text")
.HasColumnName("guild_id");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Guid>("ApplicationId")
.HasColumnType("uuid")
.HasColumnName("application_id");
b.Property<Instant>("Expires")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<string[]>("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
}
}
}

View file

@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxchat.Identity.Migrations
{
/// <inheritdoc />
public partial class AddRedirectUris : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_tokens_accounts_account_id",
table: "tokens");
migrationBuilder.AlterColumn<Guid>(
name: "account_id",
table: "tokens",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string[]>(
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");
}
/// <inheritdoc />
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<Guid>(
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);
}
}
}

View file

@ -131,6 +131,11 @@ namespace Foxchat.Identity.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("Scopes") b.Property<string[]>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
@ -214,7 +219,7 @@ namespace Foxchat.Identity.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<Guid>("AccountId") b.Property<Guid?>("AccountId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("account_id"); .HasColumnName("account_id");
@ -291,8 +296,6 @@ namespace Foxchat.Identity.Migrations
b.HasOne("Foxchat.Identity.Database.Models.Account", "Account") b.HasOne("Foxchat.Identity.Database.Models.Account", "Account")
.WithMany("Tokens") .WithMany("Tokens")
.HasForeignKey("AccountId") .HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_accounts_account_id"); .HasConstraintName("fk_tokens_accounts_account_id");
b.HasOne("Foxchat.Identity.Database.Models.Application", "Application") b.HasOne("Foxchat.Identity.Database.Models.Application", "Application")

View file

@ -26,19 +26,18 @@ builder.Services
builder.Services builder.Services
.AddCoreServices<IdentityContext>() .AddCoreServices<IdentityContext>()
.AddScoped<ChatInstanceResolverService>() .AddScoped<ChatInstanceResolverService>()
.AddCustomMiddleware()
.AddEndpointsApiExplorer() .AddEndpointsApiExplorer()
.AddSwaggerGen(); .AddSwaggerGen();
var app = builder.Build(); var app = builder.Build();
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.UseCustomMiddleware();
app.UseRouting(); app.UseRouting();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseCors(); app.UseCors();
app.UseAuthentication(); app.UseCustomMiddleware();
app.UseAuthorization();
app.MapControllers(); app.MapControllers();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())

View file

@ -1,27 +1,27 @@
using Foxchat.Core.Federation; using Foxchat.Core.Federation;
using Foxchat.Core.Models; using Foxchat.Core.Models.Http;
using Foxchat.Identity.Database; using Foxchat.Identity.Database;
using Foxchat.Identity.Database.Models; using Foxchat.Identity.Database.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Foxchat.Identity.Services; 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<ChatInstanceResolverService>(); private readonly ILogger _logger = logger.ForContext<ChatInstanceResolverService>();
public async Task<ChatInstance> ResolveChatInstanceAsync(string domain) public async Task<ChatInstance> 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; if (instance != null) return instance;
_logger.Information("Unknown chat instance {Domain}, fetching its data", domain); _logger.Information("Unknown chat instance {Domain}, fetching its data", domain);
var resp = await requestSigningService.RequestAsync<HelloResponse>( var resp = await requestSigningService.RequestAsync<Hello.HelloResponse>(
HttpMethod.Post, HttpMethod.Post,
domain, "/_fox/chat/hello", domain, "/_fox/chat/hello",
userId: null, userId: null,
body: new HelloRequest(config.Domain) body: new Hello.HelloRequest(config.Domain)
); );
instance = new ChatInstance instance = new ChatInstance
@ -31,8 +31,8 @@ public class ChatInstanceResolverService(ILogger logger, RequestSigningService r
PublicKey = resp.PublicKey, PublicKey = resp.PublicKey,
Status = ChatInstance.InstanceStatus.Active, Status = ChatInstance.InstanceStatus.Active,
}; };
await context.AddAsync(instance); await db.AddAsync(instance);
await context.SaveChangesAsync(); await db.SaveChangesAsync();
return instance; return instance;
} }

View file

@ -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;
}
}
}