-
Notifications
You must be signed in to change notification settings - Fork 26
fix: persist MCP OAuth signing/encryption keys in the database #347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)); | ||
| } | ||
| } |
| 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"; | ||
| 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); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Код с загрузкой значения из базы по |
||
| rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(stored.PrivateKey), out _); | ||
| } | ||
|
|
||
| return rsa; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Возможно, есть смысл использовать одинаковый подход в обоих случаях - и в проде, и во время разработки? |
||
| { | ||
| services.AddSingleton<OAuthKeyManager>(); | ||
| services.AddSingleton<IConfigureOptions<OpenIddictServerOptions>, ConfigureOpenIddictServerKeys>(); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Думаю что эти штуки можно было бы сделать enum'ом - чуть более строго типизировано, к тому же тогда PK в таблице был бы числовым. Но не принципиально.