diff --git a/src/Bonsai/Areas/Mcp/Logic/Auth/ConfigureOpenIddictServerKeys.cs b/src/Bonsai/Areas/Mcp/Logic/Auth/ConfigureOpenIddictServerKeys.cs new file mode 100644 index 0000000..2677c33 --- /dev/null +++ b/src/Bonsai/Areas/Mcp/Logic/Auth/ConfigureOpenIddictServerKeys.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Server; + +namespace Bonsai.Areas.Mcp.Logic.Auth; + +/// +/// Registers the persistent signing and encryption credentials on the OpenIddict server options. +/// This replaces the ephemeral keys that were previously regenerated on every startup, which invalidated +/// the tokens of already-authorized MCP agents. +/// +public class ConfigureOpenIddictServerKeys(OAuthKeyManager keyManager) : IConfigureOptions +{ + public void Configure(OpenIddictServerOptions options) + { + options.SigningCredentials.Add(new SigningCredentials( + keyManager.GetSigningKey(), + SecurityAlgorithms.RsaSha256)); + + options.EncryptionCredentials.Add(new EncryptingCredentials( + keyManager.GetEncryptionKey(), + SecurityAlgorithms.RsaOAEP, + SecurityAlgorithms.Aes256CbcHmacSha512)); + } +} diff --git a/src/Bonsai/Areas/Mcp/Logic/Auth/OAuthKeyManager.cs b/src/Bonsai/Areas/Mcp/Logic/Auth/OAuthKeyManager.cs new file mode 100644 index 0000000..d41370d --- /dev/null +++ b/src/Bonsai/Areas/Mcp/Logic/Auth/OAuthKeyManager.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using Bonsai.Data; +using Bonsai.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace Bonsai.Areas.Mcp.Logic.Auth; + +/// +/// Loads (or generates and persists) the RSA keys used by the OAuth server to sign and encrypt MCP tokens. +/// The keys are stored in the database so that already-authorized agents keep their access after a restart +/// instead of being forced to re-authorize. +/// +public class OAuthKeyManager(IServiceScopeFactory scopeFactory) +{ + private const string SigningPurpose = "Signing"; + private const string EncryptionPurpose = "Encryption"; + + private readonly Lock _lock = new(); + private RsaSecurityKey _signingKey; + private RsaSecurityKey _encryptionKey; + + /// + /// Returns the persistent signing key, generating and storing it on first use. + /// + public RsaSecurityKey GetSigningKey() + { + EnsureLoaded(); + return _signingKey; + } + + /// + /// Returns the persistent encryption key, generating and storing it on first use. + /// + public RsaSecurityKey GetEncryptionKey() + { + EnsureLoaded(); + return _encryptionKey; + } + + /// + /// Loads both keys from the database exactly once per process. + /// + private void EnsureLoaded() + { + if (_signingKey != null && _encryptionKey != null) + return; + + lock (_lock) + { + if (_signingKey != null && _encryptionKey != null) + return; + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + _signingKey = new RsaSecurityKey(LoadOrCreateKey(db, SigningPurpose)); + _encryptionKey = new RsaSecurityKey(LoadOrCreateKey(db, EncryptionPurpose)); + } + } + + /// + /// Reads the key of the specified purpose from the database, or generates and stores a new one. + /// + private static RSA LoadOrCreateKey(AppDbContext db, string purpose) + { + var rsa = RSA.Create(2048); + + var existing = db.OAuthKeys.FirstOrDefault(x => x.Purpose == purpose); + if (existing != null) + { + rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(existing.PrivateKey), out _); + return rsa; + } + + db.OAuthKeys.Add(new OAuthKey + { + Purpose = purpose, + PrivateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey()), + CreatedAt = DateTimeOffset.UtcNow + }); + + try + { + db.SaveChanges(); + } + catch (DbUpdateException) + { + // Another instance generated the key concurrently: discard ours and reuse the stored one. + db.ChangeTracker.Clear(); + var stored = db.OAuthKeys.First(x => x.Purpose == purpose); + rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(stored.PrivateKey), out _); + } + + return rsa; + } +} diff --git a/src/Bonsai/Code/Config/Startup.Mcp.cs b/src/Bonsai/Code/Config/Startup.Mcp.cs index fb3386e..746ac29 100644 --- a/src/Bonsai/Code/Config/Startup.Mcp.cs +++ b/src/Bonsai/Code/Config/Startup.Mcp.cs @@ -5,6 +5,7 @@ using Bonsai.Areas.Mcp.Logic.Services; using Bonsai.Data; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using OpenIddict.Abstractions; using OpenIddict.Server; @@ -83,19 +84,16 @@ private void ConfigureOpenIddict(IServiceCollection services) "mcp" // Custom scope for MCP access ); - // Use development encryption/signing keys in development - // In production, you should use proper certificates + // In development, use the framework's development certificates, which are persisted + // in the current user's profile and therefore survive restarts. + // In production, persistent RSA keys are loaded from (or generated into) the database + // by ConfigureOpenIddictServerKeys, so that tokens issued to already-authorized MCP + // agents remain valid across restarts instead of being invalidated by a fresh key. if (Environment.EnvironmentName == "Development") { options.AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); } - else - { - // For production, use ephemeral keys (you should configure proper certificates) - options.AddEphemeralEncryptionKey() - .AddEphemeralSigningKey(); - } // Disable access token encryption for easier debugging // MCP clients expect plain JWT tokens @@ -138,6 +136,14 @@ private void ConfigureOpenIddict(IServiceCollection services) // Register the ASP.NET Core host options.UseAspNetCore(); }); + + // In production, supply the server's signing/encryption credentials from persistent keys + // stored in the database (development relies on the framework's development certificates). + if (Environment.EnvironmentName != "Development") + { + services.AddSingleton(); + services.AddSingleton, ConfigureOpenIddictServerKeys>(); + } } /// diff --git a/src/Bonsai/Data/AppDbContext.cs b/src/Bonsai/Data/AppDbContext.cs index 2ad9782..4517f62 100644 --- a/src/Bonsai/Data/AppDbContext.cs +++ b/src/Bonsai/Data/AppDbContext.cs @@ -21,6 +21,7 @@ public AppDbContext(DbContextOptions options) } public virtual DbSet DynamicConfig => Set(); + public virtual DbSet OAuthKeys => Set(); public virtual DbSet Changes => Set(); public virtual DbSet ChangeEvents => Set(); public virtual DbSet LivingBeingOverviews => Set(); diff --git a/src/Bonsai/Data/Migrations/20260714183147_OAuthKeys.Designer.cs b/src/Bonsai/Data/Migrations/20260714183147_OAuthKeys.Designer.cs new file mode 100644 index 0000000..f6390ad --- /dev/null +++ b/src/Bonsai/Data/Migrations/20260714183147_OAuthKeys.Designer.cs @@ -0,0 +1,1324 @@ +// +using System; +using Bonsai.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bonsai.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260714183147_OAuthKeys")] + partial class OAuthKeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bonsai.Data.Models.AppUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AuthType") + .HasColumnType("integer"); + + b.Property("Birthday") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsValidated") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MiddleName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("PageId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Bonsai.Data.Models.ChangeEventGroup", b => + { + b.Property("GroupKey") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Ids") + .HasColumnType("text"); + + b.HasKey("GroupKey"); + + b.ToTable((string)null); + + b.ToView("ChangesGrouped", (string)null); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Changeset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppUserId") + .HasColumnType("text"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChangeType") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("EditedMediaId") + .HasColumnType("uuid"); + + b.Property("EditedPageId") + .HasColumnType("uuid"); + + b.Property("EditedRelationId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAIGenerated") + .HasColumnType("boolean"); + + b.Property("RevertedChangesetId") + .HasColumnType("uuid"); + + b.Property("UpdatedState") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("AuthorId"); + + b.HasIndex("EditedMediaId"); + + b.HasIndex("EditedPageId"); + + b.HasIndex("EditedRelationId"); + + b.ToTable("Changes"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.DynamicConfigWrapper", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DynamicConfig"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.JobState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arguments") + .HasColumnType("text"); + + b.Property("ArgumentsType") + .HasColumnType("text"); + + b.Property("FinishDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("Type") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("JobStates"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.LivingBeingOverview", b => + { + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("BirthDate") + .HasColumnType("text"); + + b.Property("DeathDate") + .HasColumnType("text"); + + b.Property("Gender") + .HasColumnType("boolean"); + + b.Property("IsDead") + .HasColumnType("boolean"); + + b.Property("MaidenName") + .HasColumnType("text"); + + b.Property("ShortName") + .HasColumnType("text"); + + b.HasKey("PageId"); + + b.ToTable("LivingBeingOverviews"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsProcessed") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("MimeType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NormalizedTitle") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UploadDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UploaderId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("UploaderId"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.MediaTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Coordinates") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MediaId") + .HasColumnType("uuid"); + + b.Property("ObjectId") + .HasColumnType("uuid"); + + b.Property("ObjectTitle") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MediaId"); + + b.HasIndex("ObjectId"); + + b.ToTable("MediaTags"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.OAuthKey", b => + { + b.Property("Purpose") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Purpose"); + + b.ToTable("OAuthKeys"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Facts") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastUpdateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MainPhotoId") + .HasColumnType("uuid"); + + b.Property("NormalizedTitle") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TreeLayoutId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("MainPhotoId"); + + b.HasIndex("TreeLayoutId"); + + b.ToTable("Pages"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.PageAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NormalizedTitle") + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PageId"); + + b.ToTable("PageAliases"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.PageDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.HasIndex("UserId"); + + b.ToTable("PageDrafts"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.PageReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DestinationId") + .HasColumnType("uuid"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DestinationId"); + + b.HasIndex("SourceId"); + + b.ToTable("PageReferences"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.PageScored", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CompletenessScore") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HasAnimalName") + .HasColumnType("boolean"); + + b.Property("HasAnimalSpecies") + .HasColumnType("boolean"); + + b.Property("HasBirthPlace") + .HasColumnType("boolean"); + + b.Property("HasBirthday") + .HasColumnType("boolean"); + + b.Property("HasEventDate") + .HasColumnType("boolean"); + + b.Property("HasGender") + .HasColumnType("boolean"); + + b.Property("HasHumanName") + .HasColumnType("boolean"); + + b.Property("HasLocationAddress") + .HasColumnType("boolean"); + + b.Property("HasPhoto") + .HasColumnType("boolean"); + + b.Property("HasRelations") + .HasColumnType("boolean"); + + b.Property("HasText") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastUpdateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MainPhotoId") + .HasColumnType("uuid"); + + b.Property("NormalizedTitle") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MainPhotoId"); + + b.ToTable((string)null); + + b.ToView("PagesScored", (string)null); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Relation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DestinationId") + .HasColumnType("uuid"); + + b.Property("Duration") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("IsComplementary") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DestinationId"); + + b.HasIndex("EventId"); + + b.HasIndex("IsComplementary"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("SourceId"); + + b.ToTable("Relations"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.TreeLayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GenerationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("LayoutJson") + .HasColumnType("text"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("TreeLayouts"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityPasskeyData", b => + { + b.Property("AttestationObject") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ClientDataJson") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBackedUp") + .HasColumnType("boolean"); + + b.Property("IsBackupEligible") + .HasColumnType("boolean"); + + b.Property("IsUserVerified") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Transports") + .HasColumnType("text[]"); + + b.ToTable("IdentityPasskeyData"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Bonsai.Data.Models.AppUser", b => + { + b.HasOne("Bonsai.Data.Models.Page", "Page") + .WithMany() + .HasForeignKey("PageId"); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Changeset", b => + { + b.HasOne("Bonsai.Data.Models.AppUser", null) + .WithMany("Changes") + .HasForeignKey("AppUserId"); + + b.HasOne("Bonsai.Data.Models.AppUser", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bonsai.Data.Models.Media", "EditedMedia") + .WithMany() + .HasForeignKey("EditedMediaId"); + + b.HasOne("Bonsai.Data.Models.Page", "EditedPage") + .WithMany() + .HasForeignKey("EditedPageId"); + + b.HasOne("Bonsai.Data.Models.Relation", "EditedRelation") + .WithMany() + .HasForeignKey("EditedRelationId"); + + b.Navigation("Author"); + + b.Navigation("EditedMedia"); + + b.Navigation("EditedPage"); + + b.Navigation("EditedRelation"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.LivingBeingOverview", b => + { + b.HasOne("Bonsai.Data.Models.Page", null) + .WithOne("LivingBeingOverview") + .HasForeignKey("Bonsai.Data.Models.LivingBeingOverview", "PageId"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Media", b => + { + b.HasOne("Bonsai.Data.Models.AppUser", "Uploader") + .WithMany() + .HasForeignKey("UploaderId"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.MediaTag", b => + { + b.HasOne("Bonsai.Data.Models.Media", "Media") + .WithMany("Tags") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bonsai.Data.Models.Page", "Object") + .WithMany("MediaTags") + .HasForeignKey("ObjectId"); + + b.Navigation("Media"); + + b.Navigation("Object"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Page", b => + { + b.HasOne("Bonsai.Data.Models.Media", "MainPhoto") + .WithMany() + .HasForeignKey("MainPhotoId"); + + b.HasOne("Bonsai.Data.Models.TreeLayout", "TreeLayout") + .WithMany() + .HasForeignKey("TreeLayoutId"); + + b.Navigation("MainPhoto"); + + b.Navigation("TreeLayout"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.PageAlias", b => + { + b.HasOne("Bonsai.Data.Models.Page", "Page") + .WithMany("Aliases") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.PageDraft", b => + { + b.HasOne("Bonsai.Data.Models.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.PageReference", b => + { + b.HasOne("Bonsai.Data.Models.Page", "Destination") + .WithMany("References") + .HasForeignKey("DestinationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bonsai.Data.Models.Page", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Destination"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.PageScored", b => + { + b.HasOne("Bonsai.Data.Models.Media", "MainPhoto") + .WithMany() + .HasForeignKey("MainPhotoId"); + + b.Navigation("MainPhoto"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Relation", b => + { + b.HasOne("Bonsai.Data.Models.Page", "Destination") + .WithMany() + .HasForeignKey("DestinationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bonsai.Data.Models.Page", "Event") + .WithMany() + .HasForeignKey("EventId"); + + b.HasOne("Bonsai.Data.Models.Page", "Source") + .WithMany("Relations") + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Destination"); + + b.Navigation("Event"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.TreeLayout", b => + { + b.HasOne("Bonsai.Data.Models.Page", "Page") + .WithMany() + .HasForeignKey("PageId"); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Bonsai.Data.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Bonsai.Data.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bonsai.Data.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Bonsai.Data.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.AppUser", b => + { + b.Navigation("Changes"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Media", b => + { + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Bonsai.Data.Models.Page", b => + { + b.Navigation("Aliases"); + + b.Navigation("LivingBeingOverview"); + + b.Navigation("MediaTags"); + + b.Navigation("References"); + + b.Navigation("Relations"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bonsai/Data/Migrations/20260714183147_OAuthKeys.cs b/src/Bonsai/Data/Migrations/20260714183147_OAuthKeys.cs new file mode 100644 index 0000000..29c3a7a --- /dev/null +++ b/src/Bonsai/Data/Migrations/20260714183147_OAuthKeys.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bonsai.Data.Migrations +{ + /// + public partial class OAuthKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OAuthKeys", + columns: table => new + { + Purpose = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + PrivateKey = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OAuthKeys", x => x.Purpose); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OAuthKeys"); + } + } +} diff --git a/src/Bonsai/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Bonsai/Data/Migrations/AppDbContextModelSnapshot.cs index fa8321f..a928f88 100644 --- a/src/Bonsai/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Bonsai/Data/Migrations/AppDbContextModelSnapshot.cs @@ -352,6 +352,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("MediaTags"); }); + modelBuilder.Entity("Bonsai.Data.Models.OAuthKey", b => + { + b.Property("Purpose") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Purpose"); + + b.ToTable("OAuthKeys"); + }); + modelBuilder.Entity("Bonsai.Data.Models.Page", b => { b.Property("Id") diff --git a/src/Bonsai/Data/Models/OAuthKey.cs b/src/Bonsai/Data/Models/OAuthKey.cs new file mode 100644 index 0000000..0fcae7e --- /dev/null +++ b/src/Bonsai/Data/Models/OAuthKey.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Bonsai.Data.Models; + +/// +/// A cryptographic key used by the OAuth server (OpenIddict) to sign and encrypt MCP access tokens. +/// Persisted in the database so that tokens issued to authorized agents remain valid across application +/// restarts (otherwise a new key would be generated on every startup and force agents to re-authorize). +/// +public class OAuthKey +{ + /// + /// Purpose of the key: Signing or Encryption. + /// + [Key] + [StringLength(50)] + public string Purpose { get; set; } + + /// + /// Base64-encoded PKCS#8 RSA private key. + /// + [Required] + public string PrivateKey { get; set; } + + /// + /// Timestamp when the key was generated. + /// + public DateTimeOffset CreatedAt { get; set; } +}