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