diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index d26ca94c46..6d20b9ea26 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -1,6 +1,5 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; -using System.Security.Cryptography.X509Certificates; using System.Text; using LexBoxApi.Auth.Attributes; using LexBoxApi.Auth.Requirements; @@ -283,6 +282,9 @@ public static void AddOpenId(IServiceCollection services, IHostEnvironment envir }); options.SetAccessTokenLifetime(TimeSpan.FromHours(1)); + //note, if we want to change this, then we also need to change the certificate lifetime so we don't have tokens that last longer than the certificate + //basically this means that the renewBefore should be increased to the token lifetime + 1, just be careful we don't get to close to the + //certificate legnth. If the cert is only good for 90 days and we renew too soon then it won't actually be valid for very long. options.SetRefreshTokenLifetime(TimeSpan.FromDays(14)); options.AllowAuthorizationCodeFlow() .AllowRefreshTokenFlow(); @@ -294,23 +296,9 @@ public static void AddOpenId(IServiceCollection services, IHostEnvironment envir options.AddDevelopmentEncryptionCertificate(); options.AddDevelopmentSigningCertificate(); } - else - { - //see docs: https://documentation.openiddict.com/configuration/encryption-and-signing-credentials.html - //todo, handle certificate rotation, right now these certs will be replaced 15 days before they expire - //however we need to start signing with the new cert before the old one expires - //this means we need 2 certs for each use case, an old one for tokens that have not expired yet, and a new one to sign all new tokens - var encryptionCert = X509Certificate2.CreateFromPemFile( - "/oauth-certs/encryption/tls.crt", - "/oauth-certs/encryption/tls.key"); - options.AddEncryptionCertificate(encryptionCert); - - var signingCert = X509Certificate2.CreateFromPemFile( - "/oauth-certs/signing/tls.crt", - "/oauth-certs/signing/tls.key"); - - options.AddSigningCertificate(signingCert); - } + // Non-development PEMs: OauthPemOpenIddictServerConfigurer registers current + optional + // last-seen/previous slots. See docs: + // https://documentation.openiddict.com/configuration/encryption-and-signing-credentials.html var aspnetCoreBuilder = options.UseAspNetCore() .EnableAuthorizationEndpointPassthrough() @@ -329,6 +317,21 @@ public static void AddOpenId(IServiceCollection services, IHostEnvironment envir options.AddAudiences(Enum.GetValues().Where(a => a != LexboxAudience.Unknown).Select(a => a.ToString()).ToArray()); options.EnableAuthorizationEntryValidation(); }); + + if (!environment.IsDevelopment()) + { + services.AddSingleton, OauthPemOpenIddictServerConfigurer>(); + + // Content-hash poll invalidates server + validation options together so UseLocalServer() + // re-imports keys after PEM mount updates (k8s Secret ..data swaps). + services.AddSingleton(); + services.AddSingleton>( + sp => sp.GetRequiredService()); + services.AddSingleton>( + sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + } + //ensure that validation happens on startup, not on the first request which requires authentication services.AddOptions().ValidateOnStart(); services.AddOptions().ValidateOnStart(); diff --git a/backend/LexBoxApi/Auth/OauthPemCertificateLoader.cs b/backend/LexBoxApi/Auth/OauthPemCertificateLoader.cs new file mode 100644 index 0000000000..7f70b555d2 --- /dev/null +++ b/backend/LexBoxApi/Auth/OauthPemCertificateLoader.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography.X509Certificates; + +namespace LexBoxApi.Auth; + +/// +/// Loads distinct X.509 certificates from PEM directory slots (tls.crt + tls.key). +/// Missing directories or companion files are skipped so a single current mount still works. +/// +public static class OauthPemCertificateLoader +{ + public static IReadOnlyList LoadDistinctFromDirectories(IEnumerable directories) + { + ArgumentNullException.ThrowIfNull(directories); + + var certificates = new List(); + var seenThumbprints = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var directory in directories) + { + if (string.IsNullOrWhiteSpace(directory)) + continue; + + var certPath = System.IO.Path.Combine(directory, OauthPemCertificatePaths.CertFileName); + var keyPath = System.IO.Path.Combine(directory, OauthPemCertificatePaths.KeyFileName); + if (!File.Exists(certPath) || !File.Exists(keyPath)) + continue; + + var certificate = X509Certificate2.CreateFromPemFile(certPath, keyPath); + if (!seenThumbprints.Add(certificate.Thumbprint)) + { + certificate.Dispose(); + continue; + } + + certificates.Add(certificate); + } + + return certificates; + } +} diff --git a/backend/LexBoxApi/Auth/OauthPemCertificatePaths.cs b/backend/LexBoxApi/Auth/OauthPemCertificatePaths.cs new file mode 100644 index 0000000000..69f389b39f --- /dev/null +++ b/backend/LexBoxApi/Auth/OauthPemCertificatePaths.cs @@ -0,0 +1,25 @@ +namespace LexBoxApi.Auth; + +/// +/// Default mount paths for OAuth signing/encryption PEM pairs (k8s TLS secret layout). +/// Companion last-seen/previous slots are optional until the retainer CronJob creates them. +/// +public static class OauthPemCertificatePaths +{ + public const string CertFileName = "tls.crt"; + public const string KeyFileName = "tls.key"; + + public static readonly string[] SigningDirectories = + [ + "/oauth-certs/signing", + "/oauth-certs/signing-last-seen", + "/oauth-certs/signing-previous", + ]; + + public static readonly string[] EncryptionDirectories = + [ + "/oauth-certs/encryption", + "/oauth-certs/encryption-last-seen", + "/oauth-certs/encryption-previous", + ]; +} diff --git a/backend/LexBoxApi/Auth/OauthPemOpenIddictServerConfigurer.cs b/backend/LexBoxApi/Auth/OauthPemOpenIddictServerConfigurer.cs new file mode 100644 index 0000000000..aae1118b08 --- /dev/null +++ b/backend/LexBoxApi/Auth/OauthPemOpenIddictServerConfigurer.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Server; + +namespace LexBoxApi.Auth; + +/// +/// Registers all distinct OAuth signing/encryption PEMs into OpenIddict server options. +/// Loads inside so can recreate options on PEM change. +/// +public sealed class OauthPemOpenIddictServerConfigurer : IConfigureOptions +{ + private readonly IReadOnlyList _signingDirectories; + private readonly IReadOnlyList _encryptionDirectories; + + public OauthPemOpenIddictServerConfigurer() + : this(OauthPemCertificatePaths.SigningDirectories, OauthPemCertificatePaths.EncryptionDirectories) + { + } + + public OauthPemOpenIddictServerConfigurer( + IReadOnlyList signingDirectories, + IReadOnlyList encryptionDirectories) + { + _signingDirectories = signingDirectories; + _encryptionDirectories = encryptionDirectories; + } + + public void Configure(OpenIddictServerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + foreach (var certificate in OauthPemCertificateLoader.LoadDistinctFromDirectories(_signingDirectories)) + { + EnsurePrivateKey(certificate); + options.SigningCredentials.Add( + new SigningCredentials(new X509SecurityKey(certificate), SecurityAlgorithms.RsaSha256)); + } + + foreach (var certificate in OauthPemCertificateLoader.LoadDistinctFromDirectories(_encryptionDirectories)) + { + EnsurePrivateKey(certificate); + options.EncryptionCredentials.Add(new EncryptingCredentials( + new X509SecurityKey(certificate), + SecurityAlgorithms.RsaOAEP, + SecurityAlgorithms.Aes256CbcHmacSha512)); + } + } + + private static void EnsurePrivateKey(X509Certificate2 certificate) + { + if (!certificate.HasPrivateKey) + { + throw new InvalidOperationException( + $"OAuth certificate '{certificate.Thumbprint}' is missing a private key."); + } + } +} diff --git a/backend/LexBoxApi/Auth/OauthPemOptionsChangeTokenSource.cs b/backend/LexBoxApi/Auth/OauthPemOptionsChangeTokenSource.cs new file mode 100644 index 0000000000..afb9daae89 --- /dev/null +++ b/backend/LexBoxApi/Auth/OauthPemOptionsChangeTokenSource.cs @@ -0,0 +1,145 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using OpenIddict.Server; +using OpenIddict.Validation; + +namespace LexBoxApi.Auth; + +/// +/// Polls OAuth PEM mount paths for content changes and invalidates OpenIddict server +/// and validation options. Polling (content hash) is used instead of a naive +/// because Kubernetes Secret volume updates swap +/// the ..data symlink and often produce no usable watch events. +/// +public sealed class OauthPemOptionsChangeTokenSource : + BackgroundService, + IOptionsChangeTokenSource, + IOptionsChangeTokenSource +{ + public static readonly TimeSpan DefaultPollingInterval = TimeSpan.FromSeconds(30); + + private readonly IReadOnlyList _directories; + private readonly TimeSpan _pollingInterval; + private readonly ILogger? _logger; + private CancellationTokenSource _serverCts = new(); + private CancellationTokenSource _validationCts = new(); + private string _lastFingerprint; + + public OauthPemOptionsChangeTokenSource( + IReadOnlyList signingDirectories, + IReadOnlyList encryptionDirectories, + TimeSpan? pollingInterval = null, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(signingDirectories); + ArgumentNullException.ThrowIfNull(encryptionDirectories); + + _directories = signingDirectories.Concat(encryptionDirectories).ToArray(); + _pollingInterval = pollingInterval ?? DefaultPollingInterval; + _logger = logger; + _lastFingerprint = ComputeFingerprint(); + } + + public OauthPemOptionsChangeTokenSource(ILogger logger) + : this( + OauthPemCertificatePaths.SigningDirectories, + OauthPemCertificatePaths.EncryptionDirectories, + DefaultPollingInterval, + logger) + { + } + + string IOptionsChangeTokenSource.Name => Options.DefaultName; + string IOptionsChangeTokenSource.Name => Options.DefaultName; + + IChangeToken IOptionsChangeTokenSource.GetChangeToken() => + new CancellationChangeToken(_serverCts.Token); + + IChangeToken IOptionsChangeTokenSource.GetChangeToken() => + new CancellationChangeToken(_validationCts.Token); + + /// + /// Recomputes the PEM content fingerprint and, when it changed, signals options reload. + /// Exposed for tests and for an immediate check outside the poll loop. + /// + /// if a reload was signaled. + public bool CheckForChanges() + { + string fingerprint; + try + { + fingerprint = ComputeFingerprint(); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to fingerprint OAuth PEM mounts; will retry on next poll"); + return false; + } + + if (string.Equals(fingerprint, _lastFingerprint, StringComparison.Ordinal)) + return false; + + _logger?.LogInformation("OAuth PEM mount content changed; reloading OpenIddict credentials"); + _lastFingerprint = fingerprint; + Reload(); + return true; + } + + /// + /// Forces OpenIddict server and validation options to recreate on next access. + /// Server is invalidated before validation so UseLocalServer() re-import sees fresh credentials. + /// + public void Reload() + { + var previousServer = Interlocked.Exchange(ref _serverCts, new CancellationTokenSource()); + previousServer.Cancel(); + + var previousValidation = Interlocked.Exchange(ref _validationCts, new CancellationTokenSource()); + previousValidation.Cancel(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Establish baseline without signaling a reload on process start. + _lastFingerprint = ComputeFingerprint(); + + using var timer = new PeriodicTimer(_pollingInterval); + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + CheckForChanges(); + } + } + + private string ComputeFingerprint() + { + var orderedPaths = _directories + .Where(static d => !string.IsNullOrWhiteSpace(d)) + .SelectMany(static directory => new[] + { + System.IO.Path.Combine(directory, OauthPemCertificatePaths.CertFileName), + System.IO.Path.Combine(directory, OauthPemCertificatePaths.KeyFileName), + }) + .OrderBy(static p => p, StringComparer.Ordinal) + .ToArray(); + + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var path in orderedPaths) + { + hash.AppendData(Encoding.UTF8.GetBytes(path)); + hash.AppendData("\0"u8); + + if (!File.Exists(path)) + { + hash.AppendData("missing"u8); + continue; + } + + // Read resolved file bytes so k8s ..data symlink swaps are visible. + hash.AppendData(File.ReadAllBytes(path)); + } + + return Convert.ToHexString(hash.GetHashAndReset()); + } +} diff --git a/backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs b/backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs new file mode 100644 index 0000000000..b4600c9413 --- /dev/null +++ b/backend/Testing/LexBoxApi/Auth/OauthPemCertificateLoaderTests.cs @@ -0,0 +1,182 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using LexBoxApi.Auth; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Server; + +namespace Testing.LexBoxApi.Auth; + +public class OauthPemCertificateLoaderTests +{ + [Fact] + public void LoadDistinctFromDirectories_LoadsAllPresentSlots_AndSkipsMissing() + { + using var root = new TempPemRoot(); + var certA = CreateSelfSigned("CN=A"); + var certB = CreateSelfSigned("CN=B"); + + WritePemPair(root.Dir("signing"), certA); + WritePemPair(root.Dir("signing-previous"), certB); + // signing-last-seen intentionally absent + + var loaded = OauthPemCertificateLoader.LoadDistinctFromDirectories( + [ + root.Dir("signing"), + root.Dir("signing-last-seen"), + root.Dir("signing-previous"), + ]); + + loaded.Should().HaveCount(2); + loaded.Select(c => c.Thumbprint).Should().BeEquivalentTo([certA.Thumbprint, certB.Thumbprint]); + } + + [Fact] + public void LoadDistinctFromDirectories_CurrentOnly_MatchesTodayLayout() + { + using var root = new TempPemRoot(); + var current = CreateSelfSigned("CN=Current"); + WritePemPair(root.Dir("signing"), current); + + var loaded = OauthPemCertificateLoader.LoadDistinctFromDirectories( + [ + root.Dir("signing"), + root.Dir("signing-last-seen"), + root.Dir("signing-previous"), + ]); + + loaded.Should().ContainSingle() + .Which.Thumbprint.Should().Be(current.Thumbprint); + } + + [Fact] + public void LoadDistinctFromDirectories_DeduplicatesByThumbprint() + { + using var root = new TempPemRoot(); + var shared = CreateSelfSigned("CN=Shared"); + WritePemPair(root.Dir("signing"), shared); + WritePemPair(root.Dir("signing-last-seen"), shared); + WritePemPair(root.Dir("signing-previous"), shared); + + var loaded = OauthPemCertificateLoader.LoadDistinctFromDirectories( + [ + root.Dir("signing"), + root.Dir("signing-last-seen"), + root.Dir("signing-previous"), + ]); + + loaded.Should().ContainSingle() + .Which.Thumbprint.Should().Be(shared.Thumbprint); + } + + [Fact] + public void Configurer_RegistersDistinctSigningAndEncryptionCredentials() + { + using var root = new TempPemRoot(); + var signingCurrent = CreateSelfSigned("CN=Sign-Current"); + var signingPrevious = CreateSelfSigned("CN=Sign-Previous"); + var encryptionCurrent = CreateSelfSigned("CN=Enc-Current"); + var encryptionLastSeen = CreateSelfSigned("CN=Enc-LastSeen"); + + WritePemPair(root.Dir("signing"), signingCurrent); + WritePemPair(root.Dir("signing-previous"), signingPrevious); + WritePemPair(root.Dir("encryption"), encryptionCurrent); + WritePemPair(root.Dir("encryption-last-seen"), encryptionLastSeen); + + var configurer = new OauthPemOpenIddictServerConfigurer( + [ + root.Dir("signing"), + root.Dir("signing-last-seen"), + root.Dir("signing-previous"), + ], + [ + root.Dir("encryption"), + root.Dir("encryption-last-seen"), + root.Dir("encryption-previous"), + ]); + + var options = new OpenIddictServerOptions(); + configurer.Configure(options); + + options.SigningCredentials.Should().HaveCount(2); + options.SigningCredentials.Select(GetThumbprint).Should() + .BeEquivalentTo([signingCurrent.Thumbprint, signingPrevious.Thumbprint]); + + options.EncryptionCredentials.Should().HaveCount(2); + options.EncryptionCredentials.Select(GetThumbprint).Should() + .BeEquivalentTo([encryptionCurrent.Thumbprint, encryptionLastSeen.Thumbprint]); + } + + [Fact] + public void Configurer_CurrentOnly_RegistersSingleSigningAndEncryption() + { + using var root = new TempPemRoot(); + var signing = CreateSelfSigned("CN=Sign"); + var encryption = CreateSelfSigned("CN=Enc"); + WritePemPair(root.Dir("signing"), signing); + WritePemPair(root.Dir("encryption"), encryption); + + var configurer = new OauthPemOpenIddictServerConfigurer( + [ + root.Dir("signing"), + root.Dir("signing-last-seen"), + root.Dir("signing-previous"), + ], + [ + root.Dir("encryption"), + root.Dir("encryption-last-seen"), + root.Dir("encryption-previous"), + ]); + + var options = new OpenIddictServerOptions(); + configurer.Configure(options); + + options.SigningCredentials.Should().ContainSingle(); + GetThumbprint(options.SigningCredentials[0]).Should().Be(signing.Thumbprint); + options.EncryptionCredentials.Should().ContainSingle(); + GetThumbprint(options.EncryptionCredentials[0]).Should().Be(encryption.Thumbprint); + } + + private static string GetThumbprint(SigningCredentials credentials) => + ((X509SecurityKey)credentials.Key).Certificate.Thumbprint; + + private static string GetThumbprint(EncryptingCredentials credentials) => + ((X509SecurityKey)credentials.Key).Certificate.Thumbprint; + + private static X509Certificate2 CreateSelfSigned(string subject) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + } + + private static void WritePemPair(string directory, X509Certificate2 certificate) + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, OauthPemCertificatePaths.CertFileName), certificate.ExportCertificatePem()); + var key = certificate.GetRSAPrivateKey() + ?? throw new InvalidOperationException("Test certificate is missing an RSA private key."); + File.WriteAllText(Path.Combine(directory, OauthPemCertificatePaths.KeyFileName), key.ExportPkcs8PrivateKeyPem()); + } + + private sealed class TempPemRoot : IDisposable + { + private readonly string _root = Path.Combine(Path.GetTempPath(), "lexbox-oauth-pem-tests-" + Guid.NewGuid().ToString("N")); + + public TempPemRoot() => Directory.CreateDirectory(_root); + + public string Dir(string name) => Path.Combine(_root, name); + + public void Dispose() + { + try + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + catch + { + // Best-effort cleanup for temp test dirs. + } + } + } +} diff --git a/backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs b/backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs new file mode 100644 index 0000000000..13fd63cb31 --- /dev/null +++ b/backend/Testing/LexBoxApi/Auth/OauthPemCredentialHotReloadTests.cs @@ -0,0 +1,254 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using LexBoxApi.Auth; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Server; +using OpenIddict.Validation; + +namespace Testing.LexBoxApi.Auth; + +public class OauthPemCredentialHotReloadTests +{ + [Fact] + public void CheckForChanges_SignalsReload_WhenPemContentChanges() + { + using var root = new TempPemRoot(); + var certA = CreateSelfSigned("CN=A"); + WritePemPair(root.Dir("signing"), certA); + WritePemPair(root.Dir("encryption"), certA); + + using var source = new OauthPemOptionsChangeTokenSource( + [root.Dir("signing")], + [root.Dir("encryption")], + pollingInterval: TimeSpan.FromHours(1)); + + using var fired = new ManualResetEventSlim(false); + IOptionsChangeTokenSource serverSource = source; + using var registration = ChangeToken.OnChange(serverSource.GetChangeToken, fired.Set); + + source.CheckForChanges().Should().BeFalse(); + fired.IsSet.Should().BeFalse(); + + var certB = CreateSelfSigned("CN=B"); + WritePemPair(root.Dir("signing"), certB); + + source.CheckForChanges().Should().BeTrue(); + fired.Wait(TimeSpan.FromSeconds(2)).Should().BeTrue(); + } + + [Fact] + public async Task OptionsMonitor_ReloadsServerAndValidationCredentials_AfterPemSwap() + { + using var root = new TempPemRoot(); + var signingOld = CreateSelfSigned("CN=Sign-Old"); + var signingNew = CreateSelfSigned("CN=Sign-New"); + var encryptionOld = CreateSelfSigned("CN=Enc-Old"); + var encryptionNew = CreateSelfSigned("CN=Enc-New"); + + WritePemPair(root.Dir("signing"), signingOld); + WritePemPair(root.Dir("encryption"), encryptionOld); + + var signingDirs = new[] + { + root.Dir("signing"), + root.Dir("signing-last-seen"), + root.Dir("signing-previous"), + }; + var encryptionDirs = new[] + { + root.Dir("encryption"), + root.Dir("encryption-last-seen"), + root.Dir("encryption-previous"), + }; + + var source = new OauthPemOptionsChangeTokenSource(signingDirs, encryptionDirs, TimeSpan.FromHours(1)); + await using var provider = BuildProvider(signingDirs, encryptionDirs, source); + + var serverMonitor = provider.GetRequiredService>(); + var validationMonitor = provider.GetRequiredService>(); + + GetThumbprints(serverMonitor.CurrentValue.SigningCredentials).Should().Equal(signingOld.Thumbprint); + GetThumbprints(serverMonitor.CurrentValue.EncryptionCredentials).Should().Equal(encryptionOld.Thumbprint); + GetThumbprints(validationMonitor.CurrentValue.EncryptionCredentials).Should().Equal(encryptionOld.Thumbprint); + validationMonitor.CurrentValue.Configuration!.SigningKeys.Should().ContainSingle(); + + // Retain old key in previous slot; put new key in current (rollover mid-process). + WritePemPair(root.Dir("signing-previous"), signingOld); + WritePemPair(root.Dir("encryption-previous"), encryptionOld); + WritePemPair(root.Dir("signing"), signingNew); + WritePemPair(root.Dir("encryption"), encryptionNew); + + source.CheckForChanges().Should().BeTrue(); + + GetThumbprints(serverMonitor.CurrentValue.SigningCredentials) + .Should().BeEquivalentTo([signingOld.Thumbprint, signingNew.Thumbprint]); + GetThumbprints(serverMonitor.CurrentValue.EncryptionCredentials) + .Should().BeEquivalentTo([encryptionOld.Thumbprint, encryptionNew.Thumbprint]); + + // UseLocalServer-style import must re-run when validation options recreate. + var validationEncryptionThumbprints = GetThumbprints(validationMonitor.CurrentValue.EncryptionCredentials).ToArray(); + validationEncryptionThumbprints.Should().BeEquivalentTo( + [encryptionOld.Thumbprint, encryptionNew.Thumbprint], + because: $"validation encryption thumbprints were [{string.Join(", ", validationEncryptionThumbprints)}]"); + validationMonitor.CurrentValue.Configuration!.SigningKeys.Should().HaveCount(2); + } + + [Fact] + public async Task RedeemAfterSwap_TokenProtectedWithOldKey_StillDecryptsWithoutRestart() + { + using var root = new TempPemRoot(); + var signingOld = CreateSelfSigned("CN=Sign-Old"); + var signingNew = CreateSelfSigned("CN=Sign-New"); + var encryptionOld = CreateSelfSigned("CN=Enc-Old"); + var encryptionNew = CreateSelfSigned("CN=Enc-New"); + + WritePemPair(root.Dir("signing"), signingOld); + WritePemPair(root.Dir("encryption"), encryptionOld); + + var signingDirs = new[] + { + root.Dir("signing"), + root.Dir("signing-previous"), + }; + var encryptionDirs = new[] + { + root.Dir("encryption"), + root.Dir("encryption-previous"), + }; + + var source = new OauthPemOptionsChangeTokenSource(signingDirs, encryptionDirs, TimeSpan.FromHours(1)); + await using var provider = BuildProvider(signingDirs, encryptionDirs, source); + + var serverMonitor = provider.GetRequiredService>(); + var before = serverMonitor.CurrentValue; + + // Simulate an OpenIddict refresh token: nested JWT signed then encrypted with the then-current key. + var handler = new JsonWebTokenHandler(); + var protectedToken = handler.CreateToken(new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity([new Claim("sub", "lexbox-user"), new Claim("token_usage", "refresh_token")]), + SigningCredentials = before.SigningCredentials[0], + EncryptingCredentials = before.EncryptionCredentials[0], + }); + + WritePemPair(root.Dir("signing-previous"), signingOld); + WritePemPair(root.Dir("encryption-previous"), encryptionOld); + WritePemPair(root.Dir("signing"), signingNew); + WritePemPair(root.Dir("encryption"), encryptionNew); + source.CheckForChanges().Should().BeTrue(); + + var after = serverMonitor.CurrentValue; + GetThumbprints(after.SigningCredentials) + .Should().BeEquivalentTo([signingOld.Thumbprint, signingNew.Thumbprint]); + GetThumbprints(after.EncryptionCredentials) + .Should().BeEquivalentTo([encryptionOld.Thumbprint, encryptionNew.Thumbprint]); + + // Prefer the new signing cert for issuance, but keep old keys for redemption. + var preferredSigning = after.SigningCredentials + .OrderByDescending(c => ((X509SecurityKey)c.Key).Certificate.NotAfter) + .First(); + GetThumbprint(preferredSigning).Should().Be(signingNew.Thumbprint); + + var result = await handler.ValidateTokenAsync(protectedToken, new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + IssuerSigningKeys = after.SigningCredentials.Select(c => c.Key), + TokenDecryptionKeys = after.EncryptionCredentials.Select(c => c.Key), + }); + + result.IsValid.Should().BeTrue(because: result.Exception?.ToString()); + result.ClaimsIdentity!.FindFirst("sub")!.Value.Should().Be("lexbox-user"); + result.ClaimsIdentity.FindFirst("token_usage")!.Value.Should().Be("refresh_token"); + } + + private static ServiceProvider BuildProvider( + IReadOnlyList signingDirs, + IReadOnlyList encryptionDirs, + OauthPemOptionsChangeTokenSource source) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(source); + services.AddSingleton>(source); + services.AddSingleton>(source); + services.AddSingleton>( + new OauthPemOpenIddictServerConfigurer(signingDirs, encryptionDirs)); + + // Mirror UseLocalServer(): import keys from current server options whenever validation options are created. + services.AddSingleton>(sp => + new ConfigureOptions(options => + { + var server = sp.GetRequiredService>().CurrentValue; + options.Configuration ??= new(); + foreach (var credentials in server.SigningCredentials) + options.Configuration.SigningKeys.Add(credentials.Key); + foreach (var credentials in server.EncryptionCredentials) + options.EncryptionCredentials.Add(credentials); + })); + + return services.BuildServiceProvider(); + } + + private static IEnumerable GetThumbprints(IEnumerable credentials) => + credentials.Select(GetThumbprint); + + private static IEnumerable GetThumbprints(IEnumerable credentials) => + credentials.Select(GetThumbprint); + + private static string GetThumbprint(SigningCredentials credentials) => + ((X509SecurityKey)credentials.Key).Certificate.Thumbprint; + + private static string GetThumbprint(EncryptingCredentials credentials) => + ((X509SecurityKey)credentials.Key).Certificate.Thumbprint; + + private static X509Certificate2 CreateSelfSigned(string subject) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + // Stagger NotAfter so "preferred" signing cert selection is deterministic in redeem-after-swap. + var notBefore = DateTimeOffset.UtcNow.AddDays(-1); + var notAfter = subject.Contains("New", StringComparison.Ordinal) + ? DateTimeOffset.UtcNow.AddYears(2) + : DateTimeOffset.UtcNow.AddYears(1); + return request.CreateSelfSigned(notBefore, notAfter); + } + + private static void WritePemPair(string directory, X509Certificate2 certificate) + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, OauthPemCertificatePaths.CertFileName), certificate.ExportCertificatePem()); + var key = certificate.GetRSAPrivateKey() + ?? throw new InvalidOperationException("Test certificate is missing an RSA private key."); + File.WriteAllText(Path.Combine(directory, OauthPemCertificatePaths.KeyFileName), key.ExportPkcs8PrivateKeyPem()); + } + + private sealed class TempPemRoot : IDisposable + { + private readonly string _root = Path.Combine(Path.GetTempPath(), "lexbox-oauth-pem-reload-" + Guid.NewGuid().ToString("N")); + + public TempPemRoot() => Directory.CreateDirectory(_root); + + public string Dir(string name) => Path.Combine(_root, name); + + public void Dispose() + { + try + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + catch + { + // Best-effort cleanup for temp test dirs. + } + } + } +} diff --git a/deployment/base/kustomization.yaml b/deployment/base/kustomization.yaml index 0dc4868892..680507fda7 100644 --- a/deployment/base/kustomization.yaml +++ b/deployment/base/kustomization.yaml @@ -15,3 +15,4 @@ resources: - proxy-deployment.yaml - app-config.yaml - oauth-certs.yaml +- oauth-cert-retainer.yaml diff --git a/deployment/base/lexbox-deployment.yaml b/deployment/base/lexbox-deployment.yaml index 882683e275..ba10d00f65 100644 --- a/deployment/base/lexbox-deployment.yaml +++ b/deployment/base/lexbox-deployment.yaml @@ -86,9 +86,21 @@ spec: - name: oauth-signing-cert mountPath: /oauth-certs/signing readOnly: true + - name: oauth-signing-cert-last-seen + mountPath: /oauth-certs/signing-last-seen + readOnly: true + - name: oauth-signing-cert-previous + mountPath: /oauth-certs/signing-previous + readOnly: true - name: oauth-encryption-cert mountPath: /oauth-certs/encryption readOnly: true + - name: oauth-encryption-cert-last-seen + mountPath: /oauth-certs/encryption-last-seen + readOnly: true + - name: oauth-encryption-cert-previous + mountPath: /oauth-certs/encryption-previous + readOnly: true env: - name: DOTNET_URLS @@ -263,10 +275,26 @@ spec: secret: secretName: oauth-signing-cert optional: true + - name: oauth-signing-cert-last-seen + secret: + secretName: oauth-signing-cert-last-seen + optional: true + - name: oauth-signing-cert-previous + secret: + secretName: oauth-signing-cert-previous + optional: true - name: oauth-encryption-cert secret: secretName: oauth-encryption-cert optional: true + - name: oauth-encryption-cert-last-seen + secret: + secretName: oauth-encryption-cert-last-seen + optional: true + - name: oauth-encryption-cert-previous + secret: + secretName: oauth-encryption-cert-previous + optional: true initContainers: - name: db-migrations diff --git a/deployment/base/oauth-cert-retainer.yaml b/deployment/base/oauth-cert-retainer.yaml new file mode 100644 index 0000000000..a143d29c11 --- /dev/null +++ b/deployment/base/oauth-cert-retainer.yaml @@ -0,0 +1,148 @@ +# Daily CronJob that retains last-seen / previous OAuth signing & encryption +# Secrets so the API can keep validating refresh tokens across cert-manager renewals. +# Companion Secrets hold full keypairs; cert-manager current Secrets are unchanged. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: oauth-cert-retainer +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: oauth-cert-retainer +rules: + # Read current cert-manager Secrets (named get; list included per design, unused by script) + - apiGroups: [""] + resources: ["secrets"] + resourceNames: + - oauth-signing-cert + - oauth-encryption-cert + verbs: ["get", "list"] + # Maintain companion Secrets only (create cannot use resourceNames) + - apiGroups: [""] + resources: ["secrets"] + resourceNames: + - oauth-signing-cert-last-seen + - oauth-signing-cert-previous + - oauth-encryption-cert-last-seen + - oauth-encryption-cert-previous + verbs: ["get", "update", "patch"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: oauth-cert-retainer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: oauth-cert-retainer +subjects: + - kind: ServiceAccount + name: oauth-cert-retainer +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: oauth-cert-retainer +spec: + schedule: "0 4 * * *" # daily 04:00 UTC + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + template: + spec: + serviceAccountName: oauth-cert-retainer + restartPolicy: Never + containers: + - name: retain + # Distroless rancher/kubectl has no shell; retainer script needs bash. + image: bitnamilegacy/kubectl:1.31.4 + command: ["/bin/bash", "-c"] + args: + - | + set -euo pipefail + + fingerprint() { + local secret="$1" + kubectl get secret "$secret" -o jsonpath='{.data.tls\.crt}' \ + | base64 -d \ + | sha256sum \ + | awk '{print $1}' + } + + secret_exists() { + kubectl get secret "$1" >/dev/null 2>&1 + } + + # Copy full keypair (and ca.crt when present) to a companion Secret. + copy_secret() { + local src="$1" + local dst="$2" + local work stype ca + work="$(mktemp -d)" + stype="$(kubectl get secret "$src" -o jsonpath='{.type}')" + kubectl get secret "$src" -o jsonpath='{.data.tls\.crt}' | base64 -d >"$work/tls.crt" + kubectl get secret "$src" -o jsonpath='{.data.tls\.key}' | base64 -d >"$work/tls.key" + if [ ! -s "$work/tls.crt" ] || [ ! -s "$work/tls.key" ]; then + echo "error: $src missing tls.crt or tls.key" >&2 + exit 1 + fi + ca="$(kubectl get secret "$src" -o jsonpath='{.data.ca\.crt}')" + if [ -n "$ca" ]; then + printf '%s' "$ca" | base64 -d >"$work/ca.crt" + kubectl create secret generic "$dst" \ + --type="$stype" \ + --from-file=tls.crt="$work/tls.crt" \ + --from-file=tls.key="$work/tls.key" \ + --from-file=ca.crt="$work/ca.crt" \ + --dry-run=client -o yaml | kubectl apply -f - + else + kubectl create secret generic "$dst" \ + --type="$stype" \ + --from-file=tls.crt="$work/tls.crt" \ + --from-file=tls.key="$work/tls.key" \ + --dry-run=client -o yaml | kubectl apply -f - + fi + rm -rf "$work" + } + + retain() { + local current="$1" + local last_seen="$2" + local previous="$3" + + if ! secret_exists "$current"; then + echo "error: current secret $current not found" >&2 + exit 1 + fi + + local current_fp + current_fp="$(fingerprint "$current")" + + if ! secret_exists "$last_seen" \ + || [ -z "$(kubectl get secret "$last_seen" -o jsonpath='{.data.tls\.crt}')" ]; then + echo "bootstrap: copying $current -> $last_seen and $previous" + copy_secret "$current" "$last_seen" + copy_secret "$current" "$previous" + return 0 + fi + + local last_fp + last_fp="$(fingerprint "$last_seen")" + if [ "$current_fp" != "$last_fp" ]; then + echo "rollover: $current fingerprint changed; $last_seen -> $previous, $current -> $last_seen" + copy_secret "$last_seen" "$previous" + copy_secret "$current" "$last_seen" + else + echo "unchanged: $current fingerprint matches $last_seen" + fi + } + + retain oauth-signing-cert oauth-signing-cert-last-seen oauth-signing-cert-previous + retain oauth-encryption-cert oauth-encryption-cert-last-seen oauth-encryption-cert-previous diff --git a/deployment/local-dev/delete-oauth-cert-retainer.yaml b/deployment/local-dev/delete-oauth-cert-retainer.yaml new file mode 100644 index 0000000000..80d433ff8d --- /dev/null +++ b/deployment/local-dev/delete-oauth-cert-retainer.yaml @@ -0,0 +1,10 @@ +$patch: delete +# Local-dev strips cert-manager OAuth Certificates; drop the retainer CronJob/RBAC +# so we do not schedule Jobs against missing Secrets. Companion volume mounts stay +# optional:true on the API Deployment and do not block pod start. +# The values below are ignored aside from enabling parse; $patch: delete + target +# selectors in kustomization.yaml are what matter. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: oauth-cert-retainer diff --git a/deployment/local-dev/kustomization.yaml b/deployment/local-dev/kustomization.yaml index 77b2f3d752..47b4e04046 100644 --- a/deployment/local-dev/kustomization.yaml +++ b/deployment/local-dev/kustomization.yaml @@ -37,6 +37,22 @@ patches: - target: kind: Certificate path: delete-oauth-certs.yaml + - target: + kind: CronJob + name: oauth-cert-retainer + path: delete-oauth-cert-retainer.yaml + - target: + kind: ServiceAccount + name: oauth-cert-retainer + path: delete-oauth-cert-retainer.yaml + - target: + kind: Role + name: oauth-cert-retainer + path: delete-oauth-cert-retainer.yaml + - target: + kind: RoleBinding + name: oauth-cert-retainer + path: delete-oauth-cert-retainer.yaml - path: app-config.yaml - path: fw-headless-deployment.patch.yaml