From a06e8f723b5c81945d8f226e8d77d95312cb8d85 Mon Sep 17 00:00:00 2001 From: Rory& Date: Tue, 3 Feb 2026 03:10:10 +0100 Subject: [PATCH] Abstract out C# auth --- ...r.Interop.Authentication.AspNetCore.csproj | 17 +++ .../SpacebarAspNetAuthenticationService.cs | 26 ++++ .../Spacebar.Interop.Authentication.csproj | 20 +++ .../SpacebarAuthenticationConfiguration.cs | 17 +++ .../SpacebarAuthenticationService.cs | 49 +++++++ .../Spacebar.Interop.Authentication/deps.json | 132 ++++++++++++++++++ 6 files changed, 261 insertions(+) create mode 100644 extra/admin-api/Interop/Spacebar.Interop.Authentication.AspNetCore/Spacebar.Interop.Authentication.AspNetCore.csproj create mode 100644 extra/admin-api/Interop/Spacebar.Interop.Authentication.AspNetCore/SpacebarAspNetAuthenticationService.cs create mode 100644 extra/admin-api/Interop/Spacebar.Interop.Authentication/Spacebar.Interop.Authentication.csproj create mode 100644 extra/admin-api/Interop/Spacebar.Interop.Authentication/SpacebarAuthenticationConfiguration.cs create mode 100644 extra/admin-api/Interop/Spacebar.Interop.Authentication/SpacebarAuthenticationService.cs create mode 100644 extra/admin-api/Interop/Spacebar.Interop.Authentication/deps.json diff --git a/extra/admin-api/Interop/Spacebar.Interop.Authentication.AspNetCore/Spacebar.Interop.Authentication.AspNetCore.csproj b/extra/admin-api/Interop/Spacebar.Interop.Authentication.AspNetCore/Spacebar.Interop.Authentication.AspNetCore.csproj new file mode 100644 index 000000000..971e4bf34 --- /dev/null +++ b/extra/admin-api/Interop/Spacebar.Interop.Authentication.AspNetCore/Spacebar.Interop.Authentication.AspNetCore.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/extra/admin-api/Interop/Spacebar.Interop.Authentication.AspNetCore/SpacebarAspNetAuthenticationService.cs b/extra/admin-api/Interop/Spacebar.Interop.Authentication.AspNetCore/SpacebarAspNetAuthenticationService.cs new file mode 100644 index 000000000..07abb9fed --- /dev/null +++ b/extra/admin-api/Interop/Spacebar.Interop.Authentication.AspNetCore/SpacebarAspNetAuthenticationService.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.Tokens; +using Spacebar.Models.Db.Models; + +namespace Spacebar.Interop.Authentication.AspNetCore; + +public class SpacebarAspNetAuthenticationService(SpacebarAuthenticationService authService) { + public string GetTokenAsync(HttpRequest request) { + if (!request.Headers.ContainsKey("Authorization")) { + Console.WriteLine(string.Join(", ", request.Headers.Keys)); + throw new UnauthorizedAccessException(); + } + + return request.Headers["Authorization"].ToString().Split(' ').Last(); + } + + public async Task ValidateTokenAsync(HttpRequest request) { + var token = GetTokenAsync(request); + return await authService.ValidateTokenAsync(token); + } + + public async Task GetCurrentUserAsync(HttpRequest request) { + var token = GetTokenAsync(request); + return await authService.GetCurrentUserAsync(token); + } +} \ No newline at end of file diff --git a/extra/admin-api/Interop/Spacebar.Interop.Authentication/Spacebar.Interop.Authentication.csproj b/extra/admin-api/Interop/Spacebar.Interop.Authentication/Spacebar.Interop.Authentication.csproj new file mode 100644 index 000000000..72ca5f8a7 --- /dev/null +++ b/extra/admin-api/Interop/Spacebar.Interop.Authentication/Spacebar.Interop.Authentication.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/extra/admin-api/Interop/Spacebar.Interop.Authentication/SpacebarAuthenticationConfiguration.cs b/extra/admin-api/Interop/Spacebar.Interop.Authentication/SpacebarAuthenticationConfiguration.cs new file mode 100644 index 000000000..f1186ce0e --- /dev/null +++ b/extra/admin-api/Interop/Spacebar.Interop.Authentication/SpacebarAuthenticationConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Configuration; + +namespace Spacebar.Interop.Authentication; + +public class SpacebarAuthenticationConfiguration { + public SpacebarAuthenticationConfiguration(IConfiguration configuration) { + configuration.GetRequiredSection("Spacebar").GetRequiredSection("Authentication").Bind(this); + } + + public required string PrivateKeyPath { get; set; } + public required string PublicKeyPath { get; set; } + + public string? OverrideUid { get; set; } + public bool DisableAuthentication { get; set; } = false; + public bool Enforce2FA { get; set; } = true; + public TimeSpan AuthCacheExpiry { get; set; } = TimeSpan.FromSeconds(30); +} \ No newline at end of file diff --git a/extra/admin-api/Interop/Spacebar.Interop.Authentication/SpacebarAuthenticationService.cs b/extra/admin-api/Interop/Spacebar.Interop.Authentication/SpacebarAuthenticationService.cs new file mode 100644 index 000000000..7b4ad1c8b --- /dev/null +++ b/extra/admin-api/Interop/Spacebar.Interop.Authentication/SpacebarAuthenticationService.cs @@ -0,0 +1,49 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using ArcaneLibs.Collections; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using Spacebar.Models.Db.Contexts; +using Spacebar.Models.Db.Models; + +namespace Spacebar.Interop.Authentication; + +public class SpacebarAuthenticationService(ILogger logger, SpacebarDbContext db, SpacebarAuthenticationConfiguration config) { + private static readonly ExpiringSemaphoreCache UserCache = new(); + + public async Task ValidateTokenAsync(string token) { + var handler = new JwtSecurityTokenHandler(); + var secretFile = await File.ReadAllTextAsync(config.PublicKeyPath); + var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + key.ImportFromPem(secretFile); + + var res = await handler.ValidateTokenAsync(token, new TokenValidationParameters { + IssuerSigningKey = new ECDsaSecurityKey(key), + ValidAlgorithms = ["ES512"], + LogValidationExceptions = true, + // These are required to be false for the token to be valid as they aren't provided by the token + ValidateIssuer = false, + ValidateLifetime = false, + ValidateAudience = false, + // TryAllIssuerSigningKeys = true + }); + + if ((!res.IsValid || res.Exception is not null) && !config.DisableAuthentication) { + logger.LogInformation("Invalid token"); + throw res.Exception ?? new UnauthorizedAccessException("Token was invalid"); + } + + return res; + } + + public async Task GetCurrentUserAsync(string token) { + var res = await ValidateTokenAsync(token); + return await UserCache.GetOrAdd(token, + async () => { + var uid = config.OverrideUid ?? res?.ClaimsIdentity.Claims.First(x => x.Type == "id").Value; + if (string.IsNullOrWhiteSpace(uid)) throw new InvalidOperationException("No user ID specified, is the access token valid?"); + return await db.Users.FindAsync(uid) ?? throw new InvalidOperationException(); + }, + config.AuthCacheExpiry); + } +} \ No newline at end of file diff --git a/extra/admin-api/Interop/Spacebar.Interop.Authentication/deps.json b/extra/admin-api/Interop/Spacebar.Interop.Authentication/deps.json new file mode 100644 index 000000000..1114983ef --- /dev/null +++ b/extra/admin-api/Interop/Spacebar.Interop.Authentication/deps.json @@ -0,0 +1,132 @@ +[ + { + "pname": "Microsoft.EntityFrameworkCore", + "version": "10.0.0", + "hash": "sha256-xfgrlxhtOkQwF5Q7j8gSm41URJiH8IuJ/T/Dh88++hE=" + }, + { + "pname": "Microsoft.EntityFrameworkCore.Abstractions", + "version": "10.0.0", + "hash": "sha256-UDgZbRQcGPaKsE53EH6bvJiv+Q4KSxAbnsVhTVFGG4Q=" + }, + { + "pname": "Microsoft.EntityFrameworkCore.Analyzers", + "version": "10.0.0", + "hash": "sha256-7Q0jYJO50cqGI+u6gLpootbB8GZvgsgtg0F9FZI1jig=" + }, + { + "pname": "Microsoft.EntityFrameworkCore.Relational", + "version": "10.0.0", + "hash": "sha256-vOP2CE5YA551BlpbOuIy6RuAiAEPEpCVS1cEE33/zN4=" + }, + { + "pname": "Microsoft.Extensions.Caching.Abstractions", + "version": "10.0.0", + "hash": "sha256-IciARPnXx/S6HZc4t2ED06UyUwfZI9LKSzwKSGdpsfI=" + }, + { + "pname": "Microsoft.Extensions.Caching.Memory", + "version": "10.0.0", + "hash": "sha256-AMgDSm1k6q0s17spGtyR5q8nAqUFDOxl/Fe38f9M+d4=" + }, + { + "pname": "Microsoft.Extensions.Configuration", + "version": "10.0.2", + "hash": "sha256-dBJAKDyp/sm+ZSMQfH0+4OH8Jnv1s20aHlWS6HNnH+c=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Abstractions", + "version": "10.0.0", + "hash": "sha256-GcgrnTAieCV7AVT13zyOjfwwL86e99iiO/MiMOxPGG0=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Abstractions", + "version": "10.0.2", + "hash": "sha256-P+0kaDGO+xB9KxF9eWHDJ4hzi05sUGM/uMNEX5NdBTE=" + }, + { + "pname": "Microsoft.Extensions.Configuration.Binder", + "version": "10.0.2", + "hash": "sha256-resI9gIxHh2cc+258/i+TjW8xxzKf4ZBTLIcWAMEYz0=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection", + "version": "10.0.0", + "hash": "sha256-LYm9hVlo/R9c2aAKHsDYJ5vY9U0+3Jvclme3ou3BtvQ=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection", + "version": "10.0.2", + "hash": "sha256-/9UWQRAI2eoocnJWWf1ktnAx/1Gt65c16fc0Xqr9+CQ=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection.Abstractions", + "version": "10.0.0", + "hash": "sha256-9iodXP39YqgxomnOPOxd/mzbG0JfOSXzFoNU3omT2Ps=" + }, + { + "pname": "Microsoft.Extensions.DependencyInjection.Abstractions", + "version": "10.0.2", + "hash": "sha256-UF9T13V5SQxJy2msfLmyovLmitZrjJayf8gHH+uK2eg=" + }, + { + "pname": "Microsoft.Extensions.Logging", + "version": "10.0.0", + "hash": "sha256-P+zPAadLL63k/GqK34/qChqQjY9aIRxZfxlB9lqsSrs=" + }, + { + "pname": "Microsoft.Extensions.Logging.Abstractions", + "version": "10.0.0", + "hash": "sha256-BnhgGZc01HwTSxogavq7Ueq4V7iMA3wPnbfRwQ4RhGk=" + }, + { + "pname": "Microsoft.Extensions.Options", + "version": "10.0.0", + "hash": "sha256-j5MOqZSKeUtxxzmZjzZMGy0vELHdvPraqwTQQQNVsYA=" + }, + { + "pname": "Microsoft.Extensions.Primitives", + "version": "10.0.0", + "hash": "sha256-Dup08KcptLjlnpN5t5//+p4n8FUTgRAq4n/w1s6us+I=" + }, + { + "pname": "Microsoft.Extensions.Primitives", + "version": "10.0.2", + "hash": "sha256-8Ccrjjv9cFVf9RyCc7GS/Byt8+DXdSNea0UX3A5BEdA=" + }, + { + "pname": "Microsoft.IdentityModel.Abstractions", + "version": "8.15.0", + "hash": "sha256-LKTvERNUTMCEF7xs377tCMwOMRki93OS4kh6Yv0uXJ4=" + }, + { + "pname": "Microsoft.IdentityModel.JsonWebTokens", + "version": "8.15.0", + "hash": "sha256-LwzKiGjcnORvmQ9tim6lomXpfVlPpd/fE8FKTFWKlpM=" + }, + { + "pname": "Microsoft.IdentityModel.Logging", + "version": "8.15.0", + "hash": "sha256-mMXwsjGcrrmHR1mG7BLTKg/30mX+m93QVX17/ynOOd4=" + }, + { + "pname": "Microsoft.IdentityModel.Tokens", + "version": "8.15.0", + "hash": "sha256-7Lo/TsvqgNCEMyFssO3Om233521Pqgb9K9lUeHc5HMk=" + }, + { + "pname": "Npgsql", + "version": "10.0.0", + "hash": "sha256-UVKz9dH/rVCCbMyFdqA31RYpht1XgDRLIqUy0Dp9ACQ=" + }, + { + "pname": "Npgsql.EntityFrameworkCore.PostgreSQL", + "version": "10.0.0", + "hash": "sha256-XIJxnTMektQVP1qtslEIGbmBGrIQsvjQjCMRTs9UIbg=" + }, + { + "pname": "System.IdentityModel.Tokens.Jwt", + "version": "8.15.0", + "hash": "sha256-5O0wbGp0gWnukK+0mWBjMnP1bZc6N0xuNcO2qmFiUX8=" + } +]