add basic suppport for client_credentials oauth grant
This commit is contained in:
parent
049f4a56de
commit
8995213d26
20 changed files with 627 additions and 58 deletions
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
8
Foxchat.Core/Models/Http/Apps.cs
Normal file
8
Foxchat.Core/Models/Http/Apps.cs
Normal 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);
|
||||
}
|
9
Foxchat.Core/Models/Http/Hello.cs
Normal file
9
Foxchat.Core/Models/Http/Hello.cs
Normal 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);
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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}")]
|
||||
|
|
45
Foxchat.Identity/Controllers/Oauth/AppsController.cs
Normal file
45
Foxchat.Identity/Controllers/Oauth/AppsController.cs
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
73
Foxchat.Identity/Controllers/Oauth/TokenController.cs
Normal file
73
Foxchat.Identity/Controllers/Oauth/TokenController.cs
Normal 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
|
||||
);
|
||||
}
|
|
@ -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 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 readonly string[] ValidScopes = [Identity, ChatClient];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!;
|
||||
|
|
|
@ -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
|
||||
|
|
323
Foxchat.Identity/Migrations/20240520141853_AddRedirectUris.Designer.cs
generated
Normal file
323
Foxchat.Identity/Migrations/20240520141853_AddRedirectUris.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
23
Foxchat.Identity/Utils/OauthUtils.cs
Normal file
23
Foxchat.Identity/Utils/OauthUtils.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue