From 6313008bd3c6b2bd9053ef5221c66370ba37df7a Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 11:20:02 -0300 Subject: [PATCH 01/47] feature(#22): this commit introduces realm-id and expiration dates to the secret class to support per-realm secrets and key rotation --- .../HttpsRichardy.Federation.Domain/Aggregates/Secret.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Secret.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Secret.cs index 86dd1e8..a0ab26b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Secret.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Secret.cs @@ -4,4 +4,8 @@ public sealed class Secret : Aggregate { public string PrivateKey { get; set; } = default!; public string PublicKey { get; set; } = default!; -} \ No newline at end of file + public string RealmId { get; set; } = default!; + + public DateTime? ExpiresAt { get; set; } + public DateTime? GracePeriodEndsAt { get; set; } +} From 1a78efc5071884ab89b8f4fb8d279822a09c022d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 11:21:29 -0300 Subject: [PATCH 02/47] feature(#22): this commit includes filtering classes to simplify the creation and configuration of filters related to secrets. This improves the flexibility and reusability of filtering criteria within the domain. --- .../Builders/SecretFiltersBuilder.cs | 37 +++++++++++++++++++ .../Filtering/SecretFilters.cs | 10 +++++ 2 files changed, 47 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs new file mode 100644 index 0000000..fa90578 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs @@ -0,0 +1,37 @@ +namespace HttpsRichardy.Federation.Domain.Filtering.Builders; + +public sealed class SecretFiltersBuilder : + FiltersBuilderBase +{ + public SecretFiltersBuilder ForRealm(string? realmId) + { + if (!string.IsNullOrWhiteSpace(realmId)) + _filters.RealmId = realmId; + + return this; + } + + public SecretFiltersBuilder WithCanSign(DateTime? now = null) + { + _filters.CanSign = true; + _filters.Now = now ?? DateTime.UtcNow; + + return this; + } + + public SecretFiltersBuilder WithInGrace(DateTime? now = null) + { + _filters.InGracePeriod = true; + _filters.Now = now ?? DateTime.UtcNow; + + return this; + } + + public SecretFiltersBuilder WithExpired(DateTime? now = null) + { + _filters.IsExpired = true; + _filters.Now = now ?? DateTime.UtcNow; + + return this; + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs new file mode 100644 index 0000000..3035431 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs @@ -0,0 +1,10 @@ +namespace HttpsRichardy.Federation.Domain.Filtering; + +public sealed class SecretFilters : Filters +{ + public string? RealmId { get; set; } + public bool? CanSign { get; set; } + public bool? InGracePeriod { get; set; } + public bool? IsExpired { get; set; } + public DateTime? Now { get; set; } +} From 216230017311ff6c12fd5c93d18df3f1de9efab7 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 11:36:36 -0300 Subject: [PATCH 03/47] feature(#22): this commit introduces the `secret` class with field constants in `documents.cs` --- .../Constants/Documents.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs index b8d7dbf..4caa4af 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs @@ -43,6 +43,14 @@ public static class Client public const string Id = "_id"; } + public static class Secret + { + public const string RealmId = nameof(Domain.Aggregates.Secret.RealmId); + public const string ExpiresAt = nameof(Domain.Aggregates.Secret.ExpiresAt); + public const string GracePeriodEndsAt = nameof(Domain.Aggregates.Secret.GracePeriodEndsAt); + public const string Id = "_id"; + } + public static class SecurityToken { public const string Value = nameof(Domain.Aggregates.SecurityToken.Value); From 215ae83f0a474c94c5642777980ff3cf41fdd5be Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 11:38:46 -0300 Subject: [PATCH 04/47] feature(#22): this commit introduces secret filters stage for dynamic secret filtering, allowing dynamic filters to be applied in MongoDB pipelines for the secret entity --- .../Pipelines/SecretFiltersStage.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs new file mode 100644 index 0000000..fe130a3 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs @@ -0,0 +1,40 @@ +namespace HttpsRichardy.Federation.Infrastructure.Pipelines; + +public static class SecretFiltersStage +{ + public static PipelineDefinition FilterSecrets(this PipelineDefinition pipeline, + SecretFilters filters, IRealmProvider realmProvider) + { + var realm = realmProvider.GetCurrentRealm(); + var now = filters.Now ?? DateTime.UtcNow; + + var definitions = new List> + { + FilterDefinitions.MatchIfNotEmpty(Documents.Secret.Id, filters.Id), + FilterDefinitions.MatchIfNotEmpty(Documents.Secret.RealmId, filters.RealmId ?? realm?.Id), + }; + + if (filters.CanSign is true) + { + definitions.Add(Builders.Filter.Or( + Builders.Filter.Eq(Documents.Secret.ExpiresAt, BsonNull.Value), + Builders.Filter.Gt(Documents.Secret.ExpiresAt, now) + )); + } + + // a secret is in the grace period if it has already expired for signing and its grace period has not ended yet. + + if (filters.InGracePeriod is true) + { + definitions.Add(Builders.Filter.And( + Builders.Filter.Ne(Documents.Secret.ExpiresAt, BsonNull.Value), + Builders.Filter.Lte(Documents.Secret.ExpiresAt, now), + + Builders.Filter.Ne(Documents.Secret.GracePeriodEndsAt, BsonNull.Value), + Builders.Filter.Gt(Documents.Secret.GracePeriodEndsAt, now) + )); + } + + return pipeline.Match(Builders.Filter.And(definitions)); + } +} From eb7c9ac8b4f496b6303f76d80865434f820e3826 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 11:44:41 -0300 Subject: [PATCH 05/47] refactor(#22): this commit refactors the secrets collection with filters and counts, updates the constructor to accept a realm provider, and implements aggregation pipelines in MongoDB for searching and counting secrets. --- .../Collections/ISecretCollection.cs | 10 ++++- .../Persistence/SecretCollection.cs | 41 ++++++++++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/ISecretCollection.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/ISecretCollection.cs index e5f9848..87e7009 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/ISecretCollection.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/ISecretCollection.cs @@ -2,5 +2,13 @@ namespace HttpsRichardy.Federation.Domain.Collections; public interface ISecretCollection : IAggregateCollection { - public Task GetSecretAsync(CancellationToken cancellation = default); + public Task> GetSecretsAsync( + SecretFilters filters, + CancellationToken cancellation = default + ); + + public Task CountSecretsAsync( + SecretFilters filters, + CancellationToken cancellation = default + ); } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/SecretCollection.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/SecretCollection.cs index a314b92..a13fa4f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/SecretCollection.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/SecretCollection.cs @@ -1,13 +1,42 @@ namespace HttpsRichardy.Federation.Infrastructure.Persistence; -public sealed class SecretCollection(IMongoDatabase database) : +public sealed class SecretCollection(IMongoDatabase database, IRealmProvider realmProvider) : AggregateCollection(database, Collections.Secrets), ISecretCollection { - public async Task GetSecretAsync(CancellationToken cancellation = default) + public async Task> GetSecretsAsync( + SecretFilters filters, CancellationToken cancellation = default) { - return await _collection - .Find(Builders.Filter.Empty) - .FirstOrDefaultAsync(cancellation); + var pipeline = PipelineDefinitionBuilder + .For() + .As() + .FilterSecrets(filters, realmProvider) + .Paginate(filters.Pagination) + .Sort(filters.Sort); + + var options = new AggregateOptions { AllowDiskUse = true }; + var aggregation = await _collection.AggregateAsync(pipeline, options, cancellation); + + var bsonDocuments = await aggregation.ToListAsync(cancellation); + var secrets = bsonDocuments + .Select(bson => BsonSerializer.Deserialize(bson)) + .ToList(); + + return secrets; + } + + public async Task CountSecretsAsync( + SecretFilters filters, CancellationToken cancellation = default) + { + var pipeline = PipelineDefinitionBuilder + .For() + .As() + .FilterSecrets(filters, realmProvider) + .Count(); + + var aggregation = await _collection.AggregateAsync(pipeline, cancellationToken: cancellation); + var result = await aggregation.FirstOrDefaultAsync(cancellation); + + return result?.Count ?? 0; } -} \ No newline at end of file +} From 1a6f28ede3412acf29eb0ae738d796e1ff740da7 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 11:46:46 -0300 Subject: [PATCH 06/47] refactor(#22): this commit introduces static properties, making it easier to create standard or custom filter instances. --- .../HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs index 3035431..eb5a52f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs @@ -7,4 +7,7 @@ public sealed class SecretFilters : Filters public bool? InGracePeriod { get; set; } public bool? IsExpired { get; set; } public DateTime? Now { get; set; } + + public static SecretFilters WithoutFilters => new(); + public static SecretFiltersBuilder WithSpecifications() => new(); } From 51552fab06b49996433bc13f5996b2cd6cee67c5 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 12:29:12 -0300 Subject: [PATCH 07/47] feature(#22): Implements secret rotation by introducing the secret rotation service interface and implementation to manage the secret lifecycle (creation, rotation, deletion) with expiration logic and a grace period. --- .../Services/ISecretRotationService.cs | 25 ++++++ .../Security/SecretRotationService.cs | 81 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs new file mode 100644 index 0000000..6c293f3 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs @@ -0,0 +1,25 @@ +namespace HttpsRichardy.Federation.Application.Services; + +public interface ISecretRotationService +{ + public Task EnsureSecretExistsAsync( + Realm realm, + CancellationToken cancellation = default + ); + + public Task CreateSecretAsync( + Realm realm, + CancellationToken cancellation = default + ); + + public Task RotateSecretAsync( + Realm realm, + CancellationToken cancellation = default + ); + + public Task DeleteSecretAsync( + Realm realm, + Secret secret, + CancellationToken cancellation = default + ); +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs new file mode 100644 index 0000000..5f0c5ac --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs @@ -0,0 +1,81 @@ +using HttpsRichardy.Internal.Essentials.Contracts.Behaviors; + +using Realm = HttpsRichardy.Federation.Domain.Aggregates.Realm; +using Secret = HttpsRichardy.Federation.Domain.Aggregates.Secret; + +namespace HttpsRichardy.Federation.Infrastructure.Security; + +public sealed class SecretRotationService(ISecretCollection secretCollection) : ISecretRotationService +{ + private static readonly TimeSpan _keyLifetime = TimeSpan.FromDays(30); + private static readonly TimeSpan _gracePeriod = TimeSpan.FromDays(1); + + public async Task CreateSecretAsync(Realm realm, CancellationToken cancellation = default) + { + using var rsa = RSA.Create(2048); + + var now = DateTime.UtcNow; + var secret = new Secret + { + RealmId = realm.Id, + + PrivateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey()), + PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()), + + CreatedAt = now, + ExpiresAt = now.Add(_keyLifetime), + }; + + await secretCollection.InsertAsync(secret, cancellation: cancellation); + } + + public async Task DeleteSecretAsync(Realm realm, Secret secret, CancellationToken cancellation = default) + { + if (secret.GracePeriodEndsAt is not null && secret.GracePeriodEndsAt <= DateTime.UtcNow) + { + await secretCollection.DeleteAsync(secret, behavior: DeletionBehavior.Hard, cancellation: cancellation); + } + } + + public async Task EnsureSecretExistsAsync(Realm realm, CancellationToken cancellation = default) + { + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanSign() + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + var current = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault(); + + if (current is null) + { + await CreateSecretAsync(realm, cancellation); + } + } + + public async Task RotateSecretAsync(Realm realm, CancellationToken cancellation = default) + { + var now = DateTime.UtcNow; + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanSign(now) + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + var current = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault(); + + if (current is not null) + { + current.ExpiresAt = now; + current.GracePeriodEndsAt = now.Add(_gracePeriod); + + await secretCollection.UpdateAsync(current, cancellation: cancellation); + } + + await CreateSecretAsync(realm, cancellation); + } +} From 4169206719f355b17294183f83503c0d1948dd1f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 12:30:12 -0300 Subject: [PATCH 08/47] =?UTF-8?q?feature(#22):=20this=20commit=20renames?= =?UTF-8?q?=20the=20=E2=80=9Cfor=20realm=E2=80=9D=20method=20to=20?= =?UTF-8?q?=E2=80=9Cwith=20realm=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Filtering/Builders/SecretFiltersBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs index fa90578..0203a86 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs @@ -3,7 +3,7 @@ public sealed class SecretFiltersBuilder : FiltersBuilderBase { - public SecretFiltersBuilder ForRealm(string? realmId) + public SecretFiltersBuilder WithRealm(string? realmId) { if (!string.IsNullOrWhiteSpace(realmId)) _filters.RealmId = realmId; From 03346ed858d3006b3852468cabaca1bdc03f1922 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 12:32:27 -0300 Subject: [PATCH 09/47] feature(#22): this commit includes the registration of the key rotation service with a transient lifetime --- .../Extensions/ApplicationServicesExtension.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs index 108da15..2dbc21b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs @@ -8,6 +8,7 @@ public static void AddServices(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); From 3d50a65ddfe89b88b769b32fd39b426ddf235c9d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 12:35:47 -0300 Subject: [PATCH 10/47] feature(#22): this commit removes the initial secrets extension; secrets are no longer generated automatically during service configuration, as this is now done per realm and during realm creation. --- .../Extensions/SecretsExtension.cs | 30 ------------------- .../Extensions/ServicesExtension.cs | 1 - 2 files changed, 31 deletions(-) delete mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/SecretsExtension.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/SecretsExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/SecretsExtension.cs deleted file mode 100644 index bdde86a..0000000 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/SecretsExtension.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace HttpsRichardy.Federation.Infrastructure.IoC.Extensions; - -public static class SecretsExtension -{ - public static void AddInitialSecrets(this IServiceCollection services) - { - var serviceProvider = services.BuildServiceProvider(); - var secretRepository = serviceProvider.GetRequiredService(); - - var secret = secretRepository.GetSecretAsync() - .GetAwaiter() - .GetResult(); - - /* if no secret exists, generate an initial one to sign JWT tokens */ - if (secret is null) - { - using var rsa = RSA.Create(2048); - - secret = new Secret - { - PrivateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey()), - PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()) - }; - - secretRepository.InsertAsync(secret) - .GetAwaiter() - .GetResult(); - } - } -} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ServicesExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ServicesExtension.cs index 5493cc7..a692365 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ServicesExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ServicesExtension.cs @@ -11,6 +11,5 @@ public static void AddInfrastructure(this IServiceCollection services, IConfigur services.AddServices(); services.AddMediator(); services.AddValidators(); - services.AddInitialSecrets(); } } From 5d80c84ef781af64894ea29169569db75734a97a Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 13:02:45 -0300 Subject: [PATCH 11/47] refactor(#22): this commit adjusts the filter classes --- .../Filtering/Builders/SecretFiltersBuilder.cs | 6 +++--- .../Filtering/RealmFilters.cs | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs index 0203a86..5796c77 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs @@ -14,7 +14,7 @@ public SecretFiltersBuilder WithRealm(string? realmId) public SecretFiltersBuilder WithCanSign(DateTime? now = null) { _filters.CanSign = true; - _filters.Now = now ?? DateTime.UtcNow; + _filters.Now = now; return this; } @@ -22,7 +22,7 @@ public SecretFiltersBuilder WithCanSign(DateTime? now = null) public SecretFiltersBuilder WithInGrace(DateTime? now = null) { _filters.InGracePeriod = true; - _filters.Now = now ?? DateTime.UtcNow; + _filters.Now = now; return this; } @@ -30,7 +30,7 @@ public SecretFiltersBuilder WithInGrace(DateTime? now = null) public SecretFiltersBuilder WithExpired(DateTime? now = null) { _filters.IsExpired = true; - _filters.Now = now ?? DateTime.UtcNow; + _filters.Now = now; return this; } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs index e8acd5e..43afeb1 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs @@ -4,5 +4,6 @@ public sealed class RealmFilters : Filters { public string? Name { get; set; } + public static RealmFilters WithoutFilters => new(); public static RealmFiltersBuilder WithSpecifications() => new(); } From 889b78eb2b27c2deb053ab362c0a11ad124b3749 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 13:04:19 -0300 Subject: [PATCH 12/47] =?UTF-8?q?feature(#22):=20this=20commit=20implement?= =?UTF-8?q?s=20=E2=80=9Cprune=20secrets=E2=80=9D=20in=20the=20secret=20rot?= =?UTF-8?q?ation=20interface=20and=20service,=20allowing=20you=20to=20remo?= =?UTF-8?q?ve=20all=20secrets=20from=20a=20realm.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/ISecretRotationService.cs | 5 ++++ .../Security/SecretRotationService.cs | 29 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs index 6c293f3..167c2b7 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs @@ -7,6 +7,11 @@ public Task EnsureSecretExistsAsync( CancellationToken cancellation = default ); + public Task PruneSecretsAsync( + Realm realm, + CancellationToken cancellation = default + ); + public Task CreateSecretAsync( Realm realm, CancellationToken cancellation = default diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs index 5f0c5ac..0a8a0fa 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs @@ -55,6 +55,20 @@ public async Task EnsureSecretExistsAsync(Realm realm, CancellationToken cancell } } + public async Task PruneSecretsAsync(Realm realm, CancellationToken cancellation = default) + { + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + + foreach (var secret in secrets) + { + await DeleteSecretAsync(realm, secret, cancellation); + } + } + public async Task RotateSecretAsync(Realm realm, CancellationToken cancellation = default) { var now = DateTime.UtcNow; @@ -68,14 +82,19 @@ public async Task RotateSecretAsync(Realm realm, CancellationToken cancellation .OrderByDescending(secret => secret.CreatedAt) .FirstOrDefault(); - if (current is not null) + if (current is null) { - current.ExpiresAt = now; - current.GracePeriodEndsAt = now.Add(_gracePeriod); - - await secretCollection.UpdateAsync(current, cancellation: cancellation); + await CreateSecretAsync(realm, cancellation); + return; } + if (current.ExpiresAt is not null && current.ExpiresAt > now) + return; + + current.ExpiresAt = now; + current.GracePeriodEndsAt = now.Add(_gracePeriod); + + await secretCollection.UpdateAsync(current, cancellation: cancellation); await CreateSecretAsync(realm, cancellation); } } From 3ac7d7d7f6240109736e6198db419e7b4773f12e Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 13:08:52 -0300 Subject: [PATCH 13/47] feature(#22): this commit implements a background key rotation service that performs key rotation, validation, and secret cleanup for all realms every 24 hours, using parallel processing and logs for monitoring and error handling. --- .../Workers/KeyRotationBackgroundService.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs new file mode 100644 index 0000000..b2d12f3 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs @@ -0,0 +1,37 @@ +namespace HttpsRichardy.Federation.WebApi.Workers; + +public sealed class KeyRotationBackgroundService(IServiceScopeFactory _scopeFactory, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + using var scope = _scopeFactory.CreateScope(); + + var rotationService = scope.ServiceProvider.GetRequiredService(); + var realmCollection = scope.ServiceProvider.GetRequiredService(); + + var realms = await realmCollection.GetRealmsAsync(RealmFilters.WithoutFilters, stoppingToken); + + await Parallel.ForEachAsync(realms, stoppingToken, async (realm, cancellation) => + { + try + { + logger.LogInformation("rotating keys for realm {realm}", realm.Name); + + /* !important: ensures the realm has at least one valid signing key before rotation */ + await rotationService.EnsureSecretExistsAsync(realm, cancellation); + + await rotationService.RotateSecretAsync(realm, cancellation); + await rotationService.PruneSecretsAsync(realm, cancellation); + } + catch (Exception exception) + { + logger.LogError(exception, "an error occurred while rotating keys for realm {realm}", realm.Name); + } + }); + + await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + } + } +} From dabea37df11c93f05999fd9a855ea0411996338b Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 14:57:45 -0300 Subject: [PATCH 14/47] feature(#22): this commit updates well known controller to include realm in route and enhance response handling for JWKS endpoints --- .../Controllers/WellKnownController.cs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/WellKnownController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/WellKnownController.cs index 0ac7404..be04919 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/WellKnownController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/WellKnownController.cs @@ -2,20 +2,22 @@ namespace HttpsRichardy.Federation.WebApi.Controllers; [ApiController] [ApiConventionType(typeof(WellKnownConventions))] -[Route(".well-known")] +[Route("{realm}/.well-known")] public sealed class WellKnownController(IDispatcher dispatcher) : ControllerBase { [HttpGet("openid-configuration")] [Stability(Stability.Stable)] public async Task GetConfigurationAsync([FromQuery] FetchOpenIDConfigurationParameters request, CancellationToken cancellation) { - var result = await dispatcher.DispatchAsync(request, cancellation); + var result = await dispatcher.DispatchAsync(request with { Realm = (string)RouteData.Values["realm"]! }, cancellation); - // we know the switch here is not strictly necessary since we only handle the success case, - // but we keep it for consistency with the rest of the codebase and to follow established patterns. return result switch { - { IsSuccess: true } => StatusCode(StatusCodes.Status200OK, result.Data), + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == RealmErrors.RealmDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error) }; } @@ -23,13 +25,15 @@ public async Task GetConfigurationAsync([FromQuery] FetchOpenIDCo [Stability(Stability.Stable)] public async Task GetJsonWebKeysAsync([FromQuery] FetchJsonWebKeysParameters request, CancellationToken cancellation) { - var result = await dispatcher.DispatchAsync(request, cancellation); + var result = await dispatcher.DispatchAsync(request with { Realm = (string)RouteData.Values["realm"]! }, cancellation); - // we know the switch here is not strictly necessary since we only handle the success case, - // but we keep it for consistency with the rest of the codebase and to follow established patterns. return result switch { - { IsSuccess: true } => StatusCode(StatusCodes.Status200OK, result.Data), + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == RealmErrors.RealmDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error) }; } } From c52206d126613a3f0ff92766bb5ddc4f40a69cf3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 14:58:16 -0300 Subject: [PATCH 15/47] feature(#22): this commit updates secret handling in authentication tests to include realm association and expiration dates --- .../Security/AuthenticationServiceTests.cs | 13 ++++++++----- .../Security/JwtSecurityTokenServiceTests.cs | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs b/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs index 9d03ee6..1a59fb8 100644 --- a/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs +++ b/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs @@ -26,17 +26,20 @@ public AuthenticationServiceTests(MongoDatabaseFixture mongoFixture) _passwordHasher = new PasswordHasher(); var tokenCollection = new TokenCollection(_database, _realmProvider.Object); + var realm = _fixture.Create(); var secret = new Secret { + Id = Identifier.Generate(), + RealmId = realm.Id, PrivateKey = Convert.ToBase64String(_rsa.ExportRSAPrivateKey()), - PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()) + PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()), + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(30) }; - var realm = _fixture.Create(); - _secretCollection - .Setup(collection => collection.GetSecretAsync(It.IsAny())) - .ReturnsAsync(secret); + .Setup(collection => collection.GetSecretsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([secret]); _groupCollection .Setup(collection => collection.GetGroupsAsync(It.IsAny(), It.IsAny())) diff --git a/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs b/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs index 61151ed..7ade8ca 100644 --- a/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs +++ b/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs @@ -27,8 +27,12 @@ public JwtSecurityTokenServiceTests(MongoDatabaseFixture fixture) var realm = _fixture.Create(); var secret = new Secret { + Id = Identifier.Generate(), + RealmId = realm.Id, PrivateKey = Convert.ToBase64String(_rsa.ExportRSAPrivateKey()), - PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()) + PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()), + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(30) }; _realmProvider.Setup(provider => provider.GetCurrentRealm()) @@ -39,8 +43,8 @@ public JwtSecurityTokenServiceTests(MongoDatabaseFixture fixture) .ReturnsAsync([]); _secretCollection - .Setup(collection => collection.GetSecretAsync(It.IsAny())) - .ReturnsAsync(secret); + .Setup(collection => collection.GetSecretsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([secret]); _jwtSecurityTokenService = new JwtSecurityTokenService( realmProvider: _realmProvider.Object, @@ -132,7 +136,13 @@ public async Task WhenValidatingExpiredToken_ThenItMustReturnTokenExpiredError() { /* arrange: create an expired token */ - var secret = await _secretCollection.Object.GetSecretAsync(); + var secret = new Secret + { + Id = Identifier.Generate(), + PrivateKey = Convert.ToBase64String(_rsa.ExportRSAPrivateKey()), + PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()) + }; + var privateKey = Common.Utilities.RsaHelper.CreateSecurityKeyFromPrivateKey(secret.PrivateKey); var tokenHandler = new JwtSecurityTokenHandler(); From a10f864c4540ef0b80c52ec3357cdd6cb6cd0e01 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 14:58:51 -0300 Subject: [PATCH 16/47] feature(#22): this commit introduces realm property in records classes --- .../Payloads/Connect/FetchJsonWebKeysParameters.cs | 5 ++++- .../Payloads/Connect/FetchOpenIDConfigurationParameters.cs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchJsonWebKeysParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchJsonWebKeysParameters.cs index e69b217..b8634e3 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchJsonWebKeysParameters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchJsonWebKeysParameters.cs @@ -1,3 +1,6 @@ namespace HttpsRichardy.Federation.Application.Payloads.Connect; -public sealed record FetchJsonWebKeysParameters : IDispatchable>; +public sealed record FetchJsonWebKeysParameters : IDispatchable> +{ + public string Realm { get; init; } = default!; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchOpenIDConfigurationParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchOpenIDConfigurationParameters.cs index 2c0283b..2b42817 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchOpenIDConfigurationParameters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchOpenIDConfigurationParameters.cs @@ -1,4 +1,7 @@ namespace HttpsRichardy.Federation.Application.Payloads.Connect; public sealed record FetchOpenIDConfigurationParameters : - IDispatchable>; \ No newline at end of file + IDispatchable> +{ + public string Realm { get; init; } = default!; +} From 2c089c396e26ca1a7c28051b014a7ca809456f08 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 14:59:41 -0300 Subject: [PATCH 17/47] feature(#22): this commit introduces can validate filter to secret filters --- .../Filtering/Builders/SecretFiltersBuilder.cs | 8 ++++++++ .../Filtering/SecretFilters.cs | 1 + .../Pipelines/SecretFiltersStage.cs | 17 ++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs index 5796c77..d5abd92 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs @@ -19,6 +19,14 @@ public SecretFiltersBuilder WithCanSign(DateTime? now = null) return this; } + public SecretFiltersBuilder WithCanValidate(DateTime? now = null) + { + _filters.CanValidate = true; + _filters.Now = now; + + return this; + } + public SecretFiltersBuilder WithInGrace(DateTime? now = null) { _filters.InGracePeriod = true; diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs index eb5a52f..719039a 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs @@ -4,6 +4,7 @@ public sealed class SecretFilters : Filters { public string? RealmId { get; set; } public bool? CanSign { get; set; } + public bool? CanValidate { get; set; } public bool? InGracePeriod { get; set; } public bool? IsExpired { get; set; } public DateTime? Now { get; set; } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs index fe130a3..2852591 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs @@ -22,7 +22,22 @@ public static PipelineDefinition FilterSecrets(this Pipeli )); } - // a secret is in the grace period if it has already expired for signing and its grace period has not ended yet. + if (filters.CanValidate is true) + { + var canSign = Builders.Filter.Or( + Builders.Filter.Eq(Documents.Secret.ExpiresAt, BsonNull.Value), + Builders.Filter.Gt(Documents.Secret.ExpiresAt, now) + ); + + var inGrace = Builders.Filter.And( + Builders.Filter.Ne(Documents.Secret.ExpiresAt, BsonNull.Value), + Builders.Filter.Lte(Documents.Secret.ExpiresAt, now), + Builders.Filter.Ne(Documents.Secret.GracePeriodEndsAt, BsonNull.Value), + Builders.Filter.Gt(Documents.Secret.GracePeriodEndsAt, now) + ); + + definitions.Add(Builders.Filter.Or(canSign, inGrace)); + } if (filters.InGracePeriod is true) { From 95ea97e01af20b3ba692670156b5c0235ecd3956 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:00:06 -0300 Subject: [PATCH 18/47] feature(#22): this commit introduces "workers" namespace to usings for improved organization --- .../Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs index b82f8a5..e0471e0 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs @@ -44,6 +44,7 @@ global using HttpsRichardy.Federation.WebApi.Attributes; global using HttpsRichardy.Federation.WebApi.Binders; global using HttpsRichardy.Federation.WebApi.Providers; +global using HttpsRichardy.Federation.WebApi.Workers; global using HttpsRichardy.Federation.WebApi.Constants; global using HttpsRichardy.Federation.WebApi.Conventions; From 01afe36f7d69b42110a1f88192841ea08ebc7425 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:00:33 -0300 Subject: [PATCH 19/47] feature(#22): enhance JWT authentication to resolve signing keys based on realm and secret filters --- .../Extensions/AuthenticationExtension.cs | 67 ++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/AuthenticationExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/AuthenticationExtension.cs index 67c08ee..9bbf67c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/AuthenticationExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/AuthenticationExtension.cs @@ -6,21 +6,72 @@ public static class AuthenticationExtension public static IServiceCollection AddJwtAuthentication(this IServiceCollection services) { var serviceProvider = services.BuildServiceProvider(); - var secretRepository = serviceProvider.GetRequiredService(); + var accessor = serviceProvider.GetRequiredService(); - var secret = secretRepository.GetSecretAsync() - .GetAwaiter() - .GetResult(); - - var publicKey = Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); var validationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, - IssuerSigningKey = publicKey, - ClockSkew = TimeSpan.Zero + ClockSkew = TimeSpan.FromSeconds(30), + IssuerSigningKeyResolver = (token, _, kid, _) => + { + var context = accessor.HttpContext; + if (context is null || string.IsNullOrWhiteSpace(token)) + return []; + + var secretCollection = context.RequestServices.GetRequiredService(); + var realmCollection = context.RequestServices.GetRequiredService(); + + var jsonWebToken = new Microsoft.IdentityModel.JsonWebTokens.JsonWebToken(token); + var realmId = jsonWebToken.Claims.FirstOrDefault(claim => claim.Type == Infrastructure.Constants.IdentityClaimNames.RealmId)?.Value; + + if (string.IsNullOrWhiteSpace(realmId)) + { + var realmName = jsonWebToken.Claims.FirstOrDefault(claim => claim.Type == Infrastructure.Constants.IdentityClaimNames.Realm)?.Value; + + if (string.IsNullOrWhiteSpace(realmName)) + return []; + + var realmFilters = RealmFilters.WithSpecifications() + .WithName(realmName) + .Build(); + + var realms = realmCollection + .GetRealmsAsync(realmFilters, context.RequestAborted) + .GetAwaiter() + .GetResult(); + + realmId = realms.FirstOrDefault()?.Id; + } + + if (string.IsNullOrWhiteSpace(realmId)) + return []; + + var secretFilters = SecretFilters.WithSpecifications() + .WithRealm(realmId) + .WithCanValidate() + .Build(); + + var secrets = secretCollection + .GetSecretsAsync(secretFilters, context.RequestAborted) + .GetAwaiter() + .GetResult(); + + if (!string.IsNullOrWhiteSpace(kid)) + { + secrets = [.. secrets.Where(secret => secret.Id == kid)]; + } + + return [.. secrets.Select(secret => + { + var key = Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); + key.KeyId = secret.Id; + + return key; + })]; + } }; var builder = services.AddAuthentication(options => From ae2713387219de4ac9b2f8fba1f1085781be3f61 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:01:18 -0300 Subject: [PATCH 20/47] feature(#22): this commit introduces workers extension to register "key rotation background service" --- .../Extensions/WebInfrastructureExtension.cs | 1 + .../Extensions/WorkersExtension.cs | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WorkersExtension.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WebInfrastructureExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WebInfrastructureExtension.cs index 26b3ede..558550f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WebInfrastructureExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WebInfrastructureExtension.cs @@ -14,6 +14,7 @@ public static void AddWebComposition(this IServiceCollection services, IWebHostE services.AddProviders(); services.AddOpenApiSpecification(); services.AddRazorPages(); + services.AddWorkers(); services.AddFluentValidationAutoValidation(options => { options.DisableDataAnnotationsValidation = true; diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WorkersExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WorkersExtension.cs new file mode 100644 index 0000000..08c32c8 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WorkersExtension.cs @@ -0,0 +1,10 @@ +namespace HttpsRichardy.Federation.WebApi.Extensions; + +[ExcludeFromCodeCoverage] +public static class WorkersExtension +{ + public static void AddWorkers(this IServiceCollection services) + { + services.AddHostedService(); + } +} From cd4f978abc43f627c1ceecde4437e3d419e3377e Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:01:49 -0300 Subject: [PATCH 21/47] refactor(#22): this commit updates method to accept IReadOnlyCollection of secrets --- .../Mappers/JsonWebKeysMapper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs index c83fd98..c1e87b2 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs @@ -15,11 +15,11 @@ public static JsonWebKeyScheme AsJsonWebKeys(Secret secret) }; } - public static JsonWebKeySetScheme AsJsonWebKeySetScheme(Secret secret) + public static JsonWebKeySetScheme AsJsonWebKeySetScheme(IReadOnlyCollection secrets) { return new JsonWebKeySetScheme { - Keys = [JsonWebKeysMapper.AsJsonWebKeys(secret)] + Keys = [.. secrets.Select(secret => JsonWebKeysMapper.AsJsonWebKeys(secret))] }; } } From e6d2f63df128e4b742aad73c07c2564c67fbcb01 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:02:28 -0300 Subject: [PATCH 22/47] feature(#22): this commit updates token validation to support multiple public keys and enhance private key retrieval logic --- .../Security/JwtSecurityTokenService.cs | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs index 22ee8e1..0c68a0e 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs @@ -148,14 +148,14 @@ public async Task> GenerateRefreshTokenAsync(User user, Ca public async Task ValidateTokenAsync(SecurityToken token) { var tokenHandler = new JwtSecurityTokenHandler(); - var publicKey = await GetPublicKeyAsync(); + var publicKeys = await GetPublicKeyAsync(); var validationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, - IssuerSigningKey = publicKey, + IssuerSigningKeys = publicKeys, ValidateIssuerSigningKey = true, ClockSkew = TimeSpan.FromSeconds(30) }; @@ -214,13 +214,41 @@ public Task ValidateRefreshTokenAsync(SecurityToken token, CancellationT private async Task GetPrivateKeyAsync(CancellationToken cancellation = default) { - var secret = await secretCollection.GetSecretAsync(cancellation); - return Common.Utilities.RsaHelper.CreateSecurityKeyFromPrivateKey(secret.PrivateKey); + var realm = realmProvider.GetCurrentRealm(); + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanSign() + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + var secret = secrets + .OrderByDescending(secret => secret.CreatedAt) + .First(); + + var key = Common.Utilities.RsaHelper.CreateSecurityKeyFromPrivateKey(secret.PrivateKey); + + key.KeyId = secret.Id; + + return key; } - private async Task GetPublicKeyAsync(CancellationToken cancellation = default) + private async Task> GetPublicKeyAsync(CancellationToken cancellation = default) { - var secret = await secretCollection.GetSecretAsync(cancellation); - return Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); + var realm = realmProvider.GetCurrentRealm(); + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanValidate() + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + + return [.. secrets.Select(secret => + { + var key = Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); + + key.KeyId = secret.Id; + + return key; + })]; } } From 57135a8e2a99835eaeaaee45bad83e856226040b Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:05:56 -0300 Subject: [PATCH 23/47] feature(#22): this commit updates handler to include realm collection and enhance secret retrieval logic --- .../Connect/FetchJsonWebKeysHandler.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs index 2c19cf5..87b4b5b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs @@ -1,13 +1,30 @@ namespace HttpsRichardy.Federation.Application.Handlers.Connect; -public sealed class FetchJsonWebKeysHandler(ISecretCollection collection) : +public sealed class FetchJsonWebKeysHandler(ISecretCollection collection, IRealmCollection realmCollection) : IDispatchHandler> { public async Task> HandleAsync( FetchJsonWebKeysParameters parameters, CancellationToken cancellation = default) { - var secret = await collection.GetSecretAsync(cancellation: cancellation); - var jwks = JsonWebKeysMapper.AsJsonWebKeySetScheme(secret); + var realmFilters = RealmFilters.WithSpecifications() + .WithName(parameters.Realm) + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation); + var realm = realms.FirstOrDefault(); + + if (realm is null) + { + return Result.Failure(RealmErrors.RealmDoesNotExist); + } + + var filters = SecretFilters.WithSpecifications() + .WithCanValidate() + .WithRealm(realm.Id) + .Build(); + + var secrets = await collection.GetSecretsAsync(filters, cancellation); + var jwks = JsonWebKeysMapper.AsJsonWebKeySetScheme(secrets); return Result.Success(jwks); } From 7f6ee003b4d1cb05a691df39e005b79f8f9807c0 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:38:26 -0300 Subject: [PATCH 24/47] feature(#22): this commit update endpoint tests to include 'master' prefix in url requests --- .../Endpoints/WellKnownEndpointTests.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/WellKnownEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/WellKnownEndpointTests.cs index edd4bb1..281f628 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/WellKnownEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/WellKnownEndpointTests.cs @@ -10,7 +10,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldReturnConfiguration() var httpClient = factory.HttpClient; /* act: send GET request to open id configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -33,7 +33,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldIncludeSupportedResponseTypes var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -52,7 +52,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldIncludeSubjectTypes() var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -71,7 +71,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldIncludeSigningAlgorithms() var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -91,7 +91,7 @@ public async Task WhenGetJsonWebKeys_ShouldReturnJwks() var httpClient = factory.HttpClient; /* act: send GET request to JWKS endpoint */ - var response = await httpClient.GetAsync(".well-known/jwks.json"); + var response = await httpClient.GetAsync("master/.well-known/jwks.json"); var jwks = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -109,7 +109,7 @@ public async Task WhenGetJsonWebKeys_ShouldReturnKeysWithRequiredProperties() var httpClient = factory.HttpClient; /* act: send GET request to JWKS endpoint */ - var response = await httpClient.GetAsync(".well-known/jwks.json"); + var response = await httpClient.GetAsync("master/.well-known/jwks.json"); var jwks = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -143,10 +143,10 @@ public async Task WhenGetJsonWebKeysMultipleTimes_ShouldReturnConsistentKeys() var httpClient = factory.HttpClient; /* act: send GET request twice to JWKS endpoint */ - var firstResponse = await httpClient.GetAsync(".well-known/jwks.json"); + var firstResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); var firstJwks = await firstResponse.Content.ReadFromJsonAsync(); - var secondResponse = await httpClient.GetAsync(".well-known/jwks.json"); + var secondResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); var secondJwks = await secondResponse.Content.ReadFromJsonAsync(); /* assert: both responses should be 200 OK */ @@ -168,7 +168,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldHaveMatchingJwksUri() var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -192,7 +192,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldHaveValidEndpointUrls() var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ From 640e42ff4400e8731fd3c6cac365dd7fbdfc43a0 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:39:19 -0300 Subject: [PATCH 25/47] feature(#22): this commit updates method to include realm for JWKS URI generation --- .../Mappers/OpenIDMapper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs index f97aacb..06d2f1c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs @@ -2,13 +2,13 @@ namespace HttpsRichardy.Federation.Application.Mappers; public static class ConnectMapper { - public static OpenIDConfigurationScheme AsConfiguration(Uri baseUri) + public static OpenIDConfigurationScheme AsConfiguration(Uri baseUri, string realm) { var issuer = baseUri.GetLeftPart(UriPartial.Authority); var authorizeUri = new Uri(baseUri, OpenIDEndpoints.Authorize); var tokenUri = new Uri(baseUri, OpenIDEndpoints.Token); var userInfoUri = new Uri(baseUri, OpenIDEndpoints.UserInfo); - var jwksUri = new Uri(baseUri, OpenIDEndpoints.Jwks); + var jwksUri = new Uri(baseUri, $"{realm}/{OpenIDEndpoints.Jwks}"); var configuration = new OpenIDConfigurationScheme { From 83e93733fe0ed4db3d2bd480e500c997284ecfaf Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:39:46 -0300 Subject: [PATCH 26/47] feature(#22): this commit updates handler to include realm collection and enhance configuration retrieval logic --- .../FetchOpenIDConfigurationsHandler.cs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchOpenIDConfigurationsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchOpenIDConfigurationsHandler.cs index c0de081..f72a71d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchOpenIDConfigurationsHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchOpenIDConfigurationsHandler.cs @@ -1,13 +1,25 @@ namespace HttpsRichardy.Federation.Application.Handlers.Connect; -public sealed class FetchOpenIDConfigurationHandler(IHostInformationProvider host) : +public sealed class FetchOpenIDConfigurationHandler(IHostInformationProvider host, IRealmCollection realmCollection) : IDispatchHandler> { - public Task> HandleAsync( + public async Task> HandleAsync( FetchOpenIDConfigurationParameters parameters, CancellationToken cancellation = default) { - var configuration = ConnectMapper.AsConfiguration(host.Address); + var realmFilters = RealmFilters.WithSpecifications() + .WithName(parameters.Realm) + .Build(); - return Task.FromResult(Result.Success(configuration)); + var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation); + var realm = realms.FirstOrDefault(); + + if (realm is null) + { + return Result.Failure(RealmErrors.RealmDoesNotExist); + } + + var configuration = ConnectMapper.AsConfiguration(host.Address, parameters.Realm); + + return Result.Success(configuration); } } From a6c29515b15b7ea4bde88cef8db0de5c02b19e79 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 15:40:19 -0300 Subject: [PATCH 27/47] feature(#22): this commit refactor to include secret rotation service and ensure secret exists during realm creation --- .../Handlers/Realm/RealmCreationHandler.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs index 63d9641..f8d413d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs @@ -1,6 +1,9 @@ namespace HttpsRichardy.Federation.Application.Handlers.Realm; -public sealed class RealmCreationHandler(IRealmCollection collection, IClientCredentialsGenerator credentialsGenerator) : +public sealed class RealmCreationHandler( + IRealmCollection collection, + IClientCredentialsGenerator credentialsGenerator, + ISecretRotationService secretRotationService) : IDispatchHandler> { public async Task> HandleAsync( @@ -31,6 +34,7 @@ public async Task> HandleAsync( .ToList(); await collection.InsertAsync(realm, cancellation: cancellation); + await secretRotationService.EnsureSecretExistsAsync(realm, cancellation); return Result.Success(RealmMapper.AsResponse(realm)); } From d46793b2bac4d9e2fc2d4bd910f440784e1bf47b Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 16:05:08 -0300 Subject: [PATCH 28/47] feature(#22): this commit removes the private `scope factory` field and directly uses the `scope factory` parameter in the constructor and in internal references. --- .../Workers/KeyRotationBackgroundService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs index b2d12f3..ee4f13b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs @@ -1,12 +1,12 @@ namespace HttpsRichardy.Federation.WebApi.Workers; -public sealed class KeyRotationBackgroundService(IServiceScopeFactory _scopeFactory, ILogger logger) : BackgroundService +public sealed class KeyRotationBackgroundService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { - using var scope = _scopeFactory.CreateScope(); + using var scope = scopeFactory.CreateScope(); var rotationService = scope.ServiceProvider.GetRequiredService(); var realmCollection = scope.ServiceProvider.GetRequiredService(); From 4ebbd5ef4f86150d9f5b8aa2120bb53e2652de97 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 17:01:36 -0300 Subject: [PATCH 29/47] tests(#22): this commit introduces tests for signature key rotation --- .../Security/KeyRotationIntegrationTests.cs | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs diff --git a/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs b/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs new file mode 100644 index 0000000..3e73814 --- /dev/null +++ b/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs @@ -0,0 +1,190 @@ +using System.Text.Json; + +namespace HttpsRichardy.Federation.TestSuite.Integration.Security; + +public sealed class KeyRotationIntegrationTests(IntegrationEnvironmentFixture factory) : + IClassFixture +{ + [Fact(DisplayName = "[e2e] - when rotating keys should publish new kid on realm jwks")] + public async Task WhenRotateKeys_ShouldPublishNewKidOnRealmJwks() + { + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var rotationService = factory.Services.GetRequiredService(); + + var authBefore = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authentication = await authBefore.Content.ReadFromJsonAsync(); + + Assert.NotNull(authentication); + Assert.False(string.IsNullOrWhiteSpace(authentication.AccessToken)); + + var handlerBefore = new JwtSecurityTokenHandler(); + + var jwtBefore = handlerBefore.ReadJwtToken(authentication.AccessToken); + var kidBefore = jwtBefore.Header.Kid; + + Assert.False(string.IsNullOrWhiteSpace(kidBefore)); + + var realmCollection = factory.Services.GetRequiredService(); + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + await rotationService.RotateSecretAsync(realm, CancellationToken.None); + + string? kidAfter = null; + + var started = DateTimeOffset.UtcNow; + + while (DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(10)) + { + var authAfter = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authenticationResult = await authAfter.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + + var handlerAfter = new JwtSecurityTokenHandler(); + var jwtAfter = handlerAfter.ReadJwtToken(authenticationResult.AccessToken); + + kidAfter = jwtAfter.Header.Kid; + + if (!string.Equals(kidAfter, kidBefore, StringComparison.Ordinal)) + break; + + await Task.Delay(300); + } + + Assert.False(string.IsNullOrWhiteSpace(kidAfter)); + Assert.NotEqual(kidBefore, kidAfter); + + var jwksResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); + var jwksRaw = await jwksResponse.Content.ReadAsStringAsync(); + + using var jwks = JsonDocument.Parse(jwksRaw); + + Assert.True(jwks.RootElement.TryGetProperty("keys", out var keys)); + Assert.Equal(JsonValueKind.Array, keys.ValueKind); + + var jwksKids = keys.EnumerateArray() + .Where(key => key.TryGetProperty("kid", out _)) + .Select(key => key.GetProperty("kid").GetString()) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Cast() + .ToArray(); + + Assert.Contains(kidAfter!, jwksKids); + } + + [Fact(DisplayName = "[e2e] - when rotating keys should keep old token valid while old key is published")] + public async Task WhenRotateKeys_ShouldKeepOldTokenValidWhileOldKeyIsPublished() + { + var httpClient = factory.HttpClient.WithRealmHeader("master"); + + var rotationService = factory.Services.GetRequiredService(); + var realmCollection = factory.Services.GetRequiredService(); + + var authBefore = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authentication = await authBefore.Content.ReadFromJsonAsync(); + + Assert.NotNull(authentication); + Assert.NotEmpty(authentication.AccessToken); + + var oldToken = authentication.AccessToken; + var oldJwt = new JwtSecurityTokenHandler().ReadJwtToken(oldToken); + var oldKid = oldJwt.Header.Kid; + + Assert.False(string.IsNullOrWhiteSpace(oldKid)); + + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + await rotationService.RotateSecretAsync(realm, CancellationToken.None); + + string? newKid = null; + var started = DateTimeOffset.UtcNow; + + while (DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(10)) + { + var authAfter = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authPayloadAfter = await authAfter.Content.ReadFromJsonAsync(); + + Assert.NotNull(authPayloadAfter); + + var jwtAfter = new JwtSecurityTokenHandler().ReadJwtToken(authPayloadAfter.AccessToken); + + newKid = jwtAfter.Header.Kid; + + if (!string.Equals(oldKid, newKid, StringComparison.Ordinal)) + break; + + await Task.Delay(300); + } + + Assert.False(string.IsNullOrWhiteSpace(newKid)); + + var jwksResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); + var jwksRaw = await jwksResponse.Content.ReadAsStringAsync(); + + using var jwks = JsonDocument.Parse(jwksRaw); + + Assert.True(jwks.RootElement.TryGetProperty("keys", out var keys)); + + var signingKeys = keys.EnumerateArray() + .Select(key => new JsonWebKey(key.GetRawText()) as SecurityKey) + .ToArray(); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKeys = signingKeys, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + RequireSignedTokens = true + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var validationSucceeded = true; + + try + { + tokenHandler.ValidateToken(oldToken, validationParameters, out _); + } + catch + { + validationSucceeded = false; + } + + Assert.True(validationSucceeded); + } +} From 75ef22955a8425fc3a3288fbf438fe76860ef35e Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 17:19:23 -0300 Subject: [PATCH 30/47] feature(#22): this commit updates the secret expiration logic --- .../Security/SecretRotationService.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs index 0a8a0fa..53178e6 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs @@ -88,9 +88,6 @@ public async Task RotateSecretAsync(Realm realm, CancellationToken cancellation return; } - if (current.ExpiresAt is not null && current.ExpiresAt > now) - return; - current.ExpiresAt = now; current.GracePeriodEndsAt = now.Add(_gracePeriod); From fe4d115d58ed9c47b382b212e7ef1e0f95fa1b6e Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 17:20:01 -0300 Subject: [PATCH 31/47] feature(#22): this commit adds end-to-end tests for key rotation and key purging --- .../Security/KeyRotationIntegrationTests.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs b/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs index 3e73814..f7017e8 100644 --- a/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs +++ b/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs @@ -187,4 +187,160 @@ public async Task WhenRotateKeys_ShouldKeepOldTokenValidWhileOldKeyIsPublished() Assert.True(validationSucceeded); } + + [Fact(DisplayName = "[e2e] - when rotating keys jwks should contain multiple keys during grace period")] + public async Task WhenRotateKeys_JwksShouldContainMultipleKeysDuringGracePeriod() + { + var httpClient = factory.HttpClient.WithRealmHeader("master"); + + var rotationService = factory.Services.GetRequiredService(); + var realmCollection = factory.Services.GetRequiredService(); + + var jwksResponseBefore = await httpClient.GetAsync("master/.well-known/jwks.json"); + var jwksRawBefore = await jwksResponseBefore.Content.ReadAsStringAsync(); + + using var jwksBefore = JsonDocument.Parse(jwksRawBefore); + + Assert.NotNull(jwksBefore); + Assert.True(jwksBefore.RootElement.TryGetProperty("keys", out var keysBefore)); + + var oldKidsCount = keysBefore.EnumerateArray() + .Where(key => key.TryGetProperty("kid", out _)) + .Count(); + + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + await rotationService.RotateSecretAsync(realm, CancellationToken.None); + + string? newKid = null; + var started = DateTimeOffset.UtcNow; + + while (DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(10)) + { + var response = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authentication = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(authentication); + Assert.NotEmpty(authentication.AccessToken); + + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(authentication.AccessToken); + + newKid = jwt.Header.Kid; + + var jwksResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); + var jwksRaw = await jwksResponse.Content.ReadAsStringAsync(); + + using var jwks = JsonDocument.Parse(jwksRaw); + + Assert.True(jwks.RootElement.TryGetProperty("keys", out var keysAfter)); + + var newKidsCount = keysAfter.EnumerateArray() + .Where(key => key.TryGetProperty("kid", out _)) + .Count(); + + if (newKidsCount > oldKidsCount) + { + var jwksKids = keysAfter.EnumerateArray() + .Where(key => key.TryGetProperty("kid", out _)) + .Select(key => key.GetProperty("kid").GetString()) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Cast() + .ToArray(); + + Assert.NotEmpty(jwksKids); + Assert.True(jwksKids.Length >= 2, "JWKS should contain at least 2 keys during grace period"); + + return; + } + + await Task.Delay(300); + } + + Assert.Fail("JWKS never showed multiple keys during grace period"); + } + + [Fact(DisplayName = "[e2e] - when grace period expires old key should be removed from database")] + public async Task WhenGracePeriodExpires_ShouldRemoveOldKeyFromDatabase() + { + var httpClient = factory.HttpClient.WithRealmHeader("master"); + + var rotationService = factory.Services.GetRequiredService(); + var realmCollection = factory.Services.GetRequiredService(); + var secretCollection = factory.Services.GetRequiredService(); + + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + var secretFiltersBefore = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .Build(); + + var secretsBefore = await secretCollection.GetSecretsAsync(secretFiltersBefore, CancellationToken.None); + var countBefore = secretsBefore.Count; + + await rotationService.RotateSecretAsync(realm, CancellationToken.None); + + var started = DateTimeOffset.UtcNow; + while (DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(10)) + { + var auth = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authResult = await auth.Content.ReadFromJsonAsync(); + + Assert.NotNull(authResult); + + var secretsAfterRotation = await secretCollection.GetSecretsAsync(secretFiltersBefore, CancellationToken.None); + var countAfterRotation = secretsAfterRotation.Count; + + if (countAfterRotation > countBefore) + { + var oldSecret = secretsAfterRotation.FirstOrDefault(secret => secret.GracePeriodEndsAt is not null); + + Assert.NotNull(oldSecret); + Assert.NotNull(oldSecret.GracePeriodEndsAt); + + oldSecret.GracePeriodEndsAt = DateTime.UtcNow.AddSeconds(-1); + + await secretCollection.UpdateAsync(oldSecret, cancellation: CancellationToken.None); + await rotationService.PruneSecretsAsync(realm, CancellationToken.None); + + var secretsAfterPrune = await secretCollection.GetSecretsAsync(secretFiltersBefore, CancellationToken.None); + var countAfterPrune = secretsAfterPrune.Count; + + Assert.True(countAfterPrune <= countAfterRotation); + + var removedSecret = secretsAfterPrune.FirstOrDefault(secret => secret.Id == oldSecret.Id); + + Assert.Null(removedSecret); + + return; + } + + await Task.Delay(300); + } + + Assert.Fail("Grace period test timeout: rotation did not complete in time"); + } } From 495920d9e13e66fdf69f53e9c112ca86ad986440 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 17:26:30 -0300 Subject: [PATCH 32/47] feature(#22): this commit makes System.Text.Json a global using statement in the project --- .../Tests/Integration/Security/KeyRotationIntegrationTests.cs | 2 -- Applications/Backend/Tests/Usings.cs | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs b/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs index f7017e8..dd4500d 100644 --- a/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs +++ b/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs @@ -1,5 +1,3 @@ -using System.Text.Json; - namespace HttpsRichardy.Federation.TestSuite.Integration.Security; public sealed class KeyRotationIntegrationTests(IntegrationEnvironmentFixture factory) : diff --git a/Applications/Backend/Tests/Usings.cs b/Applications/Backend/Tests/Usings.cs index 4353b94..50325b1 100644 --- a/Applications/Backend/Tests/Usings.cs +++ b/Applications/Backend/Tests/Usings.cs @@ -1,6 +1,7 @@ global using System.Net; global using System.Net.Http.Headers; global using System.Net.Http.Json; +global using System.Text.Json; global using System.IdentityModel.Tokens.Jwt; global using System.Security.Cryptography; From 34ee80c7d65f3981236136fd0b1d4dbf989a2b49 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:33:20 -0300 Subject: [PATCH 33/47] feature(#22): this commit introduces a handler to retrieve secrets from the realm --- .../Secret/FetchRealmSecretsHandler.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs new file mode 100644 index 0000000..5923d71 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs @@ -0,0 +1,20 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Secret; + +public sealed class FetchRealmSecretsHandler(ISecretCollection collection) : + IDispatchHandler>> +{ + public async Task>> HandleAsync( + FetchRealmSecretsParameters parameters, CancellationToken cancellation = default) + { + var filters = SecretFilters.WithSpecifications() + .WithRealm(parameters.RealmId) + .Build(); + + var secrets = await collection.GetSecretsAsync(filters, cancellation); + var schemes = secrets.Select(secret => secret.AsResponse()) + .ToList() + .AsReadOnly(); + + return Result>.Success(schemes); + } +} From df5c28d287b80d3c1da75d190d9311d1f6bed5a2 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:34:28 -0300 Subject: [PATCH 34/47] =?UTF-8?q?feature(#22):=20this=20commit=20introduce?= =?UTF-8?q?s=20a=20=E2=80=9Csecret=20mapper=E2=80=9D=20with=20an=20extensi?= =?UTF-8?q?on=20method=20to=20convert=20domain=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Mappers/SecretMapper.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/SecretMapper.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/SecretMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/SecretMapper.cs new file mode 100644 index 0000000..c1fe781 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/SecretMapper.cs @@ -0,0 +1,12 @@ +namespace HttpsRichardy.Federation.Application.Mappers; + +public static class SecretMapper +{ + public static SecretScheme AsResponse(this Secret secret) => new() + { + Id = secret.Id, + CreatedAt = secret.CreatedAt, + ExpiresAt = secret.ExpiresAt, + GracePeriodEndsAt = secret.GracePeriodEndsAt + }; +} From d302ef29395c8db126e0998929cdb679c36013a9 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:35:40 -0300 Subject: [PATCH 35/47] feature(#22): this commit introduces the schemes for retrieving secrets from a realm --- .../Payloads/Secret/FetchRealmSecretsParameters.cs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/FetchRealmSecretsParameters.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/FetchRealmSecretsParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/FetchRealmSecretsParameters.cs new file mode 100644 index 0000000..080d705 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/FetchRealmSecretsParameters.cs @@ -0,0 +1,7 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Secret; + +public sealed record FetchRealmSecretsParameters : + IDispatchable>> +{ + public string RealmId { get; init; } = default!; +} From d62ea64d5297e1d99967d76de14f0fc44e2b96e1 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:37:07 -0300 Subject: [PATCH 36/47] =?UTF-8?q?feature(#22):=20this=20commit=20introduce?= =?UTF-8?q?s=20the=20=E2=80=9Csecret=20scheme=E2=80=9D=20class=20for=20sec?= =?UTF-8?q?ret=20management=20to=20represent=20secret=20information=20and?= =?UTF-8?q?=20its=20expiration=20dates.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Payloads/Secret/SecretScheme.cs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/SecretScheme.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/SecretScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/SecretScheme.cs new file mode 100644 index 0000000..6d5d222 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/SecretScheme.cs @@ -0,0 +1,9 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Secret; + +public sealed record SecretScheme +{ + public string Id { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public DateTime? GracePeriodEndsAt { get; set; } +} From 5c78940406f592b6954851c6cf80e535b5d283a3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:38:27 -0300 Subject: [PATCH 37/47] =?UTF-8?q?feature(#22):=20this=20commit=20includes?= =?UTF-8?q?=20new=20=E2=80=9Cglobal=20using=E2=80=9D=20directives=20that?= =?UTF-8?q?=20make=20it=20easier=20to=20use=20types=20from=20this=20namesp?= =?UTF-8?q?ace=20throughout=20the=20project.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Source/HttpsRichardy.Federation.Application/Usings.cs | 1 + .../Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Usings.cs index e3eb8ba..c8d328d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Usings.cs @@ -20,6 +20,7 @@ global using HttpsRichardy.Federation.Application.Payloads.Group; global using HttpsRichardy.Federation.Application.Payloads.Permission; global using HttpsRichardy.Federation.Application.Payloads.Realm; +global using HttpsRichardy.Federation.Application.Payloads.Secret; global using HttpsRichardy.Federation.Application.Payloads.User; global using HttpsRichardy.Federation.Application.Payloads.Client; global using HttpsRichardy.Federation.Application.Payloads.Connect; diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs index e0471e0..852de72 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs @@ -30,6 +30,7 @@ global using HttpsRichardy.Federation.Application.Payloads.Authorization; global using HttpsRichardy.Federation.Application.Payloads.Permission; global using HttpsRichardy.Federation.Application.Payloads.Realm; +global using HttpsRichardy.Federation.Application.Payloads.Secret; global using HttpsRichardy.Federation.Application.Payloads.User; global using HttpsRichardy.Federation.Application.Payloads.Connect; global using HttpsRichardy.Federation.Application.Payloads.Client; From d5afab9f4719af51831d6b136ba70d302d86ec4d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:48:42 -0300 Subject: [PATCH 38/47] feature(#22): this commit introduces validation to ensure that the realm exists before fetching secrets. --- .../Handlers/Secret/FetchRealmSecretsHandler.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs index 5923d71..b458b2e 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs @@ -1,11 +1,23 @@ namespace HttpsRichardy.Federation.Application.Handlers.Secret; -public sealed class FetchRealmSecretsHandler(ISecretCollection collection) : +public sealed class FetchRealmSecretsHandler(ISecretCollection collection, IRealmCollection realmCollection) : IDispatchHandler>> { public async Task>> HandleAsync( FetchRealmSecretsParameters parameters, CancellationToken cancellation = default) { + var realmFilters = RealmFilters.WithSpecifications() + .WithIdentifier(parameters.RealmId) + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation); + var realm = realms.FirstOrDefault(); + + if (realm is null) + { + return Result>.Failure(RealmErrors.RealmDoesNotExist); + } + var filters = SecretFilters.WithSpecifications() .WithRealm(parameters.RealmId) .Build(); From 659c70ec58c6cea74e857484aa93c6044583ddf8 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:49:43 -0300 Subject: [PATCH 39/47] feature(#22): this commit introduces an endpoint to list secrets in a realm, allowing you to retrieve secrets from a specific realm with the appropriate authorization. --- .../Controllers/RealmsController.cs | 17 +++++++++++++++++ .../Conventions/RealmsConventions.cs | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs index cd325e4..a40ce72 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs @@ -72,6 +72,23 @@ public async Task DeleteRealmAsync([FromRoute] string id, [FromQu }; } + [HttpGet("{id}/secrets")] + [Authorize(Roles = Permissions.ViewRealms)] + [Stability(Stability.Stable)] + public async Task GetRealmSecretsAsync([FromRoute] string id, [FromQuery] FetchRealmSecretsParameters request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request with { RealmId = id }, cancellation); + + return result switch + { + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == RealmErrors.RealmDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + }; + } + [HttpGet("{id}/permissions")] [Authorize(Roles = Permissions.ViewPermissions)] [Stability(Stability.Stable)] diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs index 2fe098b..a734f0b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs @@ -23,6 +23,11 @@ public static void UpdateRealmAsync(string id, RealmUpdateScheme request, Cancel [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] public static void DeleteRealmAsync(string id, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + public static void GetRealmSecretsAsync(string id, FetchRealmSecretsParameters request, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] From 4095c3590ebe6f11b069e33604f762f6230f429a Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:56:35 -0300 Subject: [PATCH 40/47] feature(#22): this commit introduces a handler for rotating realm secrets to rotate the secrets of a specific realm, using the rotation service and validating the realm's existence. --- .../Secret/RotateRealmSecretsHandler.cs | 25 +++++++++++++++++++ .../Secret/RotateRealmSecretsParameters.cs | 7 ++++++ 2 files changed, 32 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/RotateRealmSecretsHandler.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/RotateRealmSecretsParameters.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/RotateRealmSecretsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/RotateRealmSecretsHandler.cs new file mode 100644 index 0000000..afa07d6 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/RotateRealmSecretsHandler.cs @@ -0,0 +1,25 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Secret; + +public sealed class RotateRealmSecretsHandler(ISecretRotationService rotationService, IRealmCollection realmCollection) : + IDispatchHandler +{ + public async Task HandleAsync( + RotateRealmSecretsParameters parameters, CancellationToken cancellation = default) + { + var realmFilters = RealmFilters.WithSpecifications() + .WithIdentifier(parameters.RealmId) + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation); + var realm = realms.FirstOrDefault(); + + if (realm is null) + { + return Result.Failure(RealmErrors.RealmDoesNotExist); + } + + await rotationService.RotateSecretAsync(realm, cancellation); + + return Result.Success(); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/RotateRealmSecretsParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/RotateRealmSecretsParameters.cs new file mode 100644 index 0000000..7f7c577 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/RotateRealmSecretsParameters.cs @@ -0,0 +1,7 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Secret; + +public sealed record RotateRealmSecretsParameters : + IDispatchable +{ + public string RealmId { get; init; } = default!; +} From 821ac92822597d7b399f17d2b57d056c3619d3ba Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:57:24 -0300 Subject: [PATCH 41/47] feature(#22): this commit introduces an endpoint for rotating realm secrets --- .../Controllers/RealmsController.cs | 18 ++++++++++++++++++ .../Conventions/RealmsConventions.cs | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs index a40ce72..5ddf7a0 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs @@ -89,6 +89,24 @@ public async Task GetRealmSecretsAsync([FromRoute] string id, [Fr }; } + [HttpPost("{id}/secrets/rotate")] + [Authorize(Roles = Permissions.EditRealm)] + [Stability(Stability.Stable)] + public async Task RotateRealmSecretsAsync([FromRoute] string id, CancellationToken cancellation) + { + var request = new RotateRealmSecretsParameters { RealmId = id }; + var result = await dispatcher.DispatchAsync(request, cancellation); + + return result switch + { + { IsSuccess: true } => + StatusCode(StatusCodes.Status204NoContent), + + { IsFailure: true } when result.Error == RealmErrors.RealmDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + }; + } + [HttpGet("{id}/permissions")] [Authorize(Roles = Permissions.ViewPermissions)] [Stability(Stability.Stable)] diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs index a734f0b..0fcadf0 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs @@ -28,6 +28,11 @@ public static void DeleteRealmAsync(string id, CancellationToken cancellation) { [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] public static void GetRealmSecretsAsync(string id, FetchRealmSecretsParameters request, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + public static void RotateRealmSecretsAsync(string id, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] From df8d15ec81a2c38ef207c2fa1012522bbe5f806f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 20:59:33 -0300 Subject: [PATCH 42/47] feature(#22): secrets are now sorted by the `created at` field in descending order before being converted to the response format, ensuring that the most recent ones are returned first. --- .../Handlers/Secret/FetchRealmSecretsHandler.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs index b458b2e..a7de5f6 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs @@ -23,7 +23,9 @@ public async Task>> HandleAsync( .Build(); var secrets = await collection.GetSecretsAsync(filters, cancellation); - var schemes = secrets.Select(secret => secret.AsResponse()) + var schemes = secrets + .OrderByDescending(secret => secret.CreatedAt) + .Select(secret => secret.AsResponse()) .ToList() .AsReadOnly(); From 8e18f8e19461c9b44b6a875129ddd875dce926ef Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 21:32:04 -0300 Subject: [PATCH 43/47] feature(#22): this commit includes four integration tests for the realm secrets endpoints, covering both success and error scenarios. It also adds a global `using` statement for `Payloads.Secret` in `Us --- .../Endpoints/RealmEndpointTests.cs | 166 ++++++++++++++++++ Applications/Backend/Tests/Usings.cs | 1 + 2 files changed, 167 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Endpoints/RealmEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/RealmEndpointTests.cs index fa84b6f..5ecae4e 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/RealmEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/RealmEndpointTests.cs @@ -447,4 +447,170 @@ public async Task WhenDeleteRealmPermissionWithNonExistentPermission_ShouldRetur Assert.Equal(HttpStatusCode.NotFound, httpResponse.StatusCode); Assert.Equal(PermissionErrors.PermissionDoesNotExist, error); } + + [Fact(DisplayName = "[e2e] - when GET /realms/{id}/secrets should return realm's active secrets")] + public async Task WhenGetRealmSecrets_ShouldReturnActiveSecrets() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: create a new realm */ + var realmPayload = _fixture.Build() + .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") + .Create(); + + var realmResponse = await httpClient.PostAsJsonAsync("api/v1/realms", realmPayload); + var realm = await realmResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(realm); + Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); + + /* act: send GET request to retrieve realm's secrets */ + var getResponse = await httpClient.GetAsync($"api/v1/realms/{realm.Id}/secrets"); + var secrets = await getResponse.Content.ReadFromJsonAsync>(); + + /* assert: response should be 200 OK */ + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + Assert.NotNull(secrets); + + /* assert: should have at least one active secret */ + Assert.NotEmpty(secrets); + + /* assert: verify secret structure (no private/public key values) */ + foreach (var secret in secrets) + { + Assert.NotNull(secret.Id); + Assert.True(secret.CreatedAt != default); + } + } + + [Fact(DisplayName = "[e2e] - when GET /realms/{id}/secrets with non-existent realm should return 404 #ERROR-2FB9A")] + public async Task WhenGetRealmSecretsWithNonExistentRealm_ShouldReturnNotFound() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: prepare request with a non-existent realm ID */ + var nonExistentRealmId = Guid.NewGuid().ToString(); + + /* act: send GET request for non-existent realm's secrets */ + var response = await httpClient.GetAsync($"api/v1/realms/{nonExistentRealmId}/secrets"); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(RealmErrors.RealmDoesNotExist.Code, error.Code); + } + + [Fact(DisplayName = "[e2e] - when POST /realms/{id}/secrets/rotate should rotate secrets successfully")] + public async Task WhenPostRealmSecretsRotate_ShouldRotateSecretsSuccessfully() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: create a new realm */ + var realmPayload = _fixture.Build() + .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") + .Create(); + + var realmResponse = await httpClient.PostAsJsonAsync("api/v1/realms", realmPayload); + var realm = await realmResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(realm); + Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); + + /* arrange: get secrets before rotation */ + var getBeforeResponse = await httpClient.GetAsync($"api/v1/realms/{realm.Id}/secrets"); + var secretsBefore = await getBeforeResponse.Content.ReadFromJsonAsync>(); + + Assert.NotNull(secretsBefore); + var initialSecretCount = secretsBefore.Count; + + /* act: send POST request to rotate secrets */ + var rotateResponse = await httpClient.PostAsJsonAsync($"api/v1/realms/{realm.Id}/secrets/rotate", new { }); + + /* assert: response should be 204 No Content */ + Assert.Equal(HttpStatusCode.NoContent, rotateResponse.StatusCode); + + /* assert: verify new secret was created */ + var getAfterResponse = await httpClient.GetAsync($"api/v1/realms/{realm.Id}/secrets"); + var secretsAfter = await getAfterResponse.Content.ReadFromJsonAsync>(); + + Assert.NotNull(secretsAfter); + Assert.True(secretsAfter.Count >= initialSecretCount, "New secret should be created after rotation"); + } + + [Fact(DisplayName = "[e2e] - when POST /realms/{id}/secrets/rotate with non-existent realm should return 404 #ERROR-2FB9A")] + public async Task WhenPostRealmSecretsRotateWithNonExistentRealm_ShouldReturnNotFound() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: prepare request with a non-existent realm ID */ + var nonExistentRealmId = Guid.NewGuid().ToString(); + + /* act: send POST request to rotate secrets for non-existent realm */ + var response = await httpClient.PostAsJsonAsync($"api/v1/realms/{nonExistentRealmId}/secrets/rotate", new { }); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(RealmErrors.RealmDoesNotExist, error); + } } diff --git a/Applications/Backend/Tests/Usings.cs b/Applications/Backend/Tests/Usings.cs index 50325b1..0733770 100644 --- a/Applications/Backend/Tests/Usings.cs +++ b/Applications/Backend/Tests/Usings.cs @@ -29,6 +29,7 @@ global using HttpsRichardy.Federation.Application.Payloads.Realm; global using HttpsRichardy.Federation.Application.Payloads.Permission; global using HttpsRichardy.Federation.Application.Payloads.Group; +global using HttpsRichardy.Federation.Application.Payloads.Secret; global using HttpsRichardy.Federation.Application.Payloads.Common; global using HttpsRichardy.Federation.Application.Payloads.Connect; global using HttpsRichardy.Federation.Application.Payloads.Client; From daf820b472f0c0ded314f6c98efb60e4c06ff20f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 22:48:56 -0300 Subject: [PATCH 44/47] feature(#22): this commit introduces secret rotation service integration to JWT token handling and tests --- .../Security/JwtSecurityTokenService.cs | 13 ++++++++++++- .../Security/SecretRotationService.cs | 3 ++- .../Security/AuthenticationServiceTests.cs | 2 ++ .../Security/JwtSecurityTokenServiceTests.cs | 2 ++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs index 0c68a0e..0e7450e 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs @@ -5,6 +5,7 @@ public sealed class JwtSecurityTokenService( ITokenCollection tokenCollection, IRealmProvider realmProvider, IGroupCollection groupCollection, + ISecretRotationService secretRotationService, IHostInformationProvider host ) : ISecurityTokenService { @@ -223,7 +224,17 @@ private async Task GetPrivateKeyAsync(CancellationToken cancella var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); var secret = secrets .OrderByDescending(secret => secret.CreatedAt) - .First(); + .FirstOrDefault(); + + if (secret is null) + { + await secretRotationService.EnsureSecretExistsAsync(realm, cancellation); + + secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + secret = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault() ?? throw new InvalidOperationException($"no signing key available for realm '{realm.Id}'."); + } var key = Common.Utilities.RsaHelper.CreateSecurityKeyFromPrivateKey(secret.PrivateKey); diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs index 53178e6..e04a5d3 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs @@ -88,10 +88,11 @@ public async Task RotateSecretAsync(Realm realm, CancellationToken cancellation return; } + await CreateSecretAsync(realm, cancellation); + current.ExpiresAt = now; current.GracePeriodEndsAt = now.Add(_gracePeriod); await secretCollection.UpdateAsync(current, cancellation: cancellation); - await CreateSecretAsync(realm, cancellation); } } diff --git a/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs b/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs index 1a59fb8..bc817cd 100644 --- a/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs +++ b/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs @@ -15,6 +15,7 @@ public sealed class AuthenticationServiceTests : private readonly Mock _realmProvider = new(); private readonly Mock _hostProvider = new(); private readonly Mock _secretCollection = new(); + private readonly Mock _secretRotationService = new(); private readonly Mock _groupCollection = new(); public AuthenticationServiceTests(MongoDatabaseFixture mongoFixture) @@ -56,6 +57,7 @@ public AuthenticationServiceTests(MongoDatabaseFixture mongoFixture) tokenCollection: tokenCollection, realmProvider: _realmProvider.Object, groupCollection: _groupCollection.Object, + secretRotationService: _secretRotationService.Object, host: _hostProvider.Object ); diff --git a/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs b/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs index 7ade8ca..cc75eee 100644 --- a/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs +++ b/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs @@ -11,6 +11,7 @@ public sealed class JwtSecurityTokenServiceTests : IClassFixture _realmProvider = new(); private readonly Mock _secretCollection = new(); + private readonly Mock _secretRotationService = new(); private readonly Mock _hostProvider = new(); private readonly Mock _groupCollection = new(); @@ -50,6 +51,7 @@ public JwtSecurityTokenServiceTests(MongoDatabaseFixture fixture) realmProvider: _realmProvider.Object, secretCollection: _secretCollection.Object, groupCollection: _groupCollection.Object, + secretRotationService: _secretRotationService.Object, tokenCollection: _tokenCollection, host: _hostProvider.Object ); From 329b8995cc3715e019775ecfd72b5a95dc7c3cd7 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 21 Apr 2026 23:16:35 -0300 Subject: [PATCH 45/47] feature(#22): this commit introduces metadata address configuration for JWT bearer authentication --- .../Federation.Sdk/Source/Extensions/AuthenticationExtension.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs b/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs index dc8dee3..38f55f8 100644 --- a/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs +++ b/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs @@ -11,6 +11,7 @@ public static void AddBearerAuthentication(this IServiceCollection services) .AddJwtBearer(configuration => { configuration.Authority = options.Authority; + configuration.MetadataAddress = $"{options.Authority}/{options.Realm}/.well-known/openid-configuration"; configuration.RequireHttpsMetadata = false; configuration.TokenValidationParameters = new TokenValidationParameters { From d71cdf7d9b96891a440049959352a52296669f88 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 22 Apr 2026 16:41:07 -0300 Subject: [PATCH 46/47] feature(#22): enhance key rotation logic to ensure valid signing keys and prune outdated secrets --- .../Workers/KeyRotationBackgroundService.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs index ee4f13b..f7e6c9f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs @@ -2,6 +2,8 @@ namespace HttpsRichardy.Federation.WebApi.Workers; public sealed class KeyRotationBackgroundService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { + private static readonly TimeSpan _rotationInterval = TimeSpan.FromHours(24); + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) @@ -10,6 +12,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var rotationService = scope.ServiceProvider.GetRequiredService(); var realmCollection = scope.ServiceProvider.GetRequiredService(); + var secretCollection = scope.ServiceProvider.GetRequiredService(); var realms = await realmCollection.GetRealmsAsync(RealmFilters.WithoutFilters, stoppingToken); @@ -19,10 +22,24 @@ await Parallel.ForEachAsync(realms, stoppingToken, async (realm, cancellation) = { logger.LogInformation("rotating keys for realm {realm}", realm.Name); - /* !important: ensures the realm has at least one valid signing key before rotation */ await rotationService.EnsureSecretExistsAsync(realm, cancellation); - await rotationService.RotateSecretAsync(realm, cancellation); + var now = DateTime.UtcNow; + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanSign(now) + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + var current = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault(); + + if (current is not null && now - current.CreatedAt >= _rotationInterval) + { + await rotationService.RotateSecretAsync(realm, cancellation); + } + await rotationService.PruneSecretsAsync(realm, cancellation); } catch (Exception exception) @@ -31,7 +48,7 @@ await Parallel.ForEachAsync(realms, stoppingToken, async (realm, cancellation) = } }); - await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + await Task.Delay(_rotationInterval, stoppingToken); } } } From f057b53caf2745ce02d0c359eb666d120c433172 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 24 Apr 2026 13:45:26 -0300 Subject: [PATCH 47/47] feature(#22): thiss commit updates CHANGELOG for 4.1.0 release, adding per-realm key rotation and realm-specific .well-known endpoints --- CHANGELOG | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 038c5eb..8f4a9a7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +# 4.1.0 - 2026-04-24 + +this release introduces per-realm key rotation, allowing each realm to manage and rotate its own signing keys independently. + +we also made all .well-known endpoints realm-specific. Accessing these endpoints now requires a realm name as part of the request, ensuring configuration and discovery metadata are resolved within the correct realm context. + # 4.0.0 - 2026-04-20 this release introduces full multi-client support per realm. In previous versions, a realm (tenant) was effectively treated as a single client, but starting in 4.0.0 each realm can manage multiple clients with their own credentials, permissions, flows, redirect uris, and audiences. We also added dedicated client management capabilities and support for multiple token audiences during issuance and validation.