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)
{
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();
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)

View file

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

View file

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

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 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)
{

View file

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

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 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
{
/// <summary>
/// OAuth scope for identifying a user and nothing else.
/// </summary>
public const string Identity = "identity";
/// <summary>
/// OAuth scope for a full chat client. This grants *full access* to an account.
/// </summary>
public const string ChatClient = "chat_client";
public static readonly string[] ValidScopes = [Identity, ChatClient];
public static async Task<Application> 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 async Task<Application> 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;
}
}

View file

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

View file

@ -4,6 +4,13 @@ namespace Foxchat.Identity.Extensions;
public static class WebApplicationExtensions
{
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services)
{
return services
.AddScoped<AuthenticationMiddleware>()
.AddScoped<AuthorizationMiddleware>();
}
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder 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")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
@ -214,7 +219,7 @@ namespace Foxchat.Identity.Migrations
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
b.Property<Guid?>("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")

View file

@ -26,19 +26,18 @@ builder.Services
builder.Services
.AddCoreServices<IdentityContext>()
.AddScoped<ChatInstanceResolverService>()
.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())

View file

@ -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<ChatInstanceResolverService>();
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;
_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,
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;
}

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