Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/Bonsai/Areas/Mcp/Logic/Auth/ConfigureOpenIddictServerKeys.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Server;

namespace Bonsai.Areas.Mcp.Logic.Auth;

/// <summary>
/// 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.
/// </summary>
public class ConfigureOpenIddictServerKeys(OAuthKeyManager keyManager) : IConfigureOptions<OpenIddictServerOptions>
{
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));
}
}
101 changes: 101 additions & 0 deletions src/Bonsai/Areas/Mcp/Logic/Auth/OAuthKeyManager.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class OAuthKeyManager(IServiceScopeFactory scopeFactory)
{
private const string SigningPurpose = "Signing";

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Думаю что эти штуки можно было бы сделать enum'ом - чуть более строго типизировано, к тому же тогда PK в таблице был бы числовым. Но не принципиально.

private const string EncryptionPurpose = "Encryption";

private readonly Lock _lock = new();
private RsaSecurityKey _signingKey;
private RsaSecurityKey _encryptionKey;

/// <summary>
/// Returns the persistent signing key, generating and storing it on first use.
/// </summary>
public RsaSecurityKey GetSigningKey()
{
EnsureLoaded();
return _signingKey;
}

/// <summary>
/// Returns the persistent encryption key, generating and storing it on first use.
/// </summary>
public RsaSecurityKey GetEncryptionKey()
{
EnsureLoaded();
return _encryptionKey;
}

/// <summary>
/// Loads both keys from the database exactly once per process.
/// </summary>
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<AppDbContext>();

_signingKey = new RsaSecurityKey(LoadOrCreateKey(db, SigningPurpose));
_encryptionKey = new RsaSecurityKey(LoadOrCreateKey(db, EncryptionPurpose));
}
}

/// <summary>
/// Reads the key of the specified purpose from the database, or generates and stores a new one.
/// </summary>
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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Код с загрузкой значения из базы по purpose, декодированием из base64 и загрузкой повторяется дважды - его можно вынести в локальную функцию

rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(stored.PrivateKey), out _);
}

return rsa;
}
}
22 changes: 14 additions & 8 deletions src/Bonsai/Code/Config/Startup.Mcp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Возможно, есть смысл использовать одинаковый подход в обоих случаях - и в проде, и во время разработки?

{
services.AddSingleton<OAuthKeyManager>();
services.AddSingleton<IConfigureOptions<OpenIddictServerOptions>, ConfigureOpenIddictServerKeys>();
}
}

/// <summary>
Expand Down
1 change: 1 addition & 0 deletions src/Bonsai/Data/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options)
}

public virtual DbSet<DynamicConfigWrapper> DynamicConfig => Set<DynamicConfigWrapper>();
public virtual DbSet<OAuthKey> OAuthKeys => Set<OAuthKey>();
public virtual DbSet<Changeset> Changes => Set<Changeset>();
public virtual DbSet<ChangeEventGroup> ChangeEvents => Set<ChangeEventGroup>();
public virtual DbSet<LivingBeingOverview> LivingBeingOverviews => Set<LivingBeingOverview>();
Expand Down
Loading